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>
|
|||
|
|
)}
|
|||
|
|
</>
|
|||
|
|
);
|
|||
|
|
}
|