feat(web): name the local agent and keep its chats

Local ACP chats use the same history menu as the built-in companion and stay on this device. The sidebar header shows the selected adapter name.
This commit is contained in:
tianma-if committed 2026-09-30 17:30:55 +08:00
1 parent 5ebcc5cf43
commit 4fc3f24e5e
4 files changed
+505 -50

No files matched your search

+120 -50
View File
@@ -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<string, SidebarThread & { oldestAt: string }>();
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 (
<div>
@@ -318,6 +308,7 @@ function AiSidebarThreadMenu({
<button
type="button"
className="flex h-8 min-w-0 max-w-52 items-center gap-1 rounded-full px-2 text-left text-[13px] font-medium text-slate-700 hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400"
data-ai-thread-menu=""
aria-label={`${title} · ${t("aiAssistant.sidebar.history")}`}
>
<Sparkles className="size-3.5 shrink-0 text-slate-500" aria-hidden="true" />
@@ -738,9 +729,19 @@ function AiSidebarSession({
}) {
const { t, i18n } = useTranslation();
const [source, setSource] = useState<"builtin" | "local">(readAiSidebarSource);
const [localAdapterId, setLocalAdapterId] = useState(() => readLocalAdapter()?.id ?? null);
const [turns, setTurns] = useState<CompanionTurn[]>([]);
const [actions, setActions] = useState<CompanionAction[]>([]);
const [localTurns, setLocalTurns] = useState<LocalTurn[]>([]);
const [localTurns, setLocalTurns] = useState<LocalTurn[]>(() => parseLocalAgentTurns(readStorage(AI_SIDEBAR_LOCAL_THREADS_KEY)).map((turn) => ({
...turn,
images: [],
})));
const [localThreadId, setLocalThreadId] = useState(() => (
resolveLocalAgentThreadId(
readStorage(AI_SIDEBAR_LOCAL_THREAD_KEY),
parseLocalAgentTurns(readStorage(AI_SIDEBAR_LOCAL_THREADS_KEY)),
) ?? createClientUuid()
));
const [threadId, setThreadId] = useState<string>(() => readAiSidebarThread() ?? createClientUuid());
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
const [attachmentError, setAttachmentError] = useState<string | null>(null);
@@ -760,6 +761,11 @@ function AiSidebarSession({
const attachmentSnapshot = useRef<PendingAttachment[]>([]);
const acpTurnIds = useRef(new Map<string, string>());
const acpBuffer = useRef<DesktopAcpEvent[]>([]);
const localTurnsRef = useRef(localTurns);
const localThreadIdRef = useRef(localThreadId);
const localPersistTimer = useRef<number | null>(null);
localTurnsRef.current = localTurns;
localThreadIdRef.current = localThreadId;
const focusRef = useRef({ selectionMarkdown, contentMarkdown, memoId, notebookId, notebookTitle, noteTitle, companionAvailable });
focusRef.current = { selectionMarkdown, contentMarkdown, memoId, notebookId, notebookTitle, noteTitle, companionAvailable };
@@ -812,6 +818,38 @@ function AiSidebarSession({
};
}, []);
useEffect(() => {
if (!localTurns.length && !readStorage(AI_SIDEBAR_LOCAL_THREADS_KEY)) return;
if (localPersistTimer.current != null) window.clearTimeout(localPersistTimer.current);
localPersistTimer.current = window.setTimeout(() => {
try {
saveLocalAgentTurns(window.localStorage, AI_SIDEBAR_LOCAL_THREADS_KEY, localTurnsRef.current);
} catch {
// Private mode can reject storage writes. The open chat still stays in memory.
}
}, 400);
return () => {
if (localPersistTimer.current != null) window.clearTimeout(localPersistTimer.current);
};
}, [localTurns]);
useEffect(() => {
const flush = () => {
if (localPersistTimer.current != null) window.clearTimeout(localPersistTimer.current);
if (!localTurnsRef.current.length && !readStorage(AI_SIDEBAR_LOCAL_THREADS_KEY)) return;
try {
saveLocalAgentTurns(window.localStorage, AI_SIDEBAR_LOCAL_THREADS_KEY, localTurnsRef.current);
} catch {
// The in-memory chat remains available until the page closes.
}
};
window.addEventListener("pagehide", flush);
return () => {
window.removeEventListener("pagehide", flush);
flush();
};
}, []);
const applyAcpEventRef = useRef<(event: DesktopAcpEvent) => void>(() => undefined);
useEffect(() => subscribeDesktopAcp((event) => {
if (!acpTurnIds.current.has(event.requestId)) {
@@ -885,7 +923,10 @@ function AiSidebarSession({
};
useEffect(() => {
const syncSource = () => setSource(readAiSidebarSource());
const syncSource = () => {
setSource(readAiSidebarSource());
setLocalAdapterId(readLocalAdapter()?.id ?? null);
};
syncSource();
if (!open) return;
window.addEventListener("focus", syncSource);
@@ -1041,8 +1082,13 @@ function AiSidebarSession({
mediaType: item.mediaType,
byteLength: item.byteLength,
}));
const activeLocalThreadId = localThreadIdRef.current;
const transcript = localAgentTranscript(localTurnsRef.current, activeLocalThreadId);
const noteContext = contextText();
writeStorage(AI_SIDEBAR_LOCAL_THREAD_KEY, activeLocalThreadId);
setLocalTurns((previous) => [...previous, {
id,
threadId: activeLocalThreadId,
message: text,
response: "",
reasoning: "",
@@ -1050,12 +1096,13 @@ function AiSidebarSession({
images: [],
attachments: localAttachments,
status: "running",
createdAt: new Date().toISOString(),
}]);
const result = await promptDesktopAcp({
adapterId: adapter.id,
...(adapter.path ? { path: adapter.path } : {}),
prompt: text,
...(contextText() ? { contextText: contextText() } : {}),
...((noteContext || transcript) ? { contextText: [noteContext, transcript].filter(Boolean).join("\n\n") } : {}),
...(snapshot.length ? {
attachments: snapshot.map((item) => ({
filename: item.filename,
@@ -1348,17 +1395,26 @@ function AiSidebarSession({
onOpenCompanionNote(linkedId, source.notebookId);
};
const threads = useMemo(() => sidebarThreadsFromTurns(turns), [turns]);
const threadTitle = threads.find((thread) => thread.id === threadId)?.title || t("aiAssistant.sidebar.newThread");
const companionThreads = useMemo(() => chatThreadsFromTurns(turns), [turns]);
const localThreadList = useMemo(() => chatThreadsFromTurns(localTurns), [localTurns]);
const menuThreads = source === "builtin" ? companionThreads : localThreadList;
const menuThreadId = source === "builtin" ? threadId : localThreadId;
const threadTitle = menuThreads.find((thread) => thread.id === menuThreadId)?.title || t("aiAssistant.sidebar.newThread");
const rememberThread = (id: string) => {
threadPinned.current = true;
setThreadId(id);
setError(null);
writeAiSidebarThread(id);
};
const selectLocalThread = (id: string) => {
setLocalThreadId(id);
setError(null);
writeStorage(AI_SIDEBAR_LOCAL_THREAD_KEY, id);
};
const threadTurns = turns.filter((turn) => turn.threadId === threadId).slice().reverse();
const localThreadTurns = localTurns.filter((turn) => turn.threadId === localThreadId);
const visibleCompanion = source === "builtin";
const visibleTurns = visibleCompanion ? threadTurns : localTurns;
const visibleTurns = visibleCompanion ? threadTurns : localThreadTurns;
const running = busy || visibleTurns.some((turn) => turn.status === "running");
const renderSelectionReply = (turnId: string, response: string, status: string) => {
@@ -1400,25 +1456,39 @@ function AiSidebarSession({
return (
<div className="flex h-full min-h-0 min-w-0 flex-col bg-card">
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-slate-200 px-2">
{source === "builtin" ? (
<AiSidebarThreadMenu
threads={threads}
threadId={threadId}
title={threadTitle}
onSelect={rememberThread}
onCreate={() => {
if (threads.some((thread) => thread.id === threadId)) rememberThread(createClientUuid());
}}
/>
) : (
<>
<Sparkles className="ml-1 h-4 w-4 shrink-0 text-slate-500" aria-hidden="true" />
<h2 className="min-w-0 flex-1 truncate text-sm font-semibold text-slate-950">{t("aiAssistant.sidebar.title")}</h2>
<span className="shrink-0 text-xs text-slate-500">{t("aiAssistant.sidebar.localStatus")}</span>
</>
)}
<AiSidebarThreadMenu
threads={menuThreads}
threadId={menuThreadId}
title={threadTitle}
onSelect={source === "builtin" ? rememberThread : selectLocalThread}
onCreate={() => {
if (source === "local") {
if (localTurns.some((turn) => turn.threadId === localThreadId)) selectLocalThread(createClientUuid());
return;
}
if (companionThreads.some((thread) => thread.id === threadId)) rememberThread(createClientUuid());
}}
/>
{source === "local" ? (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<span
className="ml-auto min-w-0 max-w-[46%] shrink truncate rounded-sm text-right text-xs text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400"
data-ai-local-agent=""
tabIndex={0}
>
{localAdapterId ? t(`aiAssistant.agentSource.${localAdapterId}`) : t("aiAssistant.sidebar.localStatus")}
</span>
</TooltipTrigger>
<TooltipContent>
{localAdapterId ? t(`aiAssistant.agentSource.${localAdapterId}`) : t("aiAssistant.sidebar.localStatus")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
<Button
className="ml-auto"
className={source === "local" ? undefined : "ml-auto"}
type="button"
size="icon-sm"
variant="ghost"
@@ -1430,7 +1500,7 @@ function AiSidebarSession({
</Button>
</div>
{error ? <p role="alert" className="shrink-0 px-3 pt-2 text-sm text-rose-700">{error}</p> : null}
<Conversation key={source === "builtin" ? threadId : "local"} className="min-h-0 flex-1">
<Conversation key={menuThreadId} className="min-h-0 flex-1">
<ConversationContent className={sidebarThreadClassName}>
{loading && visibleCompanion ? <p role="status" className="text-sm text-slate-500">{t("common.loading")}</p> : null}
{!loading && !visibleTurns.length ? (
@@ -1487,7 +1557,7 @@ function AiSidebarSession({
{renderActions(turn.id)}
</Message>
</div>
)) : localTurns.map((turn) => (
)) : localThreadTurns.map((turn) => (
<div key={turn.id} className="space-y-2">
<Message from="user">
<MessageContent className={sidebarUserMessageClassName}>{turn.message}</MessageContent>
+2
View File
@@ -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";
@@ -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");
});
});
+236
View File
@@ -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<LocalAgentTurnRecord>;
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<string, LocalAgentTurnRecord[]>();
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<string, ChatThreadSummary & { oldestAt: string }>();
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}`;
};