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

478 lines
17 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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