Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ import {
encodeDesktopTranscriptChange,
encodeDesktopTranscriptSnapshot,
} from '../desktop-transcript-ipc.js';
import { DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES } from '../../preload/transcript-contract.js';
import {
DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES,
DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES,
} from '../../preload/transcript-contract.js';
import {
createDesktopTranscriptRangeController,
DesktopTranscriptRangeStore,
Expand Down Expand Up @@ -297,6 +300,100 @@ test('loads a history target with newer messages available below it', async () =
assert.equal(replica.snapshot().hasNewer, true);
});

test('keeps an oversized transcript sparse while moving between indexed prompts', async () => {
const messages = syntheticLargeTranscript();
const totalBytes = messages.reduce(
(total, entry) => total + Buffer.byteLength(JSON.stringify(entry.message), 'utf8'),
0,
);
assert.ok(totalBytes > DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES * 2);

const bootstrapPage = transcriptPage('older', 'older', 15);
const historicalPage = transcriptPage('newer', 'newer', 15);
const intermediatePage = transcriptPage('newer', 'newer', 15);
const latestPage = transcriptPage('older', 'older', 15);
const requests: Array<{
direction: 'older' | 'newer';
anchorSequence: number | null;
maxBytes: number;
}> = [];
const rendererStore = transcriptStore();
const generation = 'oversized-range';
const handle = runtimeHostSessionFixture({
snapshot: continuitySnapshot(),
transcript: Promise.resolve([]),
events: { async *[Symbol.asyncIterator]() {} },
transcriptBootstrap: {
throughSequence: 15,
overlayMessageCount: 0,
durable: bootstrapPage,
overlay: { ...transcriptPage('older', null, 15), source: 'overlay' },
},
loadTranscriptOverlay: async () => [],
decodeTranscriptPage: async (page) => page === bootstrapPage || page === latestPage
? { messages: messages.slice(12, 16), nextCursor: 'older' }
: page === historicalPage
? { messages: messages.slice(0, 5), nextCursor: 'newer' }
: { messages: messages.slice(6, 11), nextCursor: 'newer' },
loadTranscriptPage: async (input) => {
requests.push({
direction: input.direction,
anchorSequence: input.anchorSequence,
maxBytes: input.maxBytes,
});
if (input.direction === 'older') return latestPage;
return input.anchorSequence === null ? historicalPage : intermediatePage;
},
async close() {},
});
const replica = await DesktopTranscriptReplica.prepare(handle, {
generation,
onChange: (_current, change) => {
for (const batch of encodeDesktopTranscriptChange({
sessionId: 'session-1',
generation,
hostEpoch: 'host-1',
}, change)) rendererStore.accept(batch);
},
});
for (const batch of encodeDesktopTranscriptSnapshot(replica.snapshot())) {
rendererStore.accept(batch);
}

assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [12, 13, 14, 15]);
assert.deepEqual(renderedUserPrompts(rendererStore), ['Prompt 7', 'Prompt 8']);
assert.equal(rendererStore.range().hasNewer, false);
assertRangeFitsBudget(rendererStore);

await replica.loadAround(0, DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES);
assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [0, 1, 2, 3, 4]);
assert.deepEqual(renderedUserPrompts(rendererStore), ['Prompt 1', 'Prompt 2', 'Prompt 3']);
assert.equal(rendererStore.range().hasOlder, false);
assert.equal(rendererStore.range().hasNewer, true);
assertRangeFitsBudget(rendererStore);

await replica.loadAround(6, DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES);
assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [6, 7, 8, 9, 10]);
assert.deepEqual(renderedUserPrompts(rendererStore), ['Prompt 4', 'Prompt 5', 'Prompt 6']);
assert.equal(rendererStore.range().hasOlder, true);
assert.equal(rendererStore.range().hasNewer, true);
assertRangeFitsBudget(rendererStore);

await replica.loadAround(15, DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES);
assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [12, 13, 14, 15]);
assert.deepEqual(renderedUserPrompts(rendererStore), ['Prompt 7', 'Prompt 8']);
assert.equal(rendererStore.range().hasOlder, true);
assert.equal(rendererStore.range().hasNewer, false);
assertRangeFitsBudget(rendererStore);

assert.deepEqual(requests, [
{ direction: 'newer', anchorSequence: null, maxBytes: DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES },
{ direction: 'newer', anchorSequence: 5, maxBytes: DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES },
{ direction: 'older', anchorSequence: 16, maxBytes: DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES },
]);
replica.close();
});

test('rejects an overlay that exceeds its cache budget', async () => {
const messages = [
assistantMessage('x'.repeat(700), 'overlay-1'),
Expand Down Expand Up @@ -562,6 +659,43 @@ function transcriptPage(
};
}

function syntheticLargeTranscript(): Array<{ identity: number; message: StoredMessage }> {
return Array.from({ length: 8 }, (_, index) => {
const number = index + 1;
const turnId = `turn-${number}`;
return [
{
identity: index * 2,
message: {
...userMessage(`Prompt ${number}`, `user-${number}`),
turnId,
},
},
{
identity: index * 2 + 1,
message: {
...assistantMessage('x'.repeat(180 * 1024), `assistant-${number}`),
turnId,
},
},
];
}).flat();
}

function renderedUserPrompts(store: DesktopTranscriptRangeStore): string[] {
return store.snapshot().messages.flatMap((message) =>
message.type === 'user' ? [message.text] : [],
);
}

function assertRangeFitsBudget(store: DesktopTranscriptRangeStore): void {
const bytes = store.snapshot().messages.reduce(
(total, message) => total + Buffer.byteLength(JSON.stringify(message), 'utf8'),
0,
);
assert.ok(bytes <= DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES);
}

function continuitySnapshot() {
return {
schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION,
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/renderer/chat-message-surface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,8 @@ export function ChatMessageSurface({
historyLoadPending={historyLoadPending}
onLoadEarlierHistory={onLoadEarlierHistory}
returnToLatest={hasNewerHistory ? {
title: transcriptCopy.partialHistoryTitle,
description: transcriptCopy.partialHistoryDescription,
label: transcriptCopy.returnLatest,
isPending: historyLoadPending,
onClick: onReturnToLatestHistory,
Expand Down
6 changes: 4 additions & 2 deletions apps/desktop/src/renderer/locales/conversation-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ export interface DesktopConversationCopy {
modelReboundTitle: string;
modelReboundDescription: (modelId?: string) => string;
messageReadFailedTitle: string;
partialHistoryTitle: string;
partialHistoryDescription: string;
returnLatest: string;
scrollMainToBottom: string;
};
Expand Down Expand Up @@ -416,7 +418,7 @@ function enDetail(parts: readonly string[]): string {

const COPY = {
zh: {
actions: { stopFailedTitle: '停止失败', stopFailedFallback: '任务操作失败,请稍后重试。', refreshSessionsFailedTitle: '刷新任务列表失败', refreshSessionsFailedFallback: '刷新任务列表失败,请稍后重试。', conversationErrorTitle: '任务出错', conversationErrorFallback: '任务运行失败,请稍后重试。', regenerateStartedTitle: '已发起重新生成', regenerateStartedDescription: '正在生成新的一轮回答', branchCreatedTitle: '已创建分支', branchCreatedDescription: (name) => `新任务 ${name}`, revisionStartedTitle: '已创建修改版草稿', revisionStartedDescription: '原任务仍会保留;修改后发送将在新版本中继续', revisionReadyTitle: '可以修改并重发了', revisionReadyDescription: '已回到该消息之前;编辑后发送即可', revisionUnavailableTitle: '暂时无法编辑这条消息', revisionAttachmentsUnsupported: '包含附件的历史消息暂不支持编辑并重发,请复制文字后新建消息。', revisionTransformedTextUnsupported: '通过显式技能发送的历史消息暂不支持编辑并重发,请复制文字后重新选择技能。', revisionDraftAttachmentConflict: 'Composer 中已有待发送附件,请先发送或移除附件,再编辑历史消息。', revisionCommandUnsupported: '修改消息时不能执行 /compact、/side 或编排命令,请取消修改后再试。', revisionAlreadyActive: '已有一条消息正在修改,请先发送或取消当前修改。', revisionCancelLabel: '取消', revisionBannerTitle: '正在修改已发送消息', revisionBannerDetail: '· 发送后创建新版本', revisionUnchanged: '内容没有变化。如需重新回答,请使用“重新生成”。', operationFailedTitle: '操作失败', operationFailedFallback: '任务操作失败,请稍后重试。', attachmentFailedTitle: '添加附件失败', tryAgain: '请稍后重试。', modelReboundTitle: '已切换到可用模型', modelReboundDescription: (modelId) => `原任务使用的连接已不可用${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: '读取任务失败', returnLatest: '返回最新消息', scrollMainToBottom: '滚动主对话到底部' },
actions: { stopFailedTitle: '停止失败', stopFailedFallback: '任务操作失败,请稍后重试。', refreshSessionsFailedTitle: '刷新任务列表失败', refreshSessionsFailedFallback: '刷新任务列表失败,请稍后重试。', conversationErrorTitle: '任务出错', conversationErrorFallback: '任务运行失败,请稍后重试。', regenerateStartedTitle: '已发起重新生成', regenerateStartedDescription: '正在生成新的一轮回答', branchCreatedTitle: '已创建分支', branchCreatedDescription: (name) => `新任务 ${name}`, revisionStartedTitle: '已创建修改版草稿', revisionStartedDescription: '原任务仍会保留;修改后发送将在新版本中继续', revisionReadyTitle: '可以修改并重发了', revisionReadyDescription: '已回到该消息之前;编辑后发送即可', revisionUnavailableTitle: '暂时无法编辑这条消息', revisionAttachmentsUnsupported: '包含附件的历史消息暂不支持编辑并重发,请复制文字后新建消息。', revisionTransformedTextUnsupported: '通过显式技能发送的历史消息暂不支持编辑并重发,请复制文字后重新选择技能。', revisionDraftAttachmentConflict: 'Composer 中已有待发送附件,请先发送或移除附件,再编辑历史消息。', revisionCommandUnsupported: '修改消息时不能执行 /compact、/side 或编排命令,请取消修改后再试。', revisionAlreadyActive: '已有一条消息正在修改,请先发送或取消当前修改。', revisionCancelLabel: '取消', revisionBannerTitle: '正在修改已发送消息', revisionBannerDetail: '· 发送后创建新版本', revisionUnchanged: '内容没有变化。如需重新回答,请使用“重新生成”。', operationFailedTitle: '操作失败', operationFailedFallback: '任务操作失败,请稍后重试。', attachmentFailedTitle: '添加附件失败', tryAgain: '请稍后重试。', modelReboundTitle: '已切换到可用模型', modelReboundDescription: (modelId) => `原任务使用的连接已不可用${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: '读取任务失败', partialHistoryTitle: '正在查看较早的一段对话', partialHistoryDescription: '较新的消息仍已保存,但当前未加载。', returnLatest: '返回最新消息', scrollMainToBottom: '滚动主对话到底部' },
attachments: { tooMany: '附件数量超过 8 个', tooLarge: '附件大小超过 50MB', duplicate: '附件来源重复,请勿重复添加同一文件。' },
model: {
fakeBackendLabel: '本地模拟连接',
Expand Down Expand Up @@ -641,7 +643,7 @@ const COPY = {
turnError: { unknown: '未知错误', contextOverflow: '上下文窗口已超出限制', contextBudgetExhausted: '上下文已达到上限,当前任务无法继续', malformedSummary: '上下文压缩未能生成有效摘要。请检查模型的上下文窗口设置、切换模型,或开启新任务。', timeout: '请求超时', auth: '鉴权失败', providerBilling: '模型服务计费受限', providerCapacity: '模型服务暂时满载,请稍后重试或切换模型', rateLimit: '触发模型速率限制', network: '网络错误', provider: '模型服务返回错误', stepCap: '达到工具步骤上限', tool: '工具调用失败', permission: '等待权限确认', restarted: '本地应用重启,上一轮没有完成', sandboxBoundaryClosed: '本地应用重启,等待确认的「允许访问工作区以外的内容」请求已按拒绝关闭', recovery: { safeResume: '检查当前状态后,可尝试安全恢复', stepCap: '任务可能尚未完成,可以继续', toolError: '先检查工具结果,再决定是否重试', connection: '先检查模型连接或登录状态', partial: '已保留部分输出,可从这里继续', toolRecord: '工具记录已保留,重试前先看结果', retry: '没有执行工具,可直接重试', capacity: '模型服务暂时满载,请等待几分钟或切换模型后重试', contextOverflow: '上下文仍超出限制,请减少附件或开启新任务', contextBudgetExhausted: '检查模型的上下文窗口设置、切换模型,或开启新任务', sandboxBoundaryClosed: '访问范围没有放开,重试本轮后可重新决定' } },
},
en: {
actions: { stopFailedTitle: 'Failed to stop', stopFailedFallback: 'The task action failed. Try again later.', refreshSessionsFailedTitle: 'Failed to refresh tasks', refreshSessionsFailedFallback: 'The task list could not be refreshed. Try again later.', conversationErrorTitle: 'Task error', conversationErrorFallback: 'The task run failed. Try again later.', regenerateStartedTitle: 'Regeneration started', regenerateStartedDescription: 'Generating a new response', branchCreatedTitle: 'Branch created', branchCreatedDescription: (name) => `New task: ${name}`, revisionStartedTitle: 'Edit draft ready', revisionStartedDescription: 'The original task is kept; sending creates a new version', revisionReadyTitle: 'Ready to edit and resend', revisionReadyDescription: 'Rewound to before that message; edit and send when ready', revisionUnavailableTitle: 'This message cannot be edited yet', revisionAttachmentsUnsupported: 'Edit & resend does not yet support historical attachments. Copy the text into a new message instead.', revisionTransformedTextUnsupported: 'Edit & resend does not yet support messages sent with an explicit skill. Copy the text and select the skill again instead.', revisionDraftAttachmentConflict: 'The composer already has pending attachments. Send or remove them before editing a sent message.', revisionCommandUnsupported: 'You cannot run /compact, /side, or orchestration commands while editing a sent message. Cancel the edit first.', revisionAlreadyActive: 'Another message is already being edited. Send or cancel that edit first.', revisionCancelLabel: 'Cancel', revisionBannerTitle: 'Editing sent message', revisionBannerDetail: '· New version on send', revisionUnchanged: 'Nothing changed. Use Regenerate if you only want a new answer.', operationFailedTitle: 'Action failed', operationFailedFallback: 'The task action failed. Try again later.', attachmentFailedTitle: 'Failed to add attachment', tryAgain: 'Try again later.', modelReboundTitle: 'Switched to an available model', modelReboundDescription: (modelId) => `The previous connection is unavailable${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: 'Failed to load task', returnLatest: 'Return to latest', scrollMainToBottom: 'Scroll main conversation to bottom' },
actions: { stopFailedTitle: 'Failed to stop', stopFailedFallback: 'The task action failed. Try again later.', refreshSessionsFailedTitle: 'Failed to refresh tasks', refreshSessionsFailedFallback: 'The task list could not be refreshed. Try again later.', conversationErrorTitle: 'Task error', conversationErrorFallback: 'The task run failed. Try again later.', regenerateStartedTitle: 'Regeneration started', regenerateStartedDescription: 'Generating a new response', branchCreatedTitle: 'Branch created', branchCreatedDescription: (name) => `New task: ${name}`, revisionStartedTitle: 'Edit draft ready', revisionStartedDescription: 'The original task is kept; sending creates a new version', revisionReadyTitle: 'Ready to edit and resend', revisionReadyDescription: 'Rewound to before that message; edit and send when ready', revisionUnavailableTitle: 'This message cannot be edited yet', revisionAttachmentsUnsupported: 'Edit & resend does not yet support historical attachments. Copy the text into a new message instead.', revisionTransformedTextUnsupported: 'Edit & resend does not yet support messages sent with an explicit skill. Copy the text and select the skill again instead.', revisionDraftAttachmentConflict: 'The composer already has pending attachments. Send or remove them before editing a sent message.', revisionCommandUnsupported: 'You cannot run /compact, /side, or orchestration commands while editing a sent message. Cancel the edit first.', revisionAlreadyActive: 'Another message is already being edited. Send or cancel that edit first.', revisionCancelLabel: 'Cancel', revisionBannerTitle: 'Editing sent message', revisionBannerDetail: '· New version on send', revisionUnchanged: 'Nothing changed. Use Regenerate if you only want a new answer.', operationFailedTitle: 'Action failed', operationFailedFallback: 'The task action failed. Try again later.', attachmentFailedTitle: 'Failed to add attachment', tryAgain: 'Try again later.', modelReboundTitle: 'Switched to an available model', modelReboundDescription: (modelId) => `The previous connection is unavailable${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: 'Failed to load task', partialHistoryTitle: 'Viewing an earlier part of this conversation', partialHistoryDescription: 'Newer messages are still saved, but are not currently loaded.', returnLatest: 'Return to latest', scrollMainToBottom: 'Scroll main conversation to bottom' },
attachments: { tooMany: 'You can attach at most 8 files', tooLarge: 'Attachments must be 50 MB or smaller', duplicate: 'This attachment was already added.' },
model: {
fakeBackendLabel: 'Local simulation',
Expand Down
31 changes: 28 additions & 3 deletions apps/desktop/src/renderer/styles/chat-message.css
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,32 @@
}
.maka-transcript-history-controls {
display: flex;
justify-content: center;
gap: 8px;
padding: 8px 0;
box-sizing: border-box;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-2) var(--space-4);
margin: var(--space-2) clamp(var(--space-3), 4vw, var(--space-10));
padding: var(--space-2) var(--space-3);
border: var(--border-width-hairline) solid var(--border-soft);
border-radius: var(--radius-element);
background: var(--surface-raised);
}

.maka-transcript-history-copy {
display: flex;
min-width: min(100%, 28rem);
flex: 1 1 28rem;
flex-direction: column;
gap: var(--space-0-5);
}

.maka-transcript-history-title {
font: var(--maka-text-label);
color: var(--foreground);
}

.maka-transcript-history-description {
font: var(--maka-text-supporting);
color: var(--muted-foreground);
}
Loading