diff --git a/apps/web/src/pages/ai-chat-page.tsx b/apps/web/src/pages/ai-chat-page.tsx index 607e71d..746fca7 100644 --- a/apps/web/src/pages/ai-chat-page.tsx +++ b/apps/web/src/pages/ai-chat-page.tsx @@ -19,6 +19,12 @@ import { type WebAiChannel, WebAiApiError } from "@/services/ai-api"; +import { + deleteLocalAiChatSession, + listLocalAiChatSessions, + saveLocalAiChatSession, + type LocalAiChatMessageRecord +} from "@/services/local-ai-chat-repo"; import type { WebSession } from "@/services/session-storage"; import { CHANNEL_META, CHANNEL_ORDER } from "@/components/ai/ai-shared"; @@ -26,12 +32,7 @@ type AiChatPageProps = { session: WebSession; }; -type AiMessageRecord = { - id: string; - role: "user" | "assistant" | "system"; - content: string; - meta?: string; -}; +type AiMessageRecord = LocalAiChatMessageRecord; function createEmptyMessages(): Record { return { @@ -78,6 +79,7 @@ export function AiChatPage({ session }: AiChatPageProps) { const [draftMessage, setDraftMessage] = useState(""); const [sending, setSending] = useState(false); const [loadError, setLoadError] = useState(null); + const [historyLoaded, setHistoryLoaded] = useState(false); const messagesEndRef = useRef(null); const bindingMap = useMemo(() => { @@ -112,6 +114,68 @@ export function AiChatPage({ session }: AiChatPageProps) { void loadBindings(); }, [loadBindings]); + useEffect(() => { + let cancelled = false; + + async function loadLocalHistory(): Promise { + try { + const records = await listLocalAiChatSessions(session.user.id); + if (cancelled) { + return; + } + + const nextMessages = createEmptyMessages(); + const nextSessionIds = createEmptySessionIds(); + + for (const record of records) { + nextMessages[record.channel] = record.messages; + if (record.sessionId) { + nextSessionIds[record.channel] = record.sessionId; + } + } + + setMessagesByChannel(nextMessages); + setSessionIds(nextSessionIds); + } finally { + if (!cancelled) { + setHistoryLoaded(true); + } + } + } + + setHistoryLoaded(false); + void loadLocalHistory(); + + return () => { + cancelled = true; + }; + }, [session.user.id]); + + useEffect(() => { + if (!historyLoaded) { + return; + } + + void Promise.all( + CHANNEL_ORDER.map(async (channel) => { + const messages = messagesByChannel[channel]; + const sessionId = sessionIds[channel] ?? null; + + if (messages.length === 0 && sessionId === null) { + await deleteLocalAiChatSession(session.user.id, channel); + return; + } + + await saveLocalAiChatSession({ + userId: session.user.id, + channel, + sessionId, + messages + }); + }) + ); + }, [historyLoaded, messagesByChannel, session.user.id, sessionIds]); + useEffect(() => { messagesEndRef.current?.scrollIntoView({ block: "end", diff --git a/apps/web/src/services/local-ai-chat-repo.ts b/apps/web/src/services/local-ai-chat-repo.ts new file mode 100644 index 0000000..e37c26f --- /dev/null +++ b/apps/web/src/services/local-ai-chat-repo.ts @@ -0,0 +1,91 @@ +import { localDb, type LocalAiChatSessionRecord } from "@/services/local-db"; +import type { WebAiChannel } from "@/services/ai-api"; + +export type LocalAiChatMessageRecord = { + id: string; + role: "user" | "assistant" | "system"; + content: string; + meta?: string; +}; + +export type SaveLocalAiChatSessionInput = { + userId: string; + channel: WebAiChannel; + sessionId: string | null; + messages: LocalAiChatMessageRecord[]; +}; + +export type LocalAiChatSessionSnapshot = { + channel: WebAiChannel; + sessionId: string | null; + messages: LocalAiChatMessageRecord[]; +}; + +function createSessionKey(userId: string, channel: WebAiChannel): string { + return `${userId}:${channel}`; +} + +function parseMessages(messagesJson: string): LocalAiChatMessageRecord[] { + try { + const parsed = JSON.parse(messagesJson) as unknown; + if (!Array.isArray(parsed)) { + return []; + } + + return parsed.filter((item): item is LocalAiChatMessageRecord => { + if (!item || typeof item !== "object") { + return false; + } + + const record = item as Record; + return ( + typeof record["id"] === "string" && + (record["role"] === "user" || + record["role"] === "assistant" || + record["role"] === "system") && + typeof record["content"] === "string" && + (record["meta"] === undefined || typeof record["meta"] === "string") + ); + }); + } catch { + return []; + } +} + +function toSnapshot(record: LocalAiChatSessionRecord): LocalAiChatSessionSnapshot { + return { + channel: record.channel, + sessionId: record.sessionId, + messages: parseMessages(record.messagesJson) + }; +} + +export async function listLocalAiChatSessions( + userId: string +): Promise { + const records = await localDb.aiChatSessions.where("userId").equals(userId).toArray(); + return records.map(toSnapshot); +} + +export async function saveLocalAiChatSession( + input: SaveLocalAiChatSessionInput +): Promise { + const record: LocalAiChatSessionRecord = { + key: createSessionKey(input.userId, input.channel), + userId: input.userId, + channel: input.channel, + sessionId: input.sessionId, + messagesJson: JSON.stringify(input.messages), + updatedAt: Date.now() + }; + + await localDb.aiChatSessions.put(record); + return record; +} + +export async function deleteLocalAiChatSession( + userId: string, + channel: WebAiChannel +): Promise { + await localDb.aiChatSessions.delete(createSessionKey(userId, channel)); +} diff --git a/apps/web/src/services/local-db.ts b/apps/web/src/services/local-db.ts index 9fa0d97..5cb8e12 100644 --- a/apps/web/src/services/local-db.ts +++ b/apps/web/src/services/local-db.ts @@ -69,12 +69,22 @@ export type LocalSyncInboxRecord = { appliedAt: number | null; }; +export type LocalAiChatSessionRecord = { + key: string; + userId: string; + channel: "USER_KEY" | "ASTRBOT" | "PUBLIC_POOL"; + sessionId: string | null; + messagesJson: string; + updatedAt: number; +}; + class TodoLocalDb extends Dexie { declare tasks: Table; declare opLogs: Table; declare taskDrafts: Table; declare syncStates: Table; declare syncInbox: Table; + declare aiChatSessions: Table; constructor() { super("todolist-web-db"); @@ -117,11 +127,21 @@ class TodoLocalDb extends Dexie { }); }); + this.version(5).stores({ + tasks: "&id,userId,status,priority,ddlAt,updatedAt,deletedAt", + op_logs: "&opId,entityId,entityType,action,clientTs,syncedAt", + task_drafts: "&taskId,userId,updatedAt", + sync_states: "&userId,updatedAt,lastSyncedAt", + sync_inbox: "&opId,userId,entityId,serverTs,appliedAt", + ai_chat_sessions: "&key,userId,channel,updatedAt" + }); + this.tasks = this.table("tasks"); this.opLogs = this.table("op_logs"); this.taskDrafts = this.table("task_drafts"); this.syncStates = this.table("sync_states"); this.syncInbox = this.table("sync_inbox"); + this.aiChatSessions = this.table("ai_chat_sessions"); } }