fix: reduce conversation history memory use
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build darwin arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux x64 Agent (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s

Load lightweight conversation summaries and hydrate full history only when
needed, preserving search, copy, export, queued requests, and remote questions.
Keep unchanged image reconciliation messages stable so list refreshes no
longer resend the entire saved history.

Prepare Desktop 0.13.6 with the approved bilingual notes and aligned feature
matrices. Agent remains 0.13.0; no LoongArch preview or navigation demo edits
are included. Development validation passed 4301 tests with 67 skipped;
the user requested CI validation instead of repeating local release checks.

Release note: 历史会话按需加载,减少切回应用时的重复保存和内存占用,同时保留完整历史、搜索与续聊。
This commit is contained in:
mesalogo
2026-09-15 20:33:47 +08:00
parent f47049c40d
commit a383b2d11f
25 changed files with 810 additions and 67 deletions
@@ -172,6 +172,44 @@ function claimManualScheduleQueueItem(
}
describe('AssistantDatabase', () => {
it('lists lightweight history without dropping searchable text or full process metadata', async () => {
const database = await createDatabase()
try {
const snapshots = Array.from({ length: 100 }, (_, index) => ({
id: randomUUID(), title: `History ${index}`, updatedAt: index + 1,
messages: Array.from({ length: 16 }, (_, messageIndex) => ({
id: randomUUID(), role: 'assistant' as const, state: 'complete' as const,
content: messageIndex === 0 ? `ÄPFEL 中文 ${index} %_` : 'Body',
reasoning: 'Complete retained process history. '.repeat(64),
createdAt: index * 100 + messageIndex
}))
}))
database.replaceConversations(snapshots)
const summaries = database.listConversationSummaries()
expect(summaries).toHaveLength(100)
expect(summaries.every(item => item.messages.length === 0 &&
item.messageSummary?.count === 16 && item.messageSummary.firstRole === 'assistant')).toBe(true)
expect(JSON.stringify(summaries).length).toBeLessThan(JSON.stringify(snapshots).length * 0.02)
const id = snapshots[0]!.id
const detail = database.listConversationSummaries([id]).find(item => item.id === id)!
expect(detail.messageSummary).toBeUndefined()
expect(detail.messages).toEqual(snapshots[0]!.messages)
expect(database.getConversation(id).messages).toEqual(snapshots[0]!.messages)
expect(database.searchConversations('äpfel 中文 0 %_')).toEqual([id])
expect(database.searchConversations('Complete retained process')).toEqual([])
expect(database.searchConversations('history 99')).toEqual([snapshots[99]!.id])
expect(database.searchConversations(' ')).toEqual([])
const added = { id: randomUUID(), title: 'Newer', updatedAt: 1000 }
database.saveLocalConversations([{ header: added, messages: [] }])
expect(database.listConversationSummaries().some(item => item.id === id)).toBe(false)
expect(database.searchConversations('äpfel 中文 0 %_', [id])).toEqual([id])
expect(database.listConversationSummaries([id]).find(item => item.id === id)?.messages).toHaveLength(16)
// A header-only save of a summary cannot replace or erase messages.
database.saveLocalConversations([{ header: { id, title: 'Renamed', updatedAt: 101 }, messages: [] }])
expect(database.getConversation(id).messages).toEqual(snapshots[0]!.messages)
} finally { database.close() }
})
it.each([0, 1, 100, 500, 999])('keeps listTasks(%i) recent history plus old visible live tasks', async (limit) => {
const database = await createDatabase()
vi.useFakeTimers()
@@ -5306,6 +5344,8 @@ describe('AssistantDatabase', () => {
expect(restored.messages).toHaveLength(501)
expect(restored.messages[0]?.id).toBe(messageId(0))
expect(database.listConversations().find((item) => item.id === conversationId)?.messages).toHaveLength(501)
expect(database.listConversationSummaries().find(item => item.id === conversationId)?.messageSummary?.count).toBe(501)
expect(database.listConversationSummaries([conversationId]).find(item => item.id === conversationId)?.messages).toHaveLength(501)
expect(restored.messages.at(-1)?.id).toBe(newestMessageId)
const raw = new DatabaseSync(databasePath)
expect(
+53 -7
View File
@@ -2618,8 +2618,16 @@ export class AssistantDatabase {
}
listConversations(): ConversationSnapshot[] {
return this.readConversationList()
}
listConversationSummaries(detailIds: string[] = []): import('../../shared/assistant-contracts').ConversationListSnapshot[] {
return this.readConversationList(new Set(detailIds))
}
private listConversationRows(extraIds: ReadonlySet<string> = new Set()): ConversationRow[] {
const database = this.requireDatabase()
const conversations = database
return database
.prepare(
`SELECT id, project_id, runtime_selection_json,
knowledge_retrieval_mode, context_state_json, title, channel,
@@ -2633,13 +2641,19 @@ export class AssistantDatabase {
id IN (SELECT id FROM conversations WHERE status = 'active'
ORDER BY updated_at DESC LIMIT 100)
OR id IN (SELECT conversation_id FROM (${activeVisibleTaskSelect}))
OR id IN (SELECT value FROM json_each(?))
OR EXISTS (SELECT 1 FROM messages
WHERE conversation_id = conversations.id
AND state = 'streaming')
)
ORDER BY updated_at DESC`
)
.all() as ConversationRow[]
.all(JSON.stringify([...extraIds])) as ConversationRow[]
}
private readConversationList(detailIds?: ReadonlySet<string>): import('../../shared/assistant-contracts').ConversationListSnapshot[] {
const database = this.requireDatabase()
const conversations = this.listConversationRows(detailIds)
const messageStatement = database.prepare(
`SELECT id, conversation_id, role, content, state, metadata_json,
created_at
@@ -2652,12 +2666,44 @@ export class AssistantDatabase {
)
ORDER BY sequence ASC`
)
return conversations.map((conversation) =>
toConversationSnapshot(
conversation,
messageStatement.all(conversation.id) as MessageRow[]
)
const summaryStatement = database.prepare(
`SELECT count(*) AS count,
max(state = 'streaming') AS streaming,
(SELECT role FROM messages WHERE conversation_id = ? ORDER BY sequence LIMIT 1) AS firstRole
FROM messages WHERE conversation_id = ?`
)
const activeIds = new Set(
(database.prepare(`SELECT conversation_id FROM (${activeVisibleTaskSelect})`).all() as
{ conversation_id: string }[]).map(row => row.conversation_id)
)
return conversations.map((conversation) => {
if (detailIds && !detailIds.has(conversation.id) && !activeIds.has(conversation.id)) {
const summary = summaryStatement.get(conversation.id, conversation.id) as {
count: number; streaming: number | null; firstRole: ConversationMessage['role'] | null
}
if (!summary.streaming) {
return {
...toConversationSnapshot(conversation, []),
messageSummary: { count: summary.count, firstRole: summary.firstRole ?? undefined }
}
}
}
return toConversationSnapshot(conversation, messageStatement.all(conversation.id) as MessageRow[])
})
}
searchConversations(query: string, conversationIds: string[] = []): string[] {
const normalized = query.trim().toLocaleLowerCase()
if (!normalized) return []
const database = this.requireDatabase()
// Read only searchable text, never tool/subagent/image metadata. Use the
// same Unicode case folding as the renderer's existing search.
const messages = database.prepare('SELECT content FROM messages WHERE conversation_id = ?')
return this.listConversationRows(new Set(conversationIds)).filter(conversation =>
conversation.title.toLocaleLowerCase().includes(normalized) ||
(messages.all(conversation.id) as { content: string }[]).some(message =>
message.content.toLocaleLowerCase().includes(normalized))
).map(conversation => conversation.id)
}
getConversation(conversationId: string): ConversationSnapshot {
+7
View File
@@ -5073,6 +5073,7 @@ describe('registerIpcHandlers agent terminal state', () => {
>(() => true),
appendRemoteConversationTaskEventOnce: vi.fn<AssistantDatabase['appendRemoteConversationTaskEventOnce']>(() => true),
listConversations: vi.fn<() => ConversationSnapshot[]>(() => []),
listConversationSummaries: vi.fn<(ids?: string[]) => ConversationSnapshot[]>(() => []),
listRecoverableRemoteTasks: vi.fn<
() => Array<{
taskId: string
@@ -6800,6 +6801,7 @@ describe('registerIpcHandlers agent terminal state', () => {
})
harness.assistantDatabase.getConversation.mockImplementation(database.getConversation.bind(database))
harness.assistantDatabase.listConversations.mockImplementation(database.listConversations.bind(database))
harness.assistantDatabase.listConversationSummaries.mockImplementation(database.listConversationSummaries.bind(database))
harness.assistantDatabase.listRecoverableRemoteTasks.mockImplementation(database.listRecoverableRemoteTasks.bind(database))
harness.assistantDatabase.appendRemoteConversationTaskEventOnce.mockImplementation(database.appendRemoteConversationTaskEventOnce.bind(database))
harness.assistantDatabase.updateTaskStatus.mockImplementation(database.updateTaskStatus.bind(database))
@@ -6813,6 +6815,11 @@ describe('registerIpcHandlers agent terminal state', () => {
harness.recoveryGetHandler?.(event)
await vi.waitFor(() => expect(ready.size).toBe(2))
await vi.waitFor(() => expect(list().every(c => c.activeRequest?.questions.length === 1)).toBe(true))
const summaries = electronMocks.handlers.get(ipcChannels.conversationsListSummaries)!(event, { detailIds: [] }) as ConversationSnapshot[]
expect(summaries).toEqual(list())
for (const snapshot of summaries) {
expect(electronMocks.handlers.get(ipcChannels.conversationsGet)!(event, snapshot.id)).toEqual(snapshot)
}
expect(harness.assistantDatabase.appendRemoteConversationTaskEventOnce).not.toHaveBeenCalled()
const update = settingsKind === 'runtime'
? [ipcChannels.runtimeSettingsUpdate, runtimeSettingsInputSchema.strip().parse({
+25 -3
View File
@@ -203,6 +203,9 @@ import {
activityHistorySnapshotSchema,
conversationBranchInputSchema,
conversationSnapshotsSchema,
conversationListRequestSchema,
conversationSearchRequestSchema,
type ConversationSnapshot,
localConversationSaveBatchSchema,
memoryCreateSchema,
normalizeInteractiveWorkMode,
@@ -6761,8 +6764,7 @@ export function registerIpcHandlers(
}
)
registerHandler(ipcChannels.conversationsList, (event) => {
assertTrustedSender(event, window)
const projectConversationRequests = <T extends ConversationSnapshot>(conversations: T[]): T[] => {
const questions = new Map<string, Extract<AgentEvent, { type: 'question' }>[]>()
for (const pending of pendingAgentQuestions.values()) {
const entries = questions.get(pending.requestId) ?? []
@@ -6780,10 +6782,30 @@ export function registerIpcHandlers(
: []
)
)
return assistantDatabase.listConversations().map(conversation => ({
return conversations.map(conversation => ({
...conversation,
...(recovered.has(conversation.id) ? { activeRequest: recovered.get(conversation.id) } : {})
}))
}
registerHandler(ipcChannels.conversationsList, (event) => {
assertTrustedSender(event, window)
return projectConversationRequests(assistantDatabase.listConversations())
})
registerHandler(ipcChannels.conversationsListSummaries, (event, input: unknown) => {
assertTrustedSender(event, window)
const { detailIds } = conversationListRequestSchema.parse(input)
return projectConversationRequests(assistantDatabase.listConversationSummaries([
...detailIds, ...[...activeRequests.values()].map(request => request.conversationId)
]))
})
registerHandler(ipcChannels.conversationsGet, (event, input: unknown) => {
assertTrustedSender(event, window)
return projectConversationRequests([assistantDatabase.getConversation(assistantIdSchema.parse(input))])[0]
})
registerHandler(ipcChannels.conversationsSearch, (event, input: unknown) => {
assertTrustedSender(event, window)
const { query, conversationIds } = conversationSearchRequestSchema.parse(input)
return assistantDatabase.searchConversations(query, conversationIds)
})
registerHandler(
+6
View File
@@ -1070,6 +1070,12 @@ const desktopApi: DesktopApi = {
ipcRenderer.invoke(
ipcChannels.conversationsList
) as Promise<ConversationSnapshot[]>,
listSummaries: (detailIds = []) =>
ipcRenderer.invoke(ipcChannels.conversationsListSummaries, { detailIds }),
get: (conversationId) =>
ipcRenderer.invoke(ipcChannels.conversationsGet, conversationId),
search: (query, conversationIds = []) =>
ipcRenderer.invoke(ipcChannels.conversationsSearch, { query, conversationIds }),
replace: async (conversations: ConversationSnapshot[]) => {
await ipcRenderer.invoke(
ipcChannels.conversationsReplace,
+145 -2
View File
@@ -489,6 +489,11 @@ const api: DesktopApi = {
onChanged: vi.fn(() => () => undefined),
},
list: vi.fn(async () => []),
// Existing full-history fixtures remain valid list projections. The lazy
// history regressions below explicitly supply summaries and detail replies.
listSummaries: vi.fn(async () => api.conversations.list()),
get: vi.fn(),
search: vi.fn(async () => []),
replace: vi.fn(async () => {}),
saveLocal: vi.fn(async () => {}),
branchLocal: vi.fn(async (input) => ({
@@ -1086,8 +1091,34 @@ describe("App", () => {
};
});
vi.mocked(api.conversations.list).mockReset().mockResolvedValue([]);
vi.mocked(api.conversations.replace).mockReset().mockResolvedValue();
vi.mocked(api.conversations.saveLocal).mockReset().mockResolvedValue();
const conversationFixtures = new Map<string, Awaited<ReturnType<DesktopApi["conversations"]["get"]>>>();
vi.mocked(api.conversations.listSummaries).mockReset()
.mockImplementation(async () => {
const snapshots = await api.conversations.list();
snapshots.forEach(snapshot => conversationFixtures.set(snapshot.id, snapshot));
return snapshots;
});
vi.mocked(api.conversations.get).mockReset().mockImplementation(async id => {
const snapshot = conversationFixtures.get(id);
if (!snapshot) throw new Error("Missing conversation fixture");
return snapshot;
});
vi.mocked(api.conversations.search).mockReset().mockImplementation(async query =>
[...conversationFixtures.values()].filter(snapshot =>
snapshot.title.toLocaleLowerCase().includes(query) ||
snapshot.messages.some(message => message.content.toLocaleLowerCase().includes(query))
).map(snapshot => snapshot.id));
vi.mocked(api.conversations.replace).mockReset().mockImplementation(async snapshots => {
snapshots.forEach(snapshot => conversationFixtures.set(snapshot.id, snapshot));
});
vi.mocked(api.conversations.saveLocal).mockReset().mockImplementation(async batch => {
for (const { header, messages } of batch) {
const existing = conversationFixtures.get(header.id);
const byId = new Map(existing?.messages.map(message => [message.id, message]));
messages.forEach(message => byId.set(message.id, message));
conversationFixtures.set(header.id, { ...header, messages: [...byId.values()] });
}
});
vi.mocked(api.conversations.branchLocal)
.mockReset()
.mockImplementation(async (input) => ({
@@ -1529,6 +1560,81 @@ describe("App", () => {
});
});
it("loads only the opened history and saves no unchanged messages after a real-shaped list refresh", async () => {
const snapshots = Array.from({ length: 100 }, (_, index) => ({
id: crypto.randomUUID(), projectId, title: `History ${index}`, updatedAt: 100 - index,
messages: Array.from({ length: 16 }, (_, messageIndex) => ({
id: crypto.randomUUID(), role: "assistant" as const, state: "complete" as const,
content: `History body ${index}/${messageIndex}`, createdAt: messageIndex,
reasoning: "Retained process output".repeat(100),
})),
}));
vi.mocked(api.conversations.listSummaries).mockImplementation(async (detailIds = []) =>
JSON.parse(JSON.stringify(snapshots.map(snapshot => detailIds.includes(snapshot.id)
? snapshot : { ...snapshot, messages: [], messageSummary: { count: 16, firstRole: "assistant" } }))));
vi.mocked(api.conversations.get).mockImplementation(async id => snapshots.find(snapshot => snapshot.id === id)!);
render(<App />);
expect(await screen.findByText("History body 0/15")).toBeInTheDocument();
expect(api.conversations.get).toHaveBeenCalledExactlyOnceWith(snapshots[0]!.id);
expect(api.conversations.list).not.toHaveBeenCalled();
vi.mocked(api.conversations.saveLocal).mockClear();
fireEvent(window, new Event("focus"));
await waitFor(() => expect(api.conversations.listSummaries).toHaveBeenCalledTimes(2));
await act(async () => { await new Promise(resolve => setTimeout(resolve, 650)); });
const saved = vi.mocked(api.conversations.saveLocal).mock.calls.flatMap(([batch]) => batch)
.flatMap(item => item.messages);
expect(saved).toHaveLength(0);
expect(api.conversations.get).toHaveBeenCalledTimes(1);
expect(screen.queryByText("History body 99/15")).not.toBeInTheDocument();
});
it("searches unloaded history and retries a failed detail load without losing the conversation", async () => {
const first = { id: crypto.randomUUID(), projectId, title: "Opened", updatedAt: 2, messages: [
{ id: crypto.randomUUID(), role: "assistant" as const, state: "complete" as const, content: "Opened body", createdAt: 1 },
] };
const second = { id: crypto.randomUUID(), projectId, title: "Unopened", updatedAt: 1, messages: [
{ id: crypto.randomUUID(), role: "user" as const, state: "complete" as const, content: "Rare searchable history", createdAt: 1 },
] };
vi.mocked(api.conversations.listSummaries).mockResolvedValue([first, {
...second, messages: [], messageSummary: { count: 1, firstRole: "user" },
}]);
vi.mocked(api.conversations.search).mockResolvedValue([second.id]);
vi.mocked(api.conversations.get).mockRejectedValueOnce(new Error("Temporary history read failure"))
.mockResolvedValueOnce(second);
const { container } = render(<App />);
expect(await screen.findByText("Opened body")).toBeInTheDocument();
fireEvent.change(screen.getByLabelText("搜索对话"), { target: { value: "rare searchable" } });
const list = within(container.querySelector<HTMLElement>(".conversation-list")!);
fireEvent.click(await list.findByText("Unopened"));
expect(await screen.findByText("Temporary history read failure")).toBeInTheDocument();
expect(screen.queryByText("Rare searchable history")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "重新加载" }));
expect(await screen.findByText("Rare searchable history")).toBeInTheDocument();
expect(api.conversations.get).toHaveBeenNthCalledWith(1, second.id);
expect(api.conversations.get).toHaveBeenNthCalledWith(2, second.id);
expect(api.conversations.replace).not.toHaveBeenCalled();
});
it("copies a complete unopened conversation instead of exporting its empty summary", async () => {
const first = { id: crypto.randomUUID(), projectId, title: "Opened", updatedAt: 2, messages: [] };
const second = { id: crypto.randomUUID(), projectId, title: "Copy unopened", updatedAt: 1, messages: [
{ id: crypto.randomUUID(), role: "user" as const, state: "complete" as const, content: "All retained text", createdAt: 1 },
] };
vi.mocked(api.conversations.listSummaries).mockResolvedValue([first, {
...second, messages: [], messageSummary: { count: 1, firstRole: "user" },
}]);
vi.mocked(api.conversations.get).mockResolvedValue(second);
render(<App />);
fireEvent.click(await screen.findByLabelText("更多会话操作 Copy unopened"));
fireEvent.click(screen.getByRole("button", { name: "复制完整会话" }));
await waitFor(() => expect(api.clipboard.writeText).toHaveBeenCalledWith(expect.stringContaining("All retained text")));
expect(api.conversations.get).toHaveBeenCalledExactlyOnceWith(second.id);
fireEvent.click(screen.getByLabelText("更多会话操作 Copy unopened"));
fireEvent.click(screen.getByRole("button", { name: "复制完整会话" }));
await waitFor(() => expect(api.conversations.get).toHaveBeenCalledTimes(2));
expect(screen.queryByText("All retained text")).not.toBeInTheDocument();
});
it("provides custom minimize, maximize, and close controls", async () => {
const { unmount } = render(<App />);
@@ -2234,6 +2340,43 @@ describe("App", () => {
).not.toBeInTheDocument();
});
it("preserves a scheduled approval arriving after an unopened summary refresh began", async () => {
const taskId = crypto.randomUUID();
const target = {
id: crypto.randomUUID(), projectId, title: "Unopened scheduled history", updatedAt: 1,
messages: [{ id: crypto.randomUUID(), role: "user" as const, state: "complete" as const,
content: "Retained scheduled history", createdAt: 1 }],
};
const snapshots = [
{ id: crypto.randomUUID(), projectId, title: "Opened history", updatedAt: 2, messages: [] },
{ ...target, messages: [], messageSummary: { count: 1, firstRole: "user" as const } },
];
vi.mocked(api.conversations.listSummaries).mockResolvedValueOnce(snapshots);
let finishRefresh: ((value: typeof snapshots) => void) | undefined;
vi.mocked(api.conversations.listSummaries).mockImplementationOnce(() => new Promise(resolve => { finishRefresh = resolve; }));
vi.mocked(api.conversations.get).mockResolvedValue(target);
vi.mocked(api.tasks.list).mockResolvedValue([{
id: taskId, projectId, conversationId: target.id, origin: "schedule", status: "running",
title: "Scheduled approval", instructions: "Ask before writing", createdAt: "2026-09-15T00:00:00Z",
}]);
const { container } = render(<App />);
await screen.findAllByText("Opened history");
fireEvent(window, new Event("focus"));
await waitFor(() => expect(finishRefresh).toBeDefined());
act(() => {
agentListener?.({
requestId: taskId, type: "approval", approvalId: crypto.randomUUID(),
title: "Keep this approval", description: "A scheduled write", toolName: "write_file",
argumentSummary: "test.txt", allowPermanent: false,
});
});
await act(async () => { finishRefresh!(snapshots); });
expect(screen.getByRole("button", { name: /全项目活动/u })).toHaveTextContent("1 个待处理");
fireEvent.click(within(container.querySelector<HTMLElement>(".conversation-list")!).getByText(target.title));
expect(await screen.findByText("Retained scheduled history")).toBeInTheDocument();
expect(await screen.findByRole("button", { name: "仅此次" })).toBeInTheDocument();
});
it("routes scheduled Task approvals to the associated Conversation", async () => {
const conversationId = "00000000-0000-4000-8000-000000000831";
const taskId = "00000000-0000-4000-8000-000000000832";
+217 -39
View File
@@ -99,6 +99,7 @@ import {
getDefaultRuntimeSelection,
getRuntimeSelectionForProvider,
} from "./runtime-selection";
import { mergeMessageImageState } from "./message-image-state";
import type {
ActivityHistorySnapshot,
AssistantProject,
@@ -116,6 +117,7 @@ import type {
ConversationMessage,
ConversationQueueItem,
ConversationSnapshot,
ConversationListSnapshot,
ConversationAttachment,
ConversationContextCompressionMarker,
ConversationMessageBlock,
@@ -490,7 +492,7 @@ function supportsSubagentSmartRouting(workMode: string): boolean {
return workMode === "ask";
}
type Conversation = Omit<ConversationSnapshot, "messages"> & {
type Conversation = Omit<ConversationListSnapshot, "messages"> & {
messages: Message[];
};
@@ -781,8 +783,8 @@ function ConversationBranchBadge({
function isUnusedConversation(conversation: Conversation): boolean {
return (
conversation.title === "新对话" &&
conversation.messages.length === 1 &&
conversation.messages[0]?.role === "assistant"
(conversation.messageSummary?.count ?? conversation.messages.length) === 1 &&
(conversation.messageSummary?.firstRole ?? conversation.messages[0]?.role) === "assistant"
);
}
@@ -804,6 +806,30 @@ type ChatScrollSnapshot = {
scrollTop: number;
};
function ConversationHistoryLoader({ conversationId, active, load }: {
conversationId: string;
active: boolean;
load: (id: string) => Promise<Conversation>;
}): React.JSX.Element {
const { t } = useTranslation("app");
const [error, setError] = useState<string>();
const [attempt, setAttempt] = useState(0);
useEffect(() => {
let mounted = true;
void load(conversationId).catch(reason => {
if (mounted) setError(displayErrorMessage(reason, t("conversation.historyLoadFailed")));
});
return () => { mounted = false; };
}, [conversationId, load, attempt, t]);
return <section hidden={!active} className="empty-state" aria-busy={!error}>
<p role={error ? "alert" : "status"}>{error ?? t("conversation.historyLoading")}</p>
{error && <button type="button" className="secondary-button" onClick={() => {
setError(undefined);
setAttempt(value => value + 1);
}}>{t("conversation.historyRetry")}</button>}
</section>;
}
function ChatHistoryPane({
onOpenImageModelSettings,
onReselectImageSources,
@@ -1464,8 +1490,9 @@ function withRecoveredQuestions(conversation: Conversation): Conversation {
function mergePersistedConversations(
current: readonly Conversation[],
incoming: readonly ConversationSnapshot[],
incoming: readonly ConversationListSnapshot[],
persistedLocal: Map<string, Conversation>,
retainDetails: ReadonlySet<string> = new Set(),
): Conversation[] {
const incomingById = new Map(
incoming.map((conversation) => [conversation.id, conversation]),
@@ -1474,6 +1501,28 @@ function mergePersistedConversations(
current.map((conversation) => [conversation.id, conversation]),
);
const merged = incoming.map((conversation): Conversation => {
if (conversation.messageSummary) {
const local = currentById.get(conversation.id);
const acknowledged = persistedLocal.get(conversation.id);
const dirtyMessages = local &&
local.messages.some(message => !acknowledged?.messages.includes(message));
const keepMessages = local && (dirtyMessages ||
local.messages.some(message => message.approval || message.pendingQuestions?.length || message.state === "streaming") ||
(!local.messageSummary && retainDetails.has(conversation.id)));
const next = local && local.updatedAt > conversation.updatedAt
? { ...local, messageSummary: conversation.messageSummary, messages: [] }
: conversation;
if (keepMessages) {
// A list reply started before navigation must not unload the newly
// opened history, or any messages not acknowledged by Main yet.
return {
...next, messages: local.messages,
messageSummary: local.messageSummary ? conversation.messageSummary : undefined,
};
}
if (!conversation.remote) persistedLocal.set(conversation.id, conversation);
return next;
}
if (conversation.remote) {
return conversation;
}
@@ -1492,24 +1541,14 @@ function mergePersistedConversations(
const messages = [
...conversation.messages.map((persistedMessage) => {
const localMessage = localMessageById.get(persistedMessage.id);
const operations = new Map(persistedMessage.imageOperations?.map(operation => [operation.id, operation]));
for (const operation of localMessage?.imageOperations ?? []) {
if (operation.updatedAt > (operations.get(operation.id)?.updatedAt ?? -1)) operations.set(operation.id, operation);
}
const imageMetadata = {
imageOperations: operations.size ? [...operations.values()] : undefined,
imageSourceArtifactIds: persistedMessage.imageSourceArtifactIds ?? localMessage?.imageSourceArtifactIds,
artifactIds: [...new Set([...(persistedMessage.artifactIds ?? []),
...[...operations.values()].flatMap(operation => operation.artifactIds)])].slice(-8),
};
const message = { ...persistedMessage, ...imageMetadata };
const message = mergeMessageImageState(persistedMessage, persistedMessage, localMessage);
if (!conversation.activeRequest && local.activeRequest?.messageId === message.id) {
return { ...message, pendingQuestions: undefined };
}
if (!localIsNewer) {
// Pending prompts are live-only and are omitted from persisted messages.
if (localMessage?.state === "streaming" && message.state === "streaming") {
return { ...localMessage, ...imageMetadata };
return mergeMessageImageState(localMessage, persistedMessage, localMessage);
}
return message;
}
@@ -1519,13 +1558,12 @@ function mergePersistedConversations(
) {
return message;
}
return { ...localMessage, ...imageMetadata,
artifactIds: [...new Set([...(localMessage.artifactIds ?? []), ...(message.artifactIds ?? [])])].slice(-8) };
return mergeMessageImageState(localMessage, persistedMessage, localMessage);
}),
...local.messages.filter((message) => !serverMessageIds.has(message.id)),
];
const next = localIsNewer
? { ...local, messages, activeRequest: conversation.activeRequest }
? { ...local, messages, messageSummary: undefined, activeRequest: conversation.activeRequest }
: { ...conversation, messages };
persistedLocal.set(conversation.id, conversation);
return withRecoveredQuestions(next);
@@ -2362,6 +2400,10 @@ function App(): React.JSX.Element {
const [cachedConversationViews, setCachedConversationViews] = useState<
KeepAliveCacheEntry<string>[]
>(() => (activeId ? [{ key: activeId, lastVisitedAt: Date.now() }] : []));
const cachedConversationViewsRef = useRef(cachedConversationViews);
useLayoutEffect(() => {
cachedConversationViewsRef.current = cachedConversationViews;
}, [cachedConversationViews]);
const commitView = useCallback((next: WorkspaceView): void => {
const previous = viewRef.current;
const now = Date.now();
@@ -2510,8 +2552,10 @@ function App(): React.JSX.Element {
const [searchQuery, setSearchQuery] = useState("");
const searchInputRef = useRef<HTMLInputElement>(null);
const deferredSearchQuery = useDeferredValue(searchQuery);
const [searchConversationSnapshot, setSearchConversationSnapshot] =
useState(conversations);
const [localSearchMatches, setLocalSearchMatches] =
useState<{ query: string; ids: Set<string> }>({ query: "", ids: new Set() });
const [persistedSearchMatches, setPersistedSearchMatches] =
useState<{ query: string; ids: Set<string> }>({ query: "", ids: new Set() });
const [conversationLoadError, setConversationLoadError] = useState<string>();
const [conversationLoadRetry, setConversationLoadRetry] = useState(0);
const [conversationActionsId, setConversationActionsId] = useState("");
@@ -2992,12 +3036,34 @@ function App(): React.JSX.Element {
return;
}
const timeout = window.setTimeout(
() => setSearchConversationSnapshot(conversations),
() => {
const query = searchQuery.trim().toLocaleLowerCase();
setLocalSearchMatches({ query, ids: new Set(conversations.filter(conversation =>
conversation.messages.some(message => message.content.toLocaleLowerCase().includes(query))
).map(conversation => conversation.id)) });
},
conversationSearchSnapshotDelayMs,
);
return () => window.clearTimeout(timeout);
}, [conversations, searchQuery]);
useEffect(() => {
const query = deferredSearchQuery.trim().toLocaleLowerCase();
if (!query || !conversationStoreReady) return;
let active = true;
const timeout = window.setTimeout(() => {
void window.goodbuddy.conversations.search(query, conversationsRef.current.map(item => item.id)).then(ids => {
if (active) setPersistedSearchMatches({ query, ids: new Set(ids) });
}).catch(() => {
if (active) notify({
tone: "error", message: tRef.current("notices.remoteConversationRefreshFailed"),
dedupeKey: "conversation-search",
});
});
}, conversationSearchSnapshotDelayMs);
return () => { active = false; window.clearTimeout(timeout); };
}, [deferredSearchQuery, conversationStoreReady, localSearchMatches]);
useEffect(() => {
projectsRef.current = projects;
}, [projects]);
@@ -3752,24 +3818,23 @@ function App(): React.JSX.Element {
);
const filteredConversations = useMemo(() => {
const query = deferredSearchQuery.trim().toLocaleLowerCase();
const candidates = query ? searchConversationSnapshot : conversations;
return candidates.filter(
return conversations.filter(
(conversation) =>
(!activeProjectId || conversation.projectId === activeProjectId) &&
(activeProject?.kind !== "channel" ||
conversation.remote !== undefined) &&
(!query ||
(persistedSearchMatches.query === query && persistedSearchMatches.ids.has(conversation.id)) ||
conversation.title.toLocaleLowerCase().includes(query) ||
conversation.messages.some((message) =>
message.content.toLocaleLowerCase().includes(query),
)),
(localSearchMatches.query === query && localSearchMatches.ids.has(conversation.id))),
);
}, [
activeProject,
activeProjectId,
conversations,
deferredSearchQuery,
searchConversationSnapshot,
localSearchMatches,
persistedSearchMatches,
]);
const productAssistantTasks = useMemo(
() =>
@@ -4994,6 +5059,38 @@ function App(): React.JSX.Element {
viewRef.current = view;
}, [view]);
const retainedConversationDetailIds = useCallback((): Set<string> => new Set([
activeConversationIdRef.current,
...cachedConversationViewsRef.current.map(entry => entry.key),
...[...activeRuns.current.values()].map(run => run.conversationId),
...preparingConversations.current,
].filter(Boolean)), []);
const conversationHistoryRequests = useRef(new Map<string, Promise<Conversation>>());
const releaseUnretainedConversationHistories = useCallback((): void => {
const retained = retainedConversationDetailIds();
setConversations(current => {
let changed = false;
const next = current.map(conversation => {
if (conversation.messageSummary || retained.has(conversation.id) ||
conversationHistoryRequests.current.has(conversation.id) ||
conversation.activeRequest ||
(!conversation.remote && persistedLocalConversationsRef.current.get(conversation.id) !== conversation) ||
conversation.messages.some(message => message.state === "streaming" ||
message.approval || message.pendingQuestions?.length) ||
assistantTasksRef.current.some(task => task.conversationId === conversation.id &&
(task.status === "running" || task.status === "waiting_approval"))) return conversation;
changed = true;
const summary: Conversation = {
...conversation, messages: [],
messageSummary: { count: conversation.messages.length, firstRole: conversation.messages[0]?.role },
};
if (!conversation.remote) persistedLocalConversationsRef.current.set(conversation.id, summary);
return summary;
});
return changed ? next : current;
});
}, [retainedConversationDetailIds]);
const persistLocalConversationChanges = useCallback((): void => {
const operation = conversationPersistenceQueueRef.current.then(async () => {
if (conversationPersistencePausedRef.current) {
@@ -5005,6 +5102,7 @@ function App(): React.JSX.Element {
deletingLocalConversationIdsRef.current,
);
if (batch.length === 0) {
releaseUnretainedConversationHistories();
return;
}
await window.goodbuddy.conversations.saveLocal(batch);
@@ -5014,6 +5112,7 @@ function App(): React.JSX.Element {
conversation,
);
}
releaseUnretainedConversationHistories();
});
conversationPersistenceQueueRef.current = operation.catch(() => undefined);
void operation.catch(() => {
@@ -5023,7 +5122,37 @@ function App(): React.JSX.Element {
dedupeKey: "conversation-persistence",
});
});
}, []);
}, [releaseUnretainedConversationHistories]);
const ensureConversationHistory = useCallback((conversationId: string): Promise<Conversation> => {
const current = conversationsRef.current.find(item => item.id === conversationId);
if (!current) return Promise.reject(new Error(tRef.current("notices.remoteConversationRefreshFailed")));
if (!current.messageSummary) return Promise.resolve(current);
const pending = conversationHistoryRequests.current.get(conversationId);
if (pending) return pending;
const request = window.goodbuddy.conversations.get(conversationId).then(snapshot => {
const latest = conversationsRef.current.find(item => item.id === conversationId);
if (!latest || deletingLocalConversationIdsRef.current.has(conversationId)) {
throw new Error(tRef.current("notices.remoteConversationRefreshFailed"));
}
if (!latest.messageSummary) return latest;
const next = mergePersistedConversations(
conversationsRef.current, [snapshot], persistedLocalConversationsRef.current,
retainedConversationDetailIds(),
);
conversationsRef.current = next;
setConversations(next);
return next.find(item => item.id === conversationId)!;
}).finally(() => { conversationHistoryRequests.current.delete(conversationId); });
conversationHistoryRequests.current.set(conversationId, request);
return request;
}, [retainedConversationDetailIds]);
useEffect(() => {
if (!conversationStoreReady) return;
// Reuse the view cache's lifetime. Release only acknowledged, idle history,
// including its persistence reference, never unsaved or executing messages.
persistLocalConversationChanges();
}, [cachedConversationViews, conversationStoreReady, persistLocalConversationChanges]);
useEffect(() => {
if (!conversationStoreReady) {
@@ -5186,7 +5315,7 @@ function App(): React.JSX.Element {
}
});
const conversationsRefresh = window.goodbuddy.conversations
.list()
.listSummaries([...retainedConversationDetailIds()])
.then((persisted) => {
if (!active || sequence !== refreshSequence) {
return;
@@ -5230,6 +5359,7 @@ function App(): React.JSX.Element {
current,
persisted,
persistedLocalConversationsRef.current,
retainedConversationDetailIds(),
),
);
settleActiveRunsFromPersistedMessages(persisted);
@@ -5277,6 +5407,7 @@ function App(): React.JSX.Element {
}, [
conversationStoreReady,
releaseConversationQueueAfterRun,
retainedConversationDetailIds,
setConversationActivity,
]);
@@ -5589,7 +5720,7 @@ function App(): React.JSX.Element {
let active = true;
const initialization = Promise.all([
window.goodbuddy.projects.list(false),
window.goodbuddy.conversations.list(),
window.goodbuddy.conversations.listSummaries(),
]).then(async ([value, persistedConversations]) => {
if (!active || value.length === 0) {
return;
@@ -5600,6 +5731,11 @@ function App(): React.JSX.Element {
throw new Error("没有可用的本地项目");
}
setActiveProjectId(project.id);
const initialConversation = persistedConversations.find(item => item.projectId === project.id);
if (initialConversation?.messageSummary) {
const detail = await window.goodbuddy.conversations.get(initialConversation.id);
persistedConversations = persistedConversations.map(item => item.id === detail.id ? detail : item);
}
const persistedLocalConversations = persistedConversations.filter(
(conversation) => !conversation.remote,
);
@@ -6763,9 +6899,22 @@ function App(): React.JSX.Element {
[t],
);
const readConversationForExport = async (item: Conversation): Promise<Conversation> => {
if (!item.messageSummary) return item;
const snapshot = await window.goodbuddy.conversations.get(item.id);
// Export reads do not acquire a view-cache entry or retain tool metadata.
return mergePersistedConversations([item], [snapshot], new Map())[0]!;
};
const copyConversation = async (
conversation: ConversationSnapshot,
item: Conversation,
): Promise<void> => {
let conversation: Conversation;
try { conversation = await readConversationForExport(item); }
catch {
notify({ tone: "error", message: t("notices.remoteConversationRefreshFailed") });
return;
}
const transcript = conversation.messages
.map((message) =>
t("chat.exportSpeaker", {
@@ -6786,7 +6935,13 @@ function App(): React.JSX.Element {
[t, writeClipboardText],
);
const exportConversation = (conversation: ConversationSnapshot): void => {
const exportConversation = async (item: Conversation): Promise<void> => {
let conversation: Conversation;
try { conversation = await readConversationForExport(item); }
catch {
notify({ tone: "error", message: t("notices.remoteConversationRefreshFailed") });
return;
}
const markdown = [
`# ${conversation.title}`,
"",
@@ -6983,11 +7138,20 @@ function App(): React.JSX.Element {
});
}
};
const conversationSnapshot = queuedInput
let conversationSnapshot = queuedInput
? conversationsRef.current.find(
(conversation) => conversation.id === queuedInput.conversationId,
)
: activeConversation;
if (conversationSnapshot?.messageSummary) {
try {
conversationSnapshot = await ensureConversationHistory(conversationSnapshot.id);
} catch {
notify({ tone: "error", message: t("notices.remoteConversationRefreshFailed") });
await releaseQueuedItem();
return;
}
}
const recoveryProjectId =
queuedInput?.projectId ?? conversationSnapshot?.projectId;
if (
@@ -7396,7 +7560,13 @@ function App(): React.JSX.Element {
) {
return;
}
const history = activeConversation.messages.filter(
let completeConversation: Conversation;
try { completeConversation = await ensureConversationHistory(activeConversation.id); }
catch {
notify({ tone: "error", message: t("notices.remoteConversationRefreshFailed") });
return;
}
const history = completeConversation.messages.filter(
(message) => message.state === "complete" && message.content.trim(),
);
if (history.length < 2) {
@@ -7959,8 +8129,8 @@ function App(): React.JSX.Element {
);
if (!conversation) {
try {
const persisted = await window.goodbuddy.conversations.list();
conversation = persisted.find((candidate) => candidate.id === conversationId);
conversation = await window.goodbuddy.conversations.get(conversationId);
const persisted = [conversation];
setConversations((current) => mergePersistedConversations(
current, persisted, persistedLocalConversationsRef.current,
));
@@ -8060,7 +8230,7 @@ function App(): React.JSX.Element {
const [conversationResult, taskResult, scheduleResult] =
await Promise.allSettled([
window.goodbuddy.conversations.list(),
window.goodbuddy.conversations.listSummaries([...retainedConversationDetailIds(), schedule.conversationId]),
window.goodbuddy.tasks.list(),
window.goodbuddy.schedules.list(),
]);
@@ -8070,6 +8240,7 @@ function App(): React.JSX.Element {
current,
conversationResult.value,
persistedLocalConversationsRef.current,
retainedConversationDetailIds(),
),
);
} else {
@@ -9299,7 +9470,14 @@ function App(): React.JSX.Element {
<KeepAliveRoute active={view === "chat"} route="chat">
<PageShell variant="reading">
<div className="chat-scroll-region">
{cachedConversations.map((conversation) => (
{cachedConversations.map((conversation) => conversation.messageSummary ? (
<ConversationHistoryLoader
key={conversation.id}
conversationId={conversation.id}
active={view === "chat" && conversation.id === activeId}
load={ensureConversationHistory}
/>
) : (
<ChatHistoryPane
active={view === "chat" && conversation.id === activeId}
artifactById={assistantArtifactById}
@@ -72,6 +72,9 @@ export const app = {
switchDark: 'Switch to dark theme'
},
conversation: {
historyLoading: 'Loading conversation history…',
historyLoadFailed: 'Failed to load conversation history',
historyRetry: 'Reload history',
defaultTitle: 'New conversation',
remoteTitle: 'Remote conversation',
greeting:
@@ -69,6 +69,9 @@ export const app = {
switchDark: '切换深色主题'
},
conversation: {
historyLoading: '正在加载对话记录…',
historyLoadFailed: '对话记录加载失败',
historyRetry: '重新加载',
defaultTitle: '新对话',
remoteTitle: '远程会话',
greeting:
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import type { Message } from './ChatTimeline'
import { mergeMessageImageState } from './message-image-state'
describe('mergeMessageImageState', () => {
const message: Message = {
id: 'message', role: 'assistant', content: 'Retained history',
state: 'complete', createdAt: 1
}
it('does not mark unchanged ordinary history as dirty after a snapshot refresh', () => {
const incoming = Array.from({ length: 1600 }, () => ({ ...message }))
const merged = incoming.map(item => mergeMessageImageState(item, item, { ...item }))
expect(merged.filter((item, index) => item !== incoming[index])).toHaveLength(0)
})
it('preserves unchanged artifact references and streaming message identity', () => {
const persisted = { ...message, artifactIds: ['image'] }
expect(mergeMessageImageState(persisted, persisted, { ...persisted, artifactIds: ['image'] })).toBe(persisted)
const streaming = { ...message, state: 'streaming' as const, content: 'New text' }
expect(mergeMessageImageState(streaming, message, streaming)).toBe(streaming)
})
it('merges newer local image operations without losing the selected message body', () => {
const operation = {
id: 'image-operation', conversationId: 'conversation', messageId: 'message',
modelProfileId: 'profile', modelName: 'image', requestId: 'request', callId: 'call',
input: { prompt: 'circle', intent: 'create' as const, sourceArtifactIds: [] },
state: 'completed' as const, artifactIds: ['new-image'],
createdAt: 1, updatedAt: 2
}
const local: Message = { ...message, imageOperations: [operation] }
const result = mergeMessageImageState(message, message, local)
expect(result.content).toBe(message.content)
expect(result.imageOperations).toEqual([operation])
expect(result.artifactIds).toEqual(['new-image'])
})
})
+43
View File
@@ -0,0 +1,43 @@
import type { Message } from './ChatTimeline'
function sameItems<T>(left: readonly T[] | undefined, right: readonly T[] | undefined): boolean {
return left === right || (
(left?.length ?? 0) === (right?.length ?? 0) &&
(left ?? []).every((item, index) => item === right?.[index])
)
}
/** Preserve message identity when reconciling unchanged, Main-owned image state. */
export function mergeMessageImageState(
base: Message,
persisted: Message,
local: Message | undefined
): Message {
if (!persisted.imageOperations?.length && !local?.imageOperations?.length &&
!persisted.imageSourceArtifactIds?.length && !local?.imageSourceArtifactIds?.length &&
!persisted.artifactIds?.length && !local?.artifactIds?.length) return base
const operations = new Map(persisted.imageOperations?.map(operation => [operation.id, operation]))
for (const operation of local?.imageOperations ?? []) {
if (operation.updatedAt > (operations.get(operation.id)?.updatedAt ?? -1)) {
operations.set(operation.id, operation)
}
}
const imageOperations = [...operations.values()]
const imageSourceArtifactIds = persisted.imageSourceArtifactIds ?? local?.imageSourceArtifactIds
const artifactIds = [...new Set([
...(base === local ? local?.artifactIds ?? [] : []),
...(persisted.artifactIds ?? []),
...imageOperations.flatMap(operation => operation.artifactIds)
])].slice(-8)
if (sameItems(base.imageOperations, imageOperations) &&
sameItems(base.imageSourceArtifactIds, imageSourceArtifactIds) &&
sameItems(base.artifactIds, artifactIds)) return base
return {
...base,
imageOperations: imageOperations.length ? imageOperations : undefined,
imageSourceArtifactIds,
artifactIds: artifactIds.length ? artifactIds : undefined
}
}
+16
View File
@@ -567,6 +567,22 @@ export const conversationSnapshotSchema = z
export type ConversationSnapshot = z.infer<
typeof conversationSnapshotSchema
>
// Read-only list projection. An absent summary means messages are complete.
// Never serialize messageSummary as a persisted conversation/header.
export type ConversationListSnapshot = ConversationSnapshot & {
messageSummary?: {
count: number
firstRole?: ConversationMessage['role']
}
}
export const conversationListRequestSchema = z.object({
detailIds: z.array(assistantIdSchema).default([])
}).strict()
export const conversationSearchRequestSchema = z.object({
query: z.string(),
conversationIds: z.array(assistantIdSchema).default([])
}).strict()
export const conversationSnapshotsSchema = z
.array(conversationSnapshotSchema)
.max(100)
+3
View File
@@ -2054,6 +2054,9 @@ export type DesktopApi = {
conversations: {
imageOperations: import('./image-operation-ipc').ImageOperationsApi
list: () => Promise<ConversationSnapshot[]>
listSummaries: (detailIds?: string[]) => Promise<import('./assistant-contracts').ConversationListSnapshot[]>
get: (conversationId: string) => Promise<ConversationSnapshot>
search: (query: string, conversationIds?: string[]) => Promise<string[]>
replace: (conversations: ConversationSnapshot[]) => Promise<void>
saveLocal: (batch: LocalConversationSaveBatch) => Promise<void>
branchLocal: (
+3
View File
@@ -185,6 +185,9 @@ export const ipcChannels = {
remoteProjectRecoveryProgress:
'projects:remote:recovery:progress',
conversationsList: 'conversations:list',
conversationsListSummaries: 'conversations:list-summaries',
conversationsGet: 'conversations:get',
conversationsSearch: 'conversations:search',
conversationsReplace: 'conversations:replace',
conversationsSaveLocal: 'conversations:save-local',
conversationsBranchLocal: 'conversations:branch-local',