478 lines
17 KiB
TypeScript
478 lines
17 KiB
TypeScript
"use client";
|
||
|
||
import React, { useCallback, useEffect, useMemo, useRef } from "react";
|
||
import {
|
||
GripVertical,
|
||
Loader2,
|
||
MessageSquare,
|
||
Minus,
|
||
Pencil,
|
||
Plus,
|
||
Send,
|
||
Sparkles,
|
||
Trash2,
|
||
X,
|
||
} from "lucide-react";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import { notify } from "@/lib/notifications/store";
|
||
import {
|
||
closeRefine,
|
||
discardDraftChanges,
|
||
openRefine,
|
||
saveRefineSession,
|
||
sendRefineChat,
|
||
sessionHasChanges,
|
||
setChatInput,
|
||
setDraft,
|
||
setRefineOpen,
|
||
setRefineTab,
|
||
} from "@/lib/refine-session/store";
|
||
import { useRefineSession } from "@/lib/refine-session/use-refine-session";
|
||
import {
|
||
SEARCH_INTENTS,
|
||
type ResearchMap,
|
||
type SearchIntent,
|
||
type SuggestedTag,
|
||
} from "@/lib/types/research";
|
||
import { ResearchMapSection } from "@/components/research-map-section";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
type Tab = "edit" | "chat";
|
||
|
||
interface ResearchMapRefinePanelProps {
|
||
topicId: string;
|
||
topicLabel: string;
|
||
}
|
||
|
||
export function ResearchMapRefinePanel({ topicId, topicLabel }: ResearchMapRefinePanelProps) {
|
||
const session = useRefineSession(topicId);
|
||
const chatEndRef = useRef<HTMLDivElement>(null);
|
||
const chatInputRef = useRef<HTMLTextAreaElement>(null);
|
||
|
||
const open = session?.open ?? false;
|
||
const tab = (session?.tab ?? "chat") as Tab;
|
||
const draft = session?.draft ?? null;
|
||
const chatting = session?.chatting ?? false;
|
||
const saving = session?.saving ?? false;
|
||
const chatInput = session?.chatInput ?? "";
|
||
const messages = useMemo(() => session?.messages ?? [], [session?.messages]);
|
||
const hasChanges = sessionHasChanges(topicId);
|
||
|
||
const clearChatInputDom = useCallback(() => {
|
||
if (chatInputRef.current) {
|
||
chatInputRef.current.value = "";
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
chatEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||
}, [messages, chatting]);
|
||
|
||
useEffect(() => {
|
||
if (!chatting && chatInput === "") {
|
||
clearChatInputDom();
|
||
}
|
||
}, [chatting, chatInput, clearChatInputDom]);
|
||
|
||
function handleOpen() {
|
||
if (!draft) {
|
||
notify({ type: "warning", title: "請先分析主題" });
|
||
return;
|
||
}
|
||
openRefine(topicId);
|
||
}
|
||
|
||
function handleClose() {
|
||
if (hasChanges && !confirm("有未套用的變更,確定關閉?")) return;
|
||
closeRefine(topicId, true);
|
||
}
|
||
|
||
function handleDiscard() {
|
||
if (!confirm("捨棄所有未套用變更?")) return;
|
||
discardDraftChanges(topicId);
|
||
}
|
||
|
||
async function handleSave() {
|
||
if (!hasChanges) return;
|
||
await saveRefineSession(topicId);
|
||
}
|
||
|
||
function handleChatSend() {
|
||
const userMsg = chatInput.trim();
|
||
if (!draft || !userMsg || chatting) return;
|
||
clearChatInputDom();
|
||
sendRefineChat(topicId, userMsg);
|
||
}
|
||
|
||
function updateDraft(patch: Partial<ResearchMap>) {
|
||
if (!draft) return;
|
||
setDraft(topicId, { ...draft, ...patch });
|
||
}
|
||
|
||
function updateListItem(
|
||
key: "questions" | "pillars" | "exclusions",
|
||
index: number,
|
||
value: string
|
||
) {
|
||
if (!draft) return;
|
||
const list = [...draft[key]];
|
||
list[index] = value;
|
||
setDraft(topicId, { ...draft, [key]: list });
|
||
}
|
||
|
||
function addListItem(key: "questions" | "pillars" | "exclusions") {
|
||
if (!draft) return;
|
||
setDraft(topicId, { ...draft, [key]: [...draft[key], ""] });
|
||
}
|
||
|
||
function removeListItem(key: "questions" | "pillars" | "exclusions", index: number) {
|
||
if (!draft) return;
|
||
setDraft(topicId, { ...draft, [key]: draft[key].filter((_, i) => i !== index) });
|
||
}
|
||
|
||
function updateTag(index: number, patch: Partial<SuggestedTag>) {
|
||
if (!draft) return;
|
||
const tags = [...draft.suggestedTags];
|
||
tags[index] = { ...tags[index], ...patch };
|
||
setDraft(topicId, { ...draft, suggestedTags: tags });
|
||
}
|
||
|
||
function addTag() {
|
||
if (!draft) return;
|
||
setDraft(topicId, {
|
||
...draft,
|
||
suggestedTags: [
|
||
...draft.suggestedTags,
|
||
{ tag: "", reason: "", searchIntent: "知識" as SearchIntent },
|
||
],
|
||
});
|
||
}
|
||
|
||
function removeTag(index: number) {
|
||
if (!draft) return;
|
||
setDraft(topicId, {
|
||
...draft,
|
||
suggestedTags: draft.suggestedTags.filter((_, i) => i !== index),
|
||
});
|
||
}
|
||
|
||
const engaged = session?.engaged ?? false;
|
||
const showFab = !open && draft !== null && engaged;
|
||
|
||
if (!showFab && !open) return null;
|
||
|
||
return (
|
||
<>
|
||
{showFab && (
|
||
<button
|
||
type="button"
|
||
onClick={handleOpen}
|
||
className="mobile-floating tool-btn fixed right-4 z-50 flex h-10 max-w-[calc(100vw-2rem)] items-center gap-2 rounded-lg border border-border bg-card px-3.5 text-[13px] font-medium text-foreground shadow-lg lg:right-6"
|
||
>
|
||
{chatting ? (
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Pencil className="h-4 w-4" />
|
||
)}
|
||
{chatting ? "微調中…" : "微調"}
|
||
</button>
|
||
)}
|
||
|
||
{open && draft && (
|
||
<div className="mobile-floating tool-card fixed inset-x-4 z-50 flex max-h-[min(70dvh,520px)] w-auto flex-col overflow-hidden shadow-xl sm:inset-x-auto sm:right-6 sm:w-[min(400px,calc(100vw-2rem))]">
|
||
<div className="flex items-center gap-2 border-b border-border bg-muted/60 px-3 py-2.5">
|
||
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||
<div className="min-w-0 flex-1">
|
||
<p className="truncate text-[13px] font-semibold">微調</p>
|
||
<p className="page-lead truncate !mt-0">{topicLabel}</p>
|
||
</div>
|
||
{hasChanges && (
|
||
<span className="shrink-0 rounded-full bg-foreground px-2 py-0.5 text-[10px] text-background">
|
||
未儲存
|
||
</span>
|
||
)}
|
||
{chatting && (
|
||
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-[10px] text-muted-foreground">
|
||
背景執行
|
||
</span>
|
||
)}
|
||
<button
|
||
type="button"
|
||
onClick={() => setRefineOpen(topicId, false)}
|
||
className="rounded-md p-1 text-muted-foreground hover:bg-background"
|
||
title="最小化"
|
||
>
|
||
<Minus className="h-4 w-4" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={handleClose}
|
||
className="rounded-md p-1 text-muted-foreground hover:bg-background"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex border-b border-border">
|
||
<button
|
||
type="button"
|
||
onClick={() => setRefineTab(topicId, "chat")}
|
||
className={cn(
|
||
"flex flex-1 items-center justify-center gap-1.5 py-2 text-[12px] font-medium transition-colors",
|
||
tab === "chat"
|
||
? "border-b-2 border-foreground text-foreground"
|
||
: "text-muted-foreground hover:text-foreground"
|
||
)}
|
||
>
|
||
<MessageSquare className="h-3.5 w-3.5" />
|
||
對話
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setRefineTab(topicId, "edit")}
|
||
className={cn(
|
||
"flex flex-1 items-center justify-center gap-1.5 py-2 text-[12px] font-medium transition-colors",
|
||
tab === "edit"
|
||
? "border-b-2 border-foreground text-foreground"
|
||
: "text-muted-foreground hover:text-foreground"
|
||
)}
|
||
>
|
||
<Pencil className="h-3.5 w-3.5" />
|
||
編輯
|
||
</button>
|
||
</div>
|
||
|
||
<div className="h-[min(420px,55vh)] overflow-y-auto">
|
||
{tab === "chat" ? (
|
||
<div className="flex h-full flex-col">
|
||
<div className="flex-1 space-y-3 overflow-y-auto p-3">
|
||
{messages.length === 0 && (
|
||
<p className="page-lead px-1 py-2">
|
||
描述想改什麼,例如調整受眾、加標籤、刪問題
|
||
</p>
|
||
)}
|
||
{messages.map((msg, i) => (
|
||
<div
|
||
key={i}
|
||
className={cn(
|
||
"max-w-[92%] rounded-lg px-3 py-2 text-[12px] leading-relaxed",
|
||
msg.role === "user"
|
||
? "ml-auto bg-foreground text-background"
|
||
: "bg-muted text-foreground"
|
||
)}
|
||
>
|
||
{msg.content}
|
||
</div>
|
||
))}
|
||
{chatting && (
|
||
<div className="flex items-center gap-2 text-[12px] text-muted-foreground">
|
||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||
調整中…
|
||
</div>
|
||
)}
|
||
<div ref={chatEndRef} />
|
||
</div>
|
||
<div className="border-t border-border p-2.5">
|
||
<div className="flex gap-2">
|
||
<Textarea
|
||
ref={chatInputRef}
|
||
value={chatInput}
|
||
onChange={(e) => setChatInput(topicId, e.target.value)}
|
||
rows={2}
|
||
placeholder="描述想改什麼…"
|
||
className="min-h-0 resize-none text-[12px]"
|
||
disabled={chatting}
|
||
onKeyDown={(e) => {
|
||
if (e.nativeEvent.isComposing) return;
|
||
if (e.key === "Enter" && !e.shiftKey) {
|
||
e.preventDefault();
|
||
handleChatSend();
|
||
}
|
||
}}
|
||
/>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
className="h-auto shrink-0 px-2.5"
|
||
onClick={handleChatSend}
|
||
disabled={chatting || !chatInput.trim()}
|
||
>
|
||
{chatting ? (
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Send className="h-4 w-4" />
|
||
)}
|
||
</Button>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-2 p-3">
|
||
<ResearchMapSection title="受眾" collapsible={false}>
|
||
<Textarea
|
||
value={draft.audienceSummary}
|
||
onChange={(e) => updateDraft({ audienceSummary: e.target.value })}
|
||
rows={3}
|
||
className="text-[12px]"
|
||
/>
|
||
</ResearchMapSection>
|
||
<ResearchMapSection title="目標" collapsible={false}>
|
||
<Textarea
|
||
value={draft.contentGoal}
|
||
onChange={(e) => updateDraft({ contentGoal: e.target.value })}
|
||
rows={2}
|
||
className="text-[12px]"
|
||
/>
|
||
</ResearchMapSection>
|
||
<ResearchMapSection title="問題" collapsible={false}>
|
||
{draft.questions.map((q, i) => (
|
||
<div key={i} className="flex gap-1.5">
|
||
<Input
|
||
value={q}
|
||
onChange={(e) => updateListItem("questions", i, e.target.value)}
|
||
className="text-[12px]"
|
||
/>
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
className="shrink-0 px-2"
|
||
onClick={() => removeListItem("questions", i)}
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</Button>
|
||
</div>
|
||
))}
|
||
<Button size="sm" variant="outline" onClick={() => addListItem("questions")}>
|
||
<Plus className="h-3.5 w-3.5" />
|
||
新增
|
||
</Button>
|
||
</ResearchMapSection>
|
||
<ResearchMapSection title="支柱" collapsible={false}>
|
||
{draft.pillars.map((p, i) => (
|
||
<div key={i} className="flex gap-1.5">
|
||
<Input
|
||
value={p}
|
||
onChange={(e) => updateListItem("pillars", i, e.target.value)}
|
||
className="text-[12px]"
|
||
/>
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
className="shrink-0 px-2"
|
||
onClick={() => removeListItem("pillars", i)}
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</Button>
|
||
</div>
|
||
))}
|
||
<Button size="sm" variant="outline" onClick={() => addListItem("pillars")}>
|
||
<Plus className="h-3.5 w-3.5" />
|
||
新增
|
||
</Button>
|
||
</ResearchMapSection>
|
||
<ResearchMapSection title="排除" collapsible={false}>
|
||
{draft.exclusions.map((ex, i) => (
|
||
<div key={i} className="flex gap-1.5">
|
||
<Input
|
||
value={ex}
|
||
onChange={(e) => updateListItem("exclusions", i, e.target.value)}
|
||
className="text-[12px]"
|
||
/>
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
className="shrink-0 px-2"
|
||
onClick={() => removeListItem("exclusions", i)}
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</Button>
|
||
</div>
|
||
))}
|
||
<Button size="sm" variant="outline" onClick={() => addListItem("exclusions")}>
|
||
<Plus className="h-3.5 w-3.5" />
|
||
新增
|
||
</Button>
|
||
</ResearchMapSection>
|
||
<ResearchMapSection title="標籤" collapsible={false}>
|
||
{draft.suggestedTags.map((item, i) => (
|
||
<div key={i} className="space-y-1.5 rounded-md border border-border p-2">
|
||
<div className="flex gap-1.5">
|
||
<Input
|
||
value={item.tag}
|
||
onChange={(e) => updateTag(i, { tag: e.target.value })}
|
||
placeholder="標籤"
|
||
className="text-[12px]"
|
||
/>
|
||
<select
|
||
value={item.searchIntent ?? "知識"}
|
||
onChange={(e) =>
|
||
updateTag(i, { searchIntent: e.target.value as SearchIntent })
|
||
}
|
||
className="h-9 shrink-0 rounded-lg border border-border bg-background px-2 text-[11px]"
|
||
>
|
||
{SEARCH_INTENTS.map((intent) => (
|
||
<option key={intent} value={intent}>
|
||
{intent}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
className="shrink-0 px-2"
|
||
onClick={() => removeTag(i)}
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</Button>
|
||
</div>
|
||
<Input
|
||
value={item.reason}
|
||
onChange={(e) => updateTag(i, { reason: e.target.value })}
|
||
placeholder="理由"
|
||
className="text-[12px]"
|
||
/>
|
||
</div>
|
||
))}
|
||
<Button size="sm" variant="outline" onClick={addTag}>
|
||
<Plus className="h-3.5 w-3.5" />
|
||
新增
|
||
</Button>
|
||
</ResearchMapSection>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2 border-t border-border bg-muted/40 px-3 py-2.5">
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
onClick={handleDiscard}
|
||
disabled={!hasChanges || saving}
|
||
className="text-[12px]"
|
||
>
|
||
捨棄
|
||
</Button>
|
||
<div className="flex-1" />
|
||
<Button
|
||
size="sm"
|
||
onClick={handleSave}
|
||
disabled={!hasChanges || saving}
|
||
className="text-[12px]"
|
||
>
|
||
{saving ? (
|
||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||
) : (
|
||
<Sparkles className="h-3.5 w-3.5" />
|
||
)}
|
||
{saving ? "儲存中…" : "套用"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
}
|