import { notify } from "@/lib/notifications/store"; import type { ResearchMap } from "@/lib/types/research"; import { listPersistedTopicIds, loadPersistedSession, removePersistedSession, savePersistedSession, } from "./storage"; import type { PersistedRefineSession, RefineChatMessage, RefineSession, RefineTab } from "./types"; type Listener = () => void; const listeners = new Set(); const sessions = new Map(); let activeTopicId: string | null = null; let snapshotVersion = 0; const applyCallbacks = new Map void>(); const inFlight = new Map>(); let hydrated = false; function cloneMap(map: ResearchMap): ResearchMap { return JSON.parse(JSON.stringify(map)) as ResearchMap; } function mapsEqual(a: ResearchMap, b: ResearchMap): boolean { return JSON.stringify(a) === JSON.stringify(b); } function emit() { snapshotVersion += 1; listeners.forEach((fn) => fn()); } function toPersisted(session: RefineSession): PersistedRefineSession { return { topicId: session.topicId, topicLabel: session.topicLabel, draft: session.draft, baseline: session.baseline, messages: [], tab: session.tab, open: session.open, engaged: session.engaged, chatInput: "", updatedAt: session.updatedAt, }; } function persistSession(session: RefineSession) { savePersistedSession(toPersisted(session)); } function patchSession(topicId: string, patch: Partial) { const current = sessions.get(topicId); if (!current) return; const next: RefineSession = { ...current, ...patch, updatedAt: new Date().toISOString(), }; sessions.set(topicId, next); persistSession(next); emit(); } /** 僅在 client mount 後呼叫,避免 SSR hydration 與 localStorage 不一致。 */ export function hydrateRefineSessionsFromStorage(): void { if (hydrated || typeof window === "undefined") return; hydrated = true; for (const topicId of listPersistedTopicIds()) { const persisted = loadPersistedSession(topicId); if (!persisted?.draft || !persisted.baseline) continue; const hasUnsavedDraft = !mapsEqual(persisted.draft, persisted.baseline); if (!hasUnsavedDraft) { removePersistedSession(topicId); continue; } sessions.set(topicId, { ...persisted, messages: [], chatInput: "", engaged: true, open: false, chatting: false, saving: false, }); } emit(); } export function subscribe(listener: Listener): () => void { listeners.add(listener); return () => listeners.delete(listener); } export function getSnapshotVersion(): number { return snapshotVersion; } export function getSession(topicId: string): RefineSession | null { return sessions.get(topicId) ?? null; } export function getActiveTopicId(): string | null { return activeTopicId; } export function getDisplayTopicId(): string | null { for (const [id, session] of sessions) { if (session.chatting) return id; } for (const [id, session] of sessions) { if (session.open) return id; } if (activeTopicId) { const active = sessions.get(activeTopicId); if (active?.engaged) return activeTopicId; } for (const [id, session] of sessions) { if (session.engaged) return id; } return null; } export function hasVisibleRefineSession(): boolean { for (const session of sessions.values()) { if (session.open || session.chatting) return true; if ( session.engaged && session.draft && session.baseline && !mapsEqual(session.draft, session.baseline) ) { return true; } } return false; } export function setActiveTopicId(topicId: string | null) { if (activeTopicId === topicId) return; activeTopicId = topicId; emit(); } export function registerApplyCallback( topicId: string, callback: (map: ResearchMap) => void ): () => void { applyCallbacks.set(topicId, callback); return () => { if (applyCallbacks.get(topicId) === callback) { applyCallbacks.delete(topicId); } }; } export function ensureSession( topicId: string, researchMap: ResearchMap, topicLabel: string ): RefineSession { const existing = sessions.get(topicId); if (existing) { if (existing.topicLabel !== topicLabel) { patchSession(topicId, { topicLabel }); } return sessions.get(topicId)!; } const persisted = loadPersistedSession(topicId); if (persisted?.draft && persisted.baseline) { const hasUnsavedDraft = !mapsEqual(persisted.draft, persisted.baseline); if (hasUnsavedDraft) { const session: RefineSession = { ...persisted, topicLabel: persisted.topicLabel || topicLabel, messages: [], chatInput: "", engaged: true, open: false, chatting: false, saving: false, }; sessions.set(topicId, session); emit(); return session; } removePersistedSession(topicId); } const session: RefineSession = { topicId, topicLabel, draft: cloneMap(researchMap), baseline: cloneMap(researchMap), messages: [], tab: "chat", open: false, engaged: false, chatting: false, chatInput: "", saving: false, updatedAt: new Date().toISOString(), }; sessions.set(topicId, session); persistSession(session); emit(); return session; } export function syncResearchMap( topicId: string, researchMap: ResearchMap, topicLabel: string ) { const existing = sessions.get(topicId); if (!existing) return; const hasLocalChanges = existing.draft && existing.baseline ? !mapsEqual(existing.draft, existing.baseline) : false; if (hasLocalChanges || existing.chatting) { if (existing.topicLabel !== topicLabel) { patchSession(topicId, { topicLabel }); } return; } patchSession(topicId, { topicLabel, draft: cloneMap(researchMap), baseline: cloneMap(researchMap), messages: [], chatInput: "", }); } export function openRefine( topicId: string, opts?: { researchMap: ResearchMap; topicLabel: string } ) { if (opts) { const map = cloneMap(opts.researchMap); const existing = sessions.get(topicId); const session: RefineSession = { topicId, topicLabel: opts.topicLabel, draft: map, baseline: map, messages: [], tab: "chat", open: true, engaged: true, chatting: existing?.chatting ?? false, chatInput: "", saving: false, updatedAt: new Date().toISOString(), }; sessions.set(topicId, session); persistSession(session); emit(); return; } if (!sessions.has(topicId)) return; patchSession(topicId, { open: true, engaged: true }); } export function setRefineOpen(topicId: string, open: boolean) { patchSession(topicId, { open }); } export function setRefineTab(topicId: string, tab: RefineTab) { patchSession(topicId, { tab }); } export function setChatInput(topicId: string, chatInput: string) { patchSession(topicId, { chatInput }); } export function setDraft(topicId: string, draft: ResearchMap) { patchSession(topicId, { draft }); } export function updateDraft(topicId: string, patch: Partial) { const session = sessions.get(topicId); if (!session?.draft) return; patchSession(topicId, { draft: { ...session.draft, ...patch } }); } export function discardDraftChanges(topicId: string) { const session = sessions.get(topicId); if (!session?.baseline) return; patchSession(topicId, { draft: cloneMap(session.baseline) }); } export function closeRefine(topicId: string, force = false) { const session = sessions.get(topicId); if (!session) return false; const hasChanges = session.draft && session.baseline ? !mapsEqual(session.draft, session.baseline) : false; if (hasChanges && !force) return false; clearRefineSession(topicId); return true; } export function clearRefineSession(topicId: string) { sessions.delete(topicId); removePersistedSession(topicId); emit(); } async function runRefineChat(topicId: string, userMsg: string, history: RefineChatMessage[]) { const session = sessions.get(topicId); if (!session?.draft) return; const wasOpen = session.open; const label = session.topicLabel; try { const res = await fetch("/api/refine-research-map", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ topicId, researchMap: session.draft, message: userMsg, history, }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error ?? "AI 微調失敗"); const current = sessions.get(topicId); if (!current) return; patchSession(topicId, { draft: data.researchMap as ResearchMap, messages: [...current.messages, { role: "assistant", content: data.reply }], chatting: false, }); const after = sessions.get(topicId); if (!after) return; const shouldNotify = !wasOpen || activeTopicId !== topicId; if (shouldNotify) { notify({ type: "success", title: "AI 微調完成", message: label, href: `/scans/${topicId}`, }); } } catch (err) { const msg = err instanceof Error ? err.message : "AI 微調失敗"; const current = sessions.get(topicId); if (!current) return; patchSession(topicId, { messages: [...current.messages, { role: "assistant", content: `⚠️ ${msg}` }], chatting: false, }); notify({ type: "error", title: "AI 微調失敗", message: msg, href: `/scans/${topicId}`, }); } finally { inFlight.delete(topicId); } } export function sendRefineChat(topicId: string, userMsg: string) { const session = sessions.get(topicId); if (!session?.draft || !userMsg.trim() || session.chatting) return; const trimmed = userMsg.trim(); const history = session.messages; patchSession(topicId, { chatInput: "", messages: [...session.messages, { role: "user", content: trimmed }], chatting: true, engaged: true, }); const promise = runRefineChat(topicId, trimmed, history); inFlight.set(topicId, promise); } export function isRefineChatting(topicId: string): boolean { return sessions.get(topicId)?.chatting ?? false; } export async function saveRefineSession(topicId: string): Promise { const session = sessions.get(topicId); if (!session?.draft || !session.baseline) return false; if (mapsEqual(session.draft, session.baseline)) return false; patchSession(topicId, { saving: true }); try { const res = await fetch("/api/topics", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: topicId, researchMap: session.draft }), }); const data = await res.json(); if (!res.ok) { notify({ type: "error", title: "儲存失敗", message: data.error }); patchSession(topicId, { saving: false }); return false; } const saved = cloneMap(session.draft); patchSession(topicId, { baseline: saved, draft: saved, saving: false, messages: [], chatInput: "", }); applyCallbacks.get(topicId)?.(saved); notify({ type: "success", title: "研究地圖已更新", message: session.topicLabel }); return true; } catch (err) { const msg = err instanceof Error ? err.message : "儲存失敗"; notify({ type: "error", title: "儲存失敗", message: msg }); patchSession(topicId, { saving: false }); return false; } } export function sessionHasChanges(topicId: string): boolean { const session = sessions.get(topicId); if (!session?.draft || !session.baseline) return false; return !mapsEqual(session.draft, session.baseline); }