diff --git a/apps/web/src/components/ai-sidebar/AiSidebar.tsx b/apps/web/src/components/ai-sidebar/AiSidebar.tsx index 0e2a0406..d952a041 100644 --- a/apps/web/src/components/ai-sidebar/AiSidebar.tsx +++ b/apps/web/src/components/ai-sidebar/AiSidebar.tsx @@ -49,6 +49,8 @@ import { companionLocale } from "@/lib/companion-locale"; import { AI_SIDEBAR_ADAPTER_KEY, AI_SIDEBAR_ADAPTER_PATH_KEY, + AI_SIDEBAR_LOCAL_THREAD_KEY, + AI_SIDEBAR_LOCAL_THREADS_KEY, AI_SIDEBAR_OPEN_KEY, AI_SIDEBAR_SOURCE_KEY, AI_SIDEBAR_THREAD_KEY, @@ -60,6 +62,14 @@ import { type DesktopAcpAdapterId, type DesktopAcpEvent, } from "@/lib/desktop-acp"; +import { + chatThreadsFromTurns, + localAgentTranscript, + parseLocalAgentTurns, + resolveLocalAgentThreadId, + saveLocalAgentTurns, + type ChatThreadSummary, +} from "@/lib/local-agent-threads"; import { sidebarRevealTransition } from "@/lib/motion"; import { SELECTION_AI_LANGUAGES, @@ -115,6 +125,7 @@ const MAX_LOCAL_IMAGES = 8; type LocalTurn = { id: string; + threadId: string; message: string; response: string; reasoning: string; @@ -122,6 +133,7 @@ type LocalTurn = { images: LocalImage[]; attachments: Array<{ id: string; filename: string; mediaType: string; byteLength: number }>; status: "running" | "completed" | "failed" | "cancelled"; + createdAt: string; }; type ActiveTurn = { @@ -229,33 +241,11 @@ const writeAiSidebarThread = (threadId: string) => { writeStorage(AI_SIDEBAR_THREAD_KEY, threadId); }; -type SidebarThread = { id: string; title: string; updatedAt: string }; - const sidebarThreadLabel = (title: string) => { const clause = title.split(/[,。!?!?;;::\n]/)[0]?.trim() || title; return clause.length >= 2 ? clause : title; }; -const sidebarThreadsFromTurns = (turns: CompanionTurn[]): SidebarThread[] => { - const byId = new Map(); - for (const turn of turns) { - const title = turn.message.replace(/\s+/g, " ").trim(); - const current = byId.get(turn.threadId); - if (!current) { - byId.set(turn.threadId, { id: turn.threadId, title, updatedAt: turn.createdAt, oldestAt: turn.createdAt }); - continue; - } - if (turn.createdAt > current.updatedAt) current.updatedAt = turn.createdAt; - if (turn.createdAt < current.oldestAt && title) { - current.oldestAt = turn.createdAt; - current.title = title; - } - } - return [...byId.values()] - .sort((left, right) => (left.updatedAt < right.updatedAt ? 1 : -1)) - .map(({ oldestAt: _oldestAt, ...thread }) => thread); -}; - function AiSidebarThreadMenu({ threads, threadId, @@ -263,7 +253,7 @@ function AiSidebarThreadMenu({ onSelect, onCreate, }: { - threads: SidebarThread[]; + threads: ChatThreadSummary[]; threadId: string; title: string; onSelect: (threadId: string) => void; @@ -279,7 +269,7 @@ function AiSidebarThreadMenu({ const older = visible.filter((thread) => Date.parse(thread.updatedAt) < cutoff); const searchOnRecent = recent.length > 0 || older.length === 0; - const renderGroup = (label: string, items: SidebarThread[], withSearch: boolean) => { + const renderGroup = (label: string, items: ChatThreadSummary[], withSearch: boolean) => { if (!items.length) return null; return (
@@ -318,6 +308,7 @@ function AiSidebarThreadMenu({
{error ?

{error}

: null} - + {loading && visibleCompanion ?

{t("common.loading")}

: null} {!loading && !visibleTurns.length ? ( @@ -1487,7 +1557,7 @@ function AiSidebarSession({ {renderActions(turn.id)} - )) : localTurns.map((turn) => ( + )) : localThreadTurns.map((turn) => (
{turn.message} diff --git a/apps/web/src/lib/desktop-acp.ts b/apps/web/src/lib/desktop-acp.ts index 641ecd7b..15836f32 100644 --- a/apps/web/src/lib/desktop-acp.ts +++ b/apps/web/src/lib/desktop-acp.ts @@ -48,6 +48,8 @@ export type DesktopAcpEvent = export const AI_SIDEBAR_WIDTH_KEY = "edgeever.aiSidebar.width"; export const AI_SIDEBAR_OPEN_KEY = "edgeever.aiSidebar.open"; export const AI_SIDEBAR_THREAD_KEY = "edgeever.aiSidebar.thread"; +export const AI_SIDEBAR_LOCAL_THREAD_KEY = "edgeever.aiSidebar.localThread"; +export const AI_SIDEBAR_LOCAL_THREADS_KEY = "edgeever.aiSidebar.localThreads"; export const AI_SIDEBAR_SOURCE_KEY = "edgeever.aiSidebar.source"; export const AI_SIDEBAR_ADAPTER_KEY = "edgeever.aiSidebar.adapterId"; export const AI_SIDEBAR_ADAPTER_PATH_KEY = "edgeever.aiSidebar.adapterPath"; diff --git a/apps/web/src/lib/local-agent-threads.test.mjs b/apps/web/src/lib/local-agent-threads.test.mjs new file mode 100644 index 00000000..fa98279f --- /dev/null +++ b/apps/web/src/lib/local-agent-threads.test.mjs @@ -0,0 +1,147 @@ +import { describe, expect, test } from "bun:test"; +import { + LOCAL_AGENT_THREAD_LIMIT, + chatThreadsFromTurns, + localAgentTranscript, + parseLocalAgentTurns, + resolveLocalAgentThreadId, + saveLocalAgentTurns, + serializeLocalAgentTurns, +} from "./local-agent-threads.ts"; + +const threadA = "11111111-1111-4111-8111-111111111111"; +const threadB = "22222222-2222-4222-8222-222222222222"; +const turnA = "33333333-3333-4333-8333-333333333333"; +const turnB = "44444444-4444-4444-8444-444444444444"; + +const turn = (overrides = {}) => ({ + id: turnA, + threadId: threadA, + message: "总结这篇笔记", + response: "这是一份摘要", + reasoning: "先看标题", + tools: [{ id: "tool-1", name: "search", status: "done", title: "Search notes" }], + attachments: [{ id: "file-1", filename: "notes.txt", mediaType: "text/plain", byteLength: 12 }], + images: [{ id: "img-1", mediaType: "image/png", base64: "AAAA" }], + status: "completed", + createdAt: "2026-09-02T00:00:00.000Z", + ...overrides, +}); + +describe("local agent threads", () => { + test("keeps the chat text and drops generated image bytes", () => { + const stored = JSON.parse(serializeLocalAgentTurns([turn()])); + expect(stored.version).toBe(1); + expect(stored.turns).toEqual([{ + id: turnA, + threadId: threadA, + message: "总结这篇笔记", + response: "这是一份摘要", + reasoning: "先看标题", + tools: [{ id: "tool-1", name: "search", status: "done", title: "Search notes" }], + attachments: [{ id: "file-1", filename: "notes.txt", mediaType: "text/plain", byteLength: 12 }], + status: "completed", + createdAt: "2026-09-02T00:00:00.000Z", + }]); + expect(JSON.stringify(stored)).not.toContain("AAAA"); + expect(parseLocalAgentTurns(JSON.stringify(stored))).toEqual(stored.turns); + }); + + test("cancels a turn that was still running when the transcript was saved", () => { + const raw = serializeLocalAgentTurns([turn({ status: "running", response: "" })]); + expect(JSON.parse(raw).turns[0].status).toBe("running"); + expect(parseLocalAgentTurns(raw)[0].status).toBe("cancelled"); + }); + + test("drops malformed transcripts and unknown store versions", () => { + expect(parseLocalAgentTurns("not json")).toEqual([]); + expect(parseLocalAgentTurns(JSON.stringify({ version: 2, turns: [turn()] }))).toEqual([]); + expect(parseLocalAgentTurns(JSON.stringify({ + version: 1, + turns: [turn({ id: "nope" }), turn({ message: " " }), null], + }))).toEqual([]); + }); + + test("keeps the newest chats when the transcript exceeds the thread cap", () => { + const turns = Array.from({ length: LOCAL_AGENT_THREAD_LIMIT + 2 }, (_, index) => turn({ + id: `55555555-5555-4555-8555-${String(index).padStart(12, "0")}`, + threadId: `66666666-6666-4666-8666-${String(index).padStart(12, "0")}`, + message: `chat ${index}`, + createdAt: new Date(Date.UTC(2026, 0, index + 1)).toISOString(), + })); + const stored = parseLocalAgentTurns(serializeLocalAgentTurns(turns)); + expect(stored).toHaveLength(LOCAL_AGENT_THREAD_LIMIT); + expect(stored.some((item) => item.message === "chat 0")).toBe(false); + expect(stored.some((item) => item.message === `chat ${LOCAL_AGENT_THREAD_LIMIT + 1}`)).toBe(true); + }); + + test("names a chat from its first message and orders newer chats first", () => { + const threads = chatThreadsFromTurns([ + turn({ message: "第二句", createdAt: "2026-09-03T00:00:00.000Z" }), + turn({ id: turnB, message: "第一句,后面还有", createdAt: "2026-09-01T00:00:00.000Z" }), + turn({ + id: turnB, + threadId: threadB, + message: "另一段会话", + createdAt: "2026-09-04T00:00:00.000Z", + }), + ]); + expect(threads.map((item) => item.title)).toEqual(["另一段会话", "第一句,后面还有"]); + }); + + test("continues only the selected chat and skips a running turn", () => { + const transcript = localAgentTranscript([ + turn({ message: "第一问", response: "第一答", createdAt: "2026-09-01T00:00:00.000Z" }), + turn({ + id: turnB, + message: "正在问", + response: "", + status: "running", + createdAt: "2026-09-03T00:00:00.000Z", + }), + turn({ + id: turnB, + threadId: threadB, + message: "别带上这段", + response: "另一会话", + createdAt: "2026-09-04T00:00:00.000Z", + }), + ], threadA); + expect(transcript).toContain("User: 第一问"); + expect(transcript).toContain("Assistant: 第一答"); + expect(transcript).not.toContain("正在问"); + expect(transcript).not.toContain("别带上这段"); + }); + + test("keeps a stored thread id and otherwise opens the latest chat", () => { + const turns = [ + turn({ createdAt: "2026-09-01T00:00:00.000Z" }), + turn({ id: turnB, threadId: threadB, createdAt: "2026-09-05T00:00:00.000Z" }), + ]; + expect(resolveLocalAgentThreadId(threadA, turns)).toBe(threadA); + expect(resolveLocalAgentThreadId("nope", turns)).toBe(threadB); + expect(resolveLocalAgentThreadId(null, [])).toBeNull(); + }); + + test("drops the oldest chat when storage rejects the full transcript", () => { + const saved = []; + const storage = { + setItem(_key, value) { + if (value.includes("old chat")) throw new Error("quota"); + saved.push(value); + }, + }; + saveLocalAgentTurns(storage, "threads", [ + turn({ message: "old chat", createdAt: "2026-09-01T00:00:00.000Z" }), + turn({ + id: turnB, + threadId: threadB, + message: "new chat", + createdAt: "2026-09-02T00:00:00.000Z", + }), + ]); + expect(saved).toHaveLength(1); + expect(saved[0]).toContain("new chat"); + expect(saved[0]).not.toContain("old chat"); + }); +}); diff --git a/apps/web/src/lib/local-agent-threads.ts b/apps/web/src/lib/local-agent-threads.ts new file mode 100644 index 00000000..10540293 --- /dev/null +++ b/apps/web/src/lib/local-agent-threads.ts @@ -0,0 +1,236 @@ +export const LOCAL_AGENT_THREAD_LIMIT = 30; +export const LOCAL_AGENT_TURN_LIMIT = 40; +const LOCAL_AGENT_TEXT_LIMIT = 12_000; +const LOCAL_AGENT_REASONING_LIMIT = 4_000; +const LOCAL_AGENT_TRANSCRIPT_TURNS = 8; +const LOCAL_AGENT_TRANSCRIPT_LINE = 1_000; +const LOCAL_AGENT_TRANSCRIPT_LIMIT = 4_000; +const LOCAL_AGENT_STORE_VERSION = 1; + +const THREAD_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export type LocalAgentTurnStatus = "running" | "completed" | "failed" | "cancelled"; + +export type LocalAgentToolRecord = { id: string; name: string; status: string; title?: string }; + +export type LocalAgentAttachmentRecord = { + id: string; + filename: string; + mediaType: string; + byteLength: number; +}; + +// Pictures stay on the live turn only. The stored transcript is text, so a reload does not keep generated images. +export type LocalAgentTurnRecord = { + id: string; + threadId: string; + message: string; + response: string; + reasoning: string; + tools: LocalAgentToolRecord[]; + attachments: LocalAgentAttachmentRecord[]; + status: LocalAgentTurnStatus; + createdAt: string; +}; + +export type ChatThreadSummary = { id: string; title: string; updatedAt: string }; + +type StorageLike = { setItem: (key: string, value: string) => void }; + +const clip = (value: unknown, max: number) => (typeof value === "string" ? value.slice(0, max) : ""); + +const isThreadId = (value: unknown): value is string => typeof value === "string" && THREAD_ID_PATTERN.test(value); + +const isStatus = (value: unknown): value is LocalAgentTurnStatus => ( + value === "running" || value === "completed" || value === "failed" || value === "cancelled" +); + +const cleanTools = (value: unknown): LocalAgentToolRecord[] => { + if (!Array.isArray(value)) return []; + const tools: LocalAgentToolRecord[] = []; + for (const item of value.slice(-12)) { + if (!item || typeof item !== "object") continue; + const tool = item as { id?: unknown; name?: unknown; status?: unknown; title?: unknown }; + const name = clip(tool.name, 120); + const status = clip(tool.status, 40); + if (!name || !status) continue; + const title = clip(tool.title, 200); + tools.push({ + id: clip(tool.id, 80) || name, + name, + status, + ...(title ? { title } : {}), + }); + } + return tools; +}; + +const cleanAttachments = (value: unknown): LocalAgentAttachmentRecord[] => { + if (!Array.isArray(value)) return []; + const attachments: LocalAgentAttachmentRecord[] = []; + for (const item of value.slice(0, 4)) { + if (!item || typeof item !== "object") continue; + const attachment = item as { id?: unknown; filename?: unknown; mediaType?: unknown; byteLength?: unknown }; + const filename = clip(attachment.filename, 200); + if (!filename) continue; + const byteLength = typeof attachment.byteLength === "number" && Number.isFinite(attachment.byteLength) + ? Math.max(0, Math.round(attachment.byteLength)) + : 0; + attachments.push({ + id: clip(attachment.id, 80) || filename, + filename, + mediaType: clip(attachment.mediaType, 120), + byteLength, + }); + } + return attachments; +}; + +const cleanTurn = (value: unknown, statusForRunning: LocalAgentTurnStatus): LocalAgentTurnRecord | null => { + if (!value || typeof value !== "object") return null; + const turn = value as Partial; + if (!isThreadId(turn.id) || !isThreadId(turn.threadId) || !isStatus(turn.status)) return null; + const createdAt = typeof turn.createdAt === "string" ? turn.createdAt : ""; + if (!Number.isFinite(Date.parse(createdAt))) return null; + const message = clip(turn.message, LOCAL_AGENT_TEXT_LIMIT).trim(); + if (!message) return null; + return { + id: turn.id, + threadId: turn.threadId, + message, + response: clip(turn.response, LOCAL_AGENT_TEXT_LIMIT), + reasoning: clip(turn.reasoning, LOCAL_AGENT_REASONING_LIMIT), + tools: cleanTools(turn.tools), + attachments: cleanAttachments(turn.attachments), + status: turn.status === "running" ? statusForRunning : turn.status, + createdAt, + }; +}; + +const turnsByThread = (turns: LocalAgentTurnRecord[]) => { + const grouped = new Map(); + for (const turn of turns) { + const list = grouped.get(turn.threadId); + if (list) list.push(turn); + else grouped.set(turn.threadId, [turn]); + } + return [...grouped.entries()] + .map(([id, items]) => { + const ordered = items.slice().sort((left, right) => (left.createdAt < right.createdAt ? -1 : 1)); + return { + id, + updatedAt: ordered[ordered.length - 1]?.createdAt ?? "", + turns: ordered.slice(-LOCAL_AGENT_TURN_LIMIT), + }; + }) + .sort((left, right) => (left.updatedAt < right.updatedAt ? 1 : -1)) + .slice(0, LOCAL_AGENT_THREAD_LIMIT); +}; + +export const compactLocalAgentTurns = (turns: readonly unknown[]): LocalAgentTurnRecord[] => ( + turnsByThread( + turns.flatMap((turn) => { + const cleaned = cleanTurn(turn, "running"); + return cleaned ? [cleaned] : []; + }), + ).flatMap((thread) => thread.turns) +); + +export const parseLocalAgentTurns = (raw: string | null): LocalAgentTurnRecord[] => { + if (!raw) return []; + try { + const parsed = JSON.parse(raw) as { version?: unknown; turns?: unknown }; + if (!parsed || parsed.version !== LOCAL_AGENT_STORE_VERSION || !Array.isArray(parsed.turns)) return []; + return turnsByThread( + parsed.turns.flatMap((turn) => { + const cleaned = cleanTurn(turn, "cancelled"); + return cleaned ? [cleaned] : []; + }), + ).flatMap((thread) => thread.turns); + } catch { + return []; + } +}; + +export const serializeLocalAgentTurns = (turns: readonly unknown[]) => ( + JSON.stringify({ version: LOCAL_AGENT_STORE_VERSION, turns: compactLocalAgentTurns(turns) }) +); + +export const saveLocalAgentTurns = (storage: StorageLike, key: string, turns: readonly unknown[]) => { + let remaining = compactLocalAgentTurns(turns); + for (let attempt = 0; attempt < 8; attempt += 1) { + try { + storage.setItem(key, JSON.stringify({ version: LOCAL_AGENT_STORE_VERSION, turns: remaining })); + return; + } catch { + const threads = turnsByThread(remaining); + if (threads.length > 1) { + remaining = threads.slice(0, -1).flatMap((thread) => thread.turns); + continue; + } + if (remaining.length <= 1) return; + remaining = remaining.slice(Math.ceil(remaining.length / 2)); + } + } +}; + +export const resolveLocalAgentThreadId = ( + stored: string | null, + turns: readonly { threadId: string; createdAt: string }[], +) => { + if (isThreadId(stored)) return stored; + let latest: { threadId: string; createdAt: string } | null = null; + for (const turn of turns) { + if (!latest || turn.createdAt > latest.createdAt) latest = turn; + } + return latest?.threadId ?? null; +}; + +export const chatThreadsFromTurns = ( + turns: readonly { threadId: string; message: string; createdAt: string }[], +): ChatThreadSummary[] => { + const byId = new Map(); + for (const turn of turns) { + const title = turn.message.replace(/\s+/g, " ").trim(); + const current = byId.get(turn.threadId); + if (!current) { + byId.set(turn.threadId, { id: turn.threadId, title, updatedAt: turn.createdAt, oldestAt: turn.createdAt }); + continue; + } + if (turn.createdAt > current.updatedAt) current.updatedAt = turn.createdAt; + if (turn.createdAt < current.oldestAt && title) { + current.oldestAt = turn.createdAt; + current.title = title; + } + } + return [...byId.values()] + .sort((left, right) => (left.updatedAt < right.updatedAt ? 1 : -1)) + .map(({ oldestAt: _oldestAt, ...thread }) => thread); +}; + +const clipLine = (value: string) => { + const text = value.replace(/\s+/g, " ").trim(); + return text.length > LOCAL_AGENT_TRANSCRIPT_LINE ? `${text.slice(0, LOCAL_AGENT_TRANSCRIPT_LINE)}…` : text; +}; + +// One-shot ACP calls do not keep the agent process. The current thread's earlier replies are the session. +export const localAgentTranscript = ( + turns: readonly { threadId: string; message: string; response: string; status: string; createdAt: string }[], + threadId: string, +) => { + const prior = turns + .filter((turn) => turn.threadId === threadId && turn.status !== "running" && turn.message.trim()) + .slice() + .sort((left, right) => (left.createdAt < right.createdAt ? -1 : 1)) + .slice(-LOCAL_AGENT_TRANSCRIPT_TURNS); + const lines: string[] = []; + for (const turn of prior) { + lines.push(`User: ${clipLine(turn.message)}`); + const reply = clipLine(turn.response); + if (reply) lines.push(`Assistant: ${reply}`); + } + while (lines.length > 1 && lines.join("\n").length > LOCAL_AGENT_TRANSCRIPT_LIMIT) lines.shift(); + const text = lines.join("\n"); + if (!text || text.length > LOCAL_AGENT_TRANSCRIPT_LIMIT) return text.slice(-LOCAL_AGENT_TRANSCRIPT_LIMIT); + return `Earlier turns in this chat:\n${text}`; +};