haixunMaster/components/research-map-refine-panel.tsx

478 lines
17 KiB
TypeScript
Raw Normal View History

2026-06-21 12:50:31 +00:00
"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>
)}
</>
);
}