haixunMaster/lib/refine-session/store.ts

453 lines
12 KiB
TypeScript
Raw Normal View History

2026-06-21 12:50:31 +00:00
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<Listener>();
const sessions = new Map<string, RefineSession>();
let activeTopicId: string | null = null;
let snapshotVersion = 0;
const applyCallbacks = new Map<string, (map: ResearchMap) => void>();
const inFlight = new Map<string, Promise<void>>();
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<RefineSession>) {
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<ResearchMap>) {
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<boolean> {
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);
}