453 lines
12 KiB
TypeScript
453 lines
12 KiB
TypeScript
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);
|
|
} |