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:
1 parent
5ebcc5cf43
commit
4fc3f24e5e
4 files changed
+505
-50
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
};
|
||||
Reference in new issue
Block a user