From 17320045dc3b6a4d0fd7666823cd6c9c360635f7 Mon Sep 17 00:00:00 2001 From: digua Date: Fri, 7 Aug 2026 14:47:15 +0800 Subject: [PATCH] feat(ai): enable automatic context compression --- apps/cli/src/ai/agent-stream-runner.ts | 14 +- apps/cli/src/ai/agent.ts | 10 +- apps/desktop/main/ai/agent-stream-runner.ts | 10 +- apps/web-wasm/src/components/ai/WebAIChat.vue | 8 + .../ai-runtime/src/context-compression.ts | 140 +++++++++++++ packages/ai-runtime/src/index.ts | 1 + packages/ai-runtime/src/runtime.test.ts | 190 ++++++++++++++++++ packages/ai-runtime/src/runtime.ts | 4 +- packages/ai-runtime/src/types.ts | 15 ++ packages/http-routes/src/context/ai.ts | 6 - .../compression/__tests__/compressor.test.ts | 1 - .../src/ai/compression/compressor.ts | 6 +- .../node-runtime/src/ai/compression/index.ts | 1 + .../node-runtime/src/ai/compression/types.ts | 18 +- packages/node-runtime/src/ai/index.ts | 7 +- packages/node-runtime/src/index.ts | 3 +- packages/node-runtime/src/preferences.test.ts | 27 +++ packages/node-runtime/src/preferences.ts | 28 +-- packages/shared-types/index.ts | 8 - .../web-ai-runtime/src/provider-factory.ts | 3 + packages/web-ai-runtime/src/rpc-adapters.ts | 10 + packages/web-ai-runtime/src/runtime.test.ts | 16 +- packages/web-ai-runtime/src/runtime.ts | 2 + .../src/ai/browser-ai-runtime.test.ts | 10 +- .../src/ai/conversation-repository.ts | 61 ++++++ packages/web-runtime/src/rpc/protocol.ts | 12 ++ packages/web-runtime/src/worker/controller.ts | 6 + .../Settings/AI/AIPromptConfigTab.test.ts | 11 - .../common/Settings/AI/AIPromptConfigTab.vue | 122 ----------- .../common/Settings/AI/promptConfigLimits.ts | 7 - .../common/Settings/AISettingsTab.vue | 1 - src/i18n/locales/en-US/settings.json | 12 -- src/i18n/locales/en-US/webAI.json | 1 + src/i18n/locales/ja-JP/settings.json | 12 -- src/i18n/locales/ja-JP/webAI.json | 1 + src/i18n/locales/zh-CN/settings.json | 12 -- src/i18n/locales/zh-CN/webAI.json | 1 + src/i18n/locales/zh-TW/settings.json | 12 -- src/i18n/locales/zh-TW/webAI.json | 1 + src/services/ai-stream/types.ts | 8 - src/services/preferences/types.ts | 1 - src/stores/aiChat.ts | 12 -- src/stores/prompt.ts | 24 +-- 43 files changed, 546 insertions(+), 309 deletions(-) create mode 100644 packages/ai-runtime/src/context-compression.ts delete mode 100644 src/components/common/Settings/AI/AIPromptConfigTab.test.ts delete mode 100644 src/components/common/Settings/AI/promptConfigLimits.ts diff --git a/apps/cli/src/ai/agent-stream-runner.ts b/apps/cli/src/ai/agent-stream-runner.ts index 75fed93e..c2b1276e 100644 --- a/apps/cli/src/ai/agent-stream-runner.ts +++ b/apps/cli/src/ai/agent-stream-runner.ts @@ -12,6 +12,7 @@ import type { } from '@openchatlab/node-runtime' import { CHART_CAPABILITY_CORE_TOOLS, + DEFAULT_CONTEXT_COMPRESSION_CONFIG, SkillManager, buildPiModel, buildSkillMenuWithBuiltinChart, @@ -93,7 +94,6 @@ export function createCliRunAgentStream( skillId, enableAutoSkill, chartAutoMode, - compressionConfig, ownerInfo, mentionedMembers, thinkingLevel, @@ -110,7 +110,7 @@ export function createCliRunAgentStream( } const llmConfig = llmConfigStore.getDefaultAssistantConfig() - const maxToolResultPercent = compressionConfig?.maxToolResultPercent ?? 50 + const maxToolResultPercent = DEFAULT_CONTEXT_COMPRESSION_CONFIG.maxToolResultPercent ?? 50 const contextWindow = llmConfig ? (buildPiModel(llmConfig).contextWindow ?? 128000) : 128000 const maxToolResultTokens = Math.floor(contextWindow * (maxToolResultPercent / 100)) @@ -196,15 +196,6 @@ export function createCliRunAgentStream( agentTools.push(activateSkillTool as any) } - const resolvedCompression = compressionConfig?.enabled - ? { - enabled: true as const, - tokenThresholdPercent: compressionConfig.tokenThresholdPercent ?? 75, - bufferSizePercent: compressionConfig.bufferSizePercent ?? 20, - maxToolResultPercent: compressionConfig.maxToolResultPercent, - } - : undefined - let dataSnapshot: DataSnapshot | undefined if (db) { try { @@ -223,7 +214,6 @@ export function createCliRunAgentStream( assistantSystemPrompt, skillMenu: resolvedSkillMenu, skillDef: resolvedSkillDef, - compressionConfig: resolvedCompression, tools: agentTools, llmConfig, aiChatManager, diff --git a/apps/cli/src/ai/agent.ts b/apps/cli/src/ai/agent.ts index 67280198..d8a03269 100644 --- a/apps/cli/src/ai/agent.ts +++ b/apps/cli/src/ai/agent.ts @@ -7,6 +7,7 @@ import { DEFAULT_MAX_TOOL_ROUNDS, + DEFAULT_CONTEXT_COMPRESSION_CONFIG, buildPiModel, buildPlanGuidance, createAnalysisPlanner, @@ -27,7 +28,6 @@ import { type PiMessage, type SimpleHistoryMessage, type AIChatManager, - type CompressionConfig, type AgentTool, type DataSnapshot, type AIServiceConfig, @@ -49,7 +49,6 @@ export interface RunAgentOptions { assistantSystemPrompt?: string skillMenu?: string | null skillDef?: { name: string; prompt: string } - compressionConfig?: CompressionConfig tools?: AgentTool[] llmConfig: AIServiceConfig | null aiChatManager: AIChatManager @@ -72,7 +71,6 @@ export async function runServerAgent(options: RunAgentOptions): Promise { assistantSystemPrompt, skillMenu, skillDef, - compressionConfig, tools = [], llmConfig, aiChatManager, @@ -123,7 +121,7 @@ export async function runServerAgent(options: RunAgentOptions): Promise { systemPrompt, }) - if (compressionConfig?.enabled && historyLeafMessageId === undefined) { + if (historyLeafMessageId === undefined) { const llmAdapter = createCompressionLlmAdapter({ piModel, apiKey: llmConfig.apiKey, @@ -131,7 +129,7 @@ export async function runServerAgent(options: RunAgentOptions): Promise { }) const compressionResult = await checkAndCompress( aiChatId, - compressionConfig, + DEFAULT_CONTEXT_COMPRESSION_CONFIG, systemPrompt, llmAdapter, aiChatManager, @@ -148,7 +146,7 @@ export async function runServerAgent(options: RunAgentOptions): Promise { }, }) } - } else if (compressionConfig?.enabled && historyLeafMessageId !== undefined) { + } else { aiLogger?.info?.('Compression', 'Skipping compression for edited branch request', { aiChatId, historyLeafMessageId, diff --git a/apps/desktop/main/ai/agent-stream-runner.ts b/apps/desktop/main/ai/agent-stream-runner.ts index 9df0ba0c..10059b5a 100644 --- a/apps/desktop/main/ai/agent-stream-runner.ts +++ b/apps/desktop/main/ai/agent-stream-runner.ts @@ -18,6 +18,7 @@ import { buildSkillMenuWithBuiltinChart, CHART_CAPABILITY_ANALYSIS_TOOLS, checkAndCompress, + DEFAULT_CONTEXT_COMPRESSION_CONFIG, createCompressionLlmAdapter, createDataSnapshotFromOverview, formatAIError, @@ -27,7 +28,7 @@ import { resolveChartRuntimeForRequest, buildSemanticSearchGuidance, } from '@openchatlab/node-runtime' -import type { CompressionConfig, CompressionLlmAdapter, AgentRuntimeStatus } from '@openchatlab/node-runtime' +import type { CompressionLlmAdapter, AgentRuntimeStatus } from '@openchatlab/node-runtime' import { Agent, type AgentStreamChunk, type SkillContext } from './agent' import type { ToolContext } from './tools/types' import { buildPiModel, findModelDefinition } from './llm' @@ -85,7 +86,6 @@ export function createElectronRunAgentStream( skillId, enableAutoSkill, chartAutoMode, - compressionConfig, ownerInfo, mentionedMembers, thinkingLevel, @@ -113,7 +113,7 @@ export function createElectronRunAgentStream( } const piModel = buildPiModel(activeAIConfig) - if (compressionConfig?.enabled && aiChatId && historyLeafMessageId === undefined) { + if (aiChatId && historyLeafMessageId === undefined) { try { const tempAssistantConfig = assistantId ? (assistantManager.getAssistantConfig(assistantId) ?? undefined) @@ -122,7 +122,7 @@ export function createElectronRunAgentStream( const compressionResult = await checkAndCompress( aiChatId, - compressionConfig as CompressionConfig, + DEFAULT_CONTEXT_COMPRESSION_CONFIG, systemPromptForCompression, buildCompressionAdapter(activeAIConfig, () => { onEvent({ @@ -242,7 +242,7 @@ export function createElectronRunAgentStream( } } - const maxToolResultPercent = compressionConfig?.maxToolResultPercent ?? 50 + const maxToolResultPercent = DEFAULT_CONTEXT_COMPRESSION_CONFIG.maxToolResultPercent ?? 50 const modelDef = findModelDefinition(activeAIConfig.provider, activeAIConfig.model || '') const resolvedContextWindow = modelDef?.contextWindow || DEFAULT_CONTEXT_WINDOW const maxToolResultTokens = Math.floor(resolvedContextWindow * (maxToolResultPercent / 100)) diff --git a/apps/web-wasm/src/components/ai/WebAIChat.vue b/apps/web-wasm/src/components/ai/WebAIChat.vue index a8a8ce05..9d400b92 100644 --- a/apps/web-wasm/src/components/ai/WebAIChat.vue +++ b/apps/web-wasm/src/components/ai/WebAIChat.vue @@ -288,6 +288,14 @@ function handleEvent(event: AgentStreamEvent) { } return } + if (event.type === 'context-compression-start') { + processText.value = t('webAI.compressingContext') + return + } + if (event.type === 'context-compression-finish') { + processText.value = '' + return + } const message = streamingMessage.value if (!message) return if (event.type === 'text-delta') { diff --git a/packages/ai-runtime/src/context-compression.ts b/packages/ai-runtime/src/context-compression.ts new file mode 100644 index 00000000..5d830176 --- /dev/null +++ b/packages/ai-runtime/src/context-compression.ts @@ -0,0 +1,140 @@ +import { BUILTIN_MODELS, getBuiltinModelById } from '@openchatlab/core' +import { generateText } from 'ai' + +import type { RunAgentInput, RuntimeContextSummary, RuntimeMessage } from './types' + +const DEFAULT_CONTEXT_WINDOW = 32_768 +const COMPRESSION_THRESHOLD_PERCENT = 75 +const BUFFER_SIZE_PERCENT = 20 +const MAX_SUMMARY_TOKENS = 4_096 +const textEncoder = new TextEncoder() + +const COMPRESSION_PROMPT = `You are compressing a long AI conversation so it can continue within the model context window. + +Return only a concise structured summary in the same language as the conversation. +Preserve important facts, conclusions, user preferences, names, dates, unresolved questions, and next actions. +Merge the previous summary when present. Do not copy long passages verbatim. + +{history}` + +export function resolveRuntimeContextWindow(providerId: string, modelId: string): number { + const exact = getBuiltinModelById(providerId, modelId) + const knownById = exact ?? BUILTIN_MODELS.find((model) => model.id === modelId) + return knownById?.contextWindow ?? DEFAULT_CONTEXT_WINDOW +} + +export async function prepareContextMessages( + input: RunAgentInput, + storedMessages: RuntimeMessage[] +): Promise { + const previousSummary = await input.repository.getContextSummary(input.conversationId) + const { summary, recentMessages } = resolveCurrentHistory(storedMessages, previousSummary) + const effectiveMessages = summary ? [toSummaryMessage(summary), ...recentMessages] : recentMessages + const contextWindow = input.model.contextWindow ?? DEFAULT_CONTEXT_WINDOW + const thresholdTokens = Math.floor(contextWindow * (COMPRESSION_THRESHOLD_PERCENT / 100)) + const currentTokens = estimateMessagesTokens(effectiveMessages, input.systemPrompt, input.userMessage) + + if (currentTokens < thresholdTokens || recentMessages.length === 0) return effectiveMessages + + const bufferTokenBudget = Math.floor(contextWindow * (BUFFER_SIZE_PERCENT / 100)) + const { bufferMessages, messagesToCompress } = splitMessages(recentMessages, bufferTokenBudget) + if (messagesToCompress.length === 0) return effectiveMessages + + input.onEvent({ type: 'context-compression-start' }) + let compressed = false + try { + const history = formatCompressionHistory(summary, messagesToCompress) + const maxOutputTokens = Math.max(64, Math.min(MAX_SUMMARY_TOKENS, Math.floor(contextWindow * 0.1))) + const result = await generateText({ + model: input.model.model, + prompt: COMPRESSION_PROMPT.replace('{history}', history), + maxOutputTokens, + abortSignal: input.signal, + }) + const content = result.text.trim() + if (!content) return effectiveMessages + + const boundaryMessageId = messagesToCompress.at(-1)!.id + const nextSummary = await input.repository.saveContextSummary({ + conversationId: input.conversationId, + content, + boundaryMessageId, + compressedMessageCount: (summary?.compressedMessageCount ?? 0) + messagesToCompress.length, + }) + compressed = true + return [toSummaryMessage(nextSummary), ...bufferMessages] + } catch (error) { + if (input.signal.aborted) throw error + return effectiveMessages + } finally { + input.onEvent({ type: 'context-compression-finish', compressed }) + } +} + +function resolveCurrentHistory( + messages: RuntimeMessage[], + summary: RuntimeContextSummary | null +): { summary: RuntimeContextSummary | null; recentMessages: RuntimeMessage[] } { + if (!summary) return { summary: null, recentMessages: messages } + const boundaryIndex = messages.findIndex((message) => message.id === summary.boundaryMessageId) + if (boundaryIndex < 0) return { summary: null, recentMessages: messages } + return { summary, recentMessages: messages.slice(boundaryIndex + 1) } +} + +function toSummaryMessage(summary: RuntimeContextSummary): RuntimeMessage { + return { + id: `context-summary:${summary.conversationId}`, + conversationId: summary.conversationId, + role: 'assistant', + content: summary.content, + createdAt: summary.updatedAt, + } +} + +function splitMessages( + messages: RuntimeMessage[], + bufferTokenBudget: number +): { bufferMessages: RuntimeMessage[]; messagesToCompress: RuntimeMessage[] } { + let bufferTokens = 0 + let splitIndex = messages.length + for (let index = messages.length - 1; index >= 0; index--) { + const messageTokens = estimateMessageTokens(messages[index]) + if (bufferTokens + messageTokens > bufferTokenBudget) { + splitIndex = index + 1 + break + } + bufferTokens += messageTokens + if (index === 0) splitIndex = 0 + } + return { + bufferMessages: messages.slice(splitIndex), + messagesToCompress: messages.slice(0, splitIndex), + } +} + +function estimateMessagesTokens(messages: RuntimeMessage[], systemPrompt: string, userMessage: string): number { + return ( + estimateTextTokens(systemPrompt) + + estimateTextTokens(userMessage) + + messages.reduce((total, message) => total + estimateMessageTokens(message), 0) + ) +} + +function estimateMessageTokens(message: RuntimeMessage): number { + return estimateTextTokens(message.content) + 4 +} + +function estimateTextTokens(text: string): number { + // UTF-8 bytes / 3 is deliberately conservative for mixed Chinese and Latin + // text while keeping this browser runtime free of a tokenizer dependency. + return Math.ceil(textEncoder.encode(text).byteLength / 3) +} + +function formatCompressionHistory(summary: RuntimeContextSummary | null, messages: RuntimeMessage[]): string { + const sections: string[] = [] + if (summary) sections.push(`[PREVIOUS SUMMARY]\n${summary.content}`) + sections.push( + `[MESSAGES]\n${messages.map((message) => `[${message.role.toUpperCase()}]\n${message.content}`).join('\n\n')}` + ) + return sections.join('\n\n') +} diff --git a/packages/ai-runtime/src/index.ts b/packages/ai-runtime/src/index.ts index 7a8948e0..712f4b12 100644 --- a/packages/ai-runtime/src/index.ts +++ b/packages/ai-runtime/src/index.ts @@ -1,4 +1,5 @@ export * from './default-assistant' +export * from './context-compression' export * from './errors' export * from './runtime' export * from './token-budget' diff --git a/packages/ai-runtime/src/runtime.test.ts b/packages/ai-runtime/src/runtime.test.ts index a0209bef..8fb900ab 100644 --- a/packages/ai-runtime/src/runtime.test.ts +++ b/packages/ai-runtime/src/runtime.test.ts @@ -9,7 +9,10 @@ import type { AppendRuntimeMessageInput, ConversationRepository, RuntimeConversation, + RuntimeContextSummary, RuntimeMessage, + RuntimeModel, + SaveRuntimeContextSummaryInput, RuntimeToolDefinition, RuntimeToolResult, ToolExecutionContext, @@ -23,6 +26,7 @@ const EMPTY_USAGE = { class MemoryRepository implements ConversationRepository { readonly messages: RuntimeMessage[] = [] + contextSummary: RuntimeContextSummary | null = null readonly conversation: RuntimeConversation = { id: 'conversation-1', sessionId: 'session-1', @@ -57,6 +61,15 @@ class MemoryRepository implements ConversationRepository { const message = this.messages.find((item) => item.id === id) if (message) Object.assign(message, patch) } + + async getContextSummary(conversationId: string): Promise { + return this.contextSummary?.conversationId === conversationId ? this.contextSummary : null + } + + async saveContextSummary(input: SaveRuntimeContextSummaryInput): Promise { + this.contextSummary = { ...input, updatedAt: Date.now() } + return this.contextSummary + } } class FakeToolExecutor implements ToolExecutor { @@ -296,4 +309,181 @@ describe('runAgent', () => { assert.equal(result.message.blocks?.[0]?.type === 'text' ? result.message.blocks[0].text : null, '先确认数据。') assert.equal(result.message.blocks?.[3]?.type === 'text' ? result.message.blocks[3].text : null, '最终结论。') }) + + it('automatically compresses oversized browser history without deleting original messages', async () => { + const model = new MockLanguageModelV3({ + doGenerate: { + content: [{ type: 'text', text: 'COMPRESSED CONTEXT' }], + finishReason: { unified: 'stop', raw: 'stop' }, + usage: EMPTY_USAGE, + warnings: [], + }, + doStream: { + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-1' }, + { type: 'text-delta', id: 'text-1', delta: '继续回答' }, + { type: 'text-end', id: 'text-1' }, + { type: 'finish', finishReason: { unified: 'stop', raw: 'stop' }, usage: EMPTY_USAGE }, + ], + }), + }, + }) + const repository = new MemoryRepository() + for (let index = 0; index < 8; index++) { + await repository.appendMessage({ + conversationId: 'conversation-1', + role: index % 2 === 0 ? 'user' : 'assistant', + content: `old-marker-${index} ${'history '.repeat(20)}`, + createdAt: index + 1, + }) + } + const originalMessageIds = repository.messages.map((message) => message.id) + const events: AgentStreamEvent[] = [] + + await runAgent({ + conversationId: 'conversation-1', + sessionId: 'session-1', + systemPrompt: 'You are a test assistant.', + userMessage: 'continue', + model: { model, contextWindow: 240 } as RuntimeModel, + repository, + tools: new FakeToolExecutor(), + signal: new AbortController().signal, + onEvent: (event) => events.push(event), + }) + + assert.equal(repository.contextSummary?.content, 'COMPRESSED CONTEXT') + assert.ok(repository.contextSummary?.compressedMessageCount) + assert.deepEqual( + repository.messages.slice(0, originalMessageIds.length).map((message) => message.id), + originalMessageIds + ) + const modelPrompt = JSON.stringify(model.doStreamCalls[0]?.prompt) + assert.match(modelPrompt, /COMPRESSED CONTEXT/) + assert.doesNotMatch(modelPrompt, /old-marker-0/) + assert.equal( + events.some((event) => event.type === 'context-compression-start'), + true + ) + assert.equal( + events.some((event) => event.type === 'context-compression-finish'), + true + ) + }) + + it('merges an existing browser summary into the next automatic compression', async () => { + const model = new MockLanguageModelV3({ + doGenerate: { + content: [{ type: 'text', text: 'UPDATED SUMMARY' }], + finishReason: { unified: 'stop', raw: 'stop' }, + usage: EMPTY_USAGE, + warnings: [], + }, + doStream: { + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-1' }, + { type: 'text-delta', id: 'text-1', delta: '继续回答' }, + { type: 'text-end', id: 'text-1' }, + { type: 'finish', finishReason: { unified: 'stop', raw: 'stop' }, usage: EMPTY_USAGE }, + ], + }), + }, + }) + const repository = new MemoryRepository() + const boundary = await repository.appendMessage({ + conversationId: 'conversation-1', + role: 'assistant', + content: 'already summarized', + createdAt: 1, + }) + repository.contextSummary = { + conversationId: 'conversation-1', + content: 'PREVIOUS SUMMARY', + boundaryMessageId: boundary.id, + compressedMessageCount: 4, + updatedAt: 2, + } + for (let index = 0; index < 8; index++) { + await repository.appendMessage({ + conversationId: 'conversation-1', + role: index % 2 === 0 ? 'user' : 'assistant', + content: `new-marker-${index} ${'history '.repeat(20)}`, + createdAt: index + 3, + }) + } + + await runAgent({ + conversationId: 'conversation-1', + sessionId: 'session-1', + systemPrompt: 'You are a test assistant.', + userMessage: 'continue', + model: { model, contextWindow: 240 }, + repository, + tools: new FakeToolExecutor(), + signal: new AbortController().signal, + onEvent: () => undefined, + }) + + const compressionPrompt = JSON.stringify(model.doGenerateCalls[0]?.prompt) + assert.match(compressionPrompt, /PREVIOUS SUMMARY/) + assert.match(compressionPrompt, /new-marker-0/) + assert.equal(repository.contextSummary?.content, 'UPDATED SUMMARY') + assert.ok((repository.contextSummary?.compressedMessageCount ?? 0) > 4) + const modelPrompt = JSON.stringify(model.doStreamCalls[0]?.prompt) + assert.match(modelPrompt, /UPDATED SUMMARY/) + assert.doesNotMatch(modelPrompt, /PREVIOUS SUMMARY/) + }) + + it('continues with the original browser history when automatic compression fails', async () => { + const model = new MockLanguageModelV3({ + doGenerate: async () => { + throw new Error('compression failed') + }, + doStream: { + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-1' }, + { type: 'text-delta', id: 'text-1', delta: 'fallback answer' }, + { type: 'text-end', id: 'text-1' }, + { type: 'finish', finishReason: { unified: 'stop', raw: 'stop' }, usage: EMPTY_USAGE }, + ], + }), + }, + }) + const repository = new MemoryRepository() + for (let index = 0; index < 8; index++) { + await repository.appendMessage({ + conversationId: 'conversation-1', + role: index % 2 === 0 ? 'user' : 'assistant', + content: `fallback-marker-${index} ${'history '.repeat(20)}`, + createdAt: index + 1, + }) + } + const events: AgentStreamEvent[] = [] + + const result = await runAgent({ + conversationId: 'conversation-1', + sessionId: 'session-1', + systemPrompt: 'You are a test assistant.', + userMessage: 'continue', + model: { model, contextWindow: 240 }, + repository, + tools: new FakeToolExecutor(), + signal: new AbortController().signal, + onEvent: (event) => events.push(event), + }) + + assert.equal(result.message.content, 'fallback answer') + assert.equal(repository.contextSummary, null) + assert.match(JSON.stringify(model.doStreamCalls[0]?.prompt), /fallback-marker-0/) + assert.deepEqual( + events.find((event) => event.type === 'context-compression-finish'), + { type: 'context-compression-finish', compressed: false } + ) + }) }) diff --git a/packages/ai-runtime/src/runtime.ts b/packages/ai-runtime/src/runtime.ts index c3ba0ff2..a60a8786 100644 --- a/packages/ai-runtime/src/runtime.ts +++ b/packages/ai-runtime/src/runtime.ts @@ -2,6 +2,7 @@ import type { ModelMessage, ToolSet } from 'ai' import { jsonSchema, stepCountIs, streamText, tool } from 'ai' import { normalizeRuntimeError } from './errors' +import { prepareContextMessages } from './context-compression' import { truncateToolResult } from './token-budget' import type { AgentStreamEvent, @@ -86,6 +87,7 @@ export async function runAgent(input: RunAgentInput): Promise { try { const storedMessages = await input.repository.getMessages(input.conversationId) + const contextMessages = await prepareContextMessages(input, storedMessages) const blocks: RuntimeContentBlock[] = [] const toolsUsed = new Set() @@ -156,7 +158,7 @@ export async function runAgent(input: RunAgentInput): Promise { const result = streamText({ model: input.model.model, instructions: input.systemPrompt, - messages: toModelMessages(storedMessages, input.userMessage), + messages: toModelMessages(contextMessages, input.userMessage), tools: toolSet, stopWhen: stepCountIs(input.maxToolSteps ?? 8), maxOutputTokens: input.maxOutputTokens ?? 4_096, diff --git a/packages/ai-runtime/src/types.ts b/packages/ai-runtime/src/types.ts index f3629b0e..1228aac9 100644 --- a/packages/ai-runtime/src/types.ts +++ b/packages/ai-runtime/src/types.ts @@ -21,6 +21,16 @@ export interface RuntimeMessage { usage?: TokenUsage } +export interface RuntimeContextSummary { + conversationId: string + content: string + boundaryMessageId: string + compressedMessageCount: number + updatedAt: number +} + +export type SaveRuntimeContextSummaryInput = Omit + export type RuntimeContentBlock = | { type: 'text'; text: string } | { type: 'reasoning'; text: string } @@ -48,6 +58,8 @@ export interface AppendRuntimeMessageInput { export interface ConversationRepository { getConversation(id: string): Promise getMessages(conversationId: string): Promise + getContextSummary(conversationId: string): Promise + saveContextSummary(input: SaveRuntimeContextSummaryInput): Promise appendMessage(input: AppendRuntimeMessageInput): Promise updateMessage(id: string, patch: Pick): Promise } @@ -83,12 +95,15 @@ export interface ToolExecutor { export interface RuntimeModel { model: LanguageModel + contextWindow?: number } export type FinishReason = 'stop' | 'length' | 'content-filter' | 'tool-calls' | 'error' | 'aborted' | 'unknown' export type AgentStreamEvent = | { type: 'start'; requestId: string; messageId: string } + | { type: 'context-compression-start' } + | { type: 'context-compression-finish'; compressed: boolean } | { type: 'text-delta'; delta: string } | { type: 'reasoning-delta'; delta: string } | { type: 'tool-start'; callId: string; name: string; input: unknown } diff --git a/packages/http-routes/src/context/ai.ts b/packages/http-routes/src/context/ai.ts index 09d1e2de..70e0c18e 100644 --- a/packages/http-routes/src/context/ai.ts +++ b/packages/http-routes/src/context/ai.ts @@ -39,12 +39,6 @@ export interface AgentStreamRequest { skillId?: string | null enableAutoSkill?: boolean chartAutoMode?: ChartAutoMode - compressionConfig?: { - enabled: boolean - tokenThresholdPercent?: number - bufferSizePercent?: number - maxToolResultPercent?: number - } ownerInfo?: { platformId: string; displayName: string } mentionedMembers?: Array<{ memberId: number diff --git a/packages/node-runtime/src/ai/compression/__tests__/compressor.test.ts b/packages/node-runtime/src/ai/compression/__tests__/compressor.test.ts index 15c85a92..e02f270b 100644 --- a/packages/node-runtime/src/ai/compression/__tests__/compressor.test.ts +++ b/packages/node-runtime/src/ai/compression/__tests__/compressor.test.ts @@ -27,7 +27,6 @@ function cleanup(dir: string): void { } const CONFIG: CompressionConfig = { - enabled: true, tokenThresholdPercent: 75, bufferSizePercent: 20, } diff --git a/packages/node-runtime/src/ai/compression/compressor.ts b/packages/node-runtime/src/ai/compression/compressor.ts index 2c06d66a..6f1fe624 100644 --- a/packages/node-runtime/src/ai/compression/compressor.ts +++ b/packages/node-runtime/src/ai/compression/compressor.ts @@ -70,10 +70,6 @@ export async function checkAndCompress( convManager: AIChatManager, logger: CompressionLogger = defaultLogger ): Promise { - if (!config.enabled) { - return { compressed: false, reason: 'skipped_disabled' } - } - try { const contextWindow = llmAdapter.contextWindow || DEFAULT_CONTEXT_WINDOW const thresholdTokens = Math.floor(contextWindow * (config.tokenThresholdPercent / 100) * 0.95) @@ -198,7 +194,7 @@ export async function manualCompress( return { compressed: false, reason: 'skipped_idempotent' } } - const overrideConfig = { ...config, enabled: true, tokenThresholdPercent: 0 } + const overrideConfig = { ...config, tokenThresholdPercent: 0 } return checkAndCompress(aiChatId, overrideConfig, systemPrompt, llmAdapter, convManager, logger) } diff --git a/packages/node-runtime/src/ai/compression/index.ts b/packages/node-runtime/src/ai/compression/index.ts index af265ae6..f2746199 100644 --- a/packages/node-runtime/src/ai/compression/index.ts +++ b/packages/node-runtime/src/ai/compression/index.ts @@ -3,5 +3,6 @@ */ export type { CompressionConfig, CompressionResult, CompressionLogger, CompressionLlmAdapter } from './types' +export { DEFAULT_CONTEXT_COMPRESSION_CONFIG } from './types' export { checkAndCompress, manualCompress } from './compressor' export { createCompressionLlmAdapter, type CreateCompressionLlmAdapterOptions } from './adapter-factory' diff --git a/packages/node-runtime/src/ai/compression/types.ts b/packages/node-runtime/src/ai/compression/types.ts index c4f70117..38d13726 100644 --- a/packages/node-runtime/src/ai/compression/types.ts +++ b/packages/node-runtime/src/ai/compression/types.ts @@ -1,23 +1,21 @@ export interface CompressionConfig { - enabled: boolean /** 触发压缩的 token 阈值百分比(相对于 context window),默认 75 */ tokenThresholdPercent: number /** 保留最近消息的缓冲区大小(相对于 context window 的百分比),默认 20 */ bufferSizePercent: number - /** 单次工具返回的最大上下文占比(相对于 context window 的百分比),默认 35 */ + /** 单次工具返回的最大上下文占比(相对于 context window 的百分比),默认 50 */ maxToolResultPercent?: number } +export const DEFAULT_CONTEXT_COMPRESSION_CONFIG: Readonly = Object.freeze({ + tokenThresholdPercent: 75, + bufferSizePercent: 20, + maxToolResultPercent: 50, +}) + export interface CompressionResult { compressed: boolean - reason: - | 'skipped_disabled' - | 'skipped_below_threshold' - | 'skipped_idempotent' - | 'success' - | 'fallback_truncated' - | 'thrashing' - | 'error' + reason: 'skipped_below_threshold' | 'skipped_idempotent' | 'success' | 'fallback_truncated' | 'thrashing' | 'error' tokensBefore?: number tokensAfter?: number summaryContent?: string diff --git a/packages/node-runtime/src/ai/index.ts b/packages/node-runtime/src/ai/index.ts index a63a5acd..686959b4 100644 --- a/packages/node-runtime/src/ai/index.ts +++ b/packages/node-runtime/src/ai/index.ts @@ -76,7 +76,12 @@ export type { ChartSchemaGateState } from './chart-schema-gate' // Compression export type { CompressionConfig, CompressionResult, CompressionLogger, CompressionLlmAdapter } from './compression' -export { checkAndCompress, manualCompress, createCompressionLlmAdapter } from './compression' +export { + DEFAULT_CONTEXT_COMPRESSION_CONFIG, + checkAndCompress, + manualCompress, + createCompressionLlmAdapter, +} from './compression' export type { CreateCompressionLlmAdapterOptions } from './compression' // Preprocessor diff --git a/packages/node-runtime/src/index.ts b/packages/node-runtime/src/index.ts index 1b17d45e..9f883353 100644 --- a/packages/node-runtime/src/index.ts +++ b/packages/node-runtime/src/index.ts @@ -216,7 +216,7 @@ export type { // Compression export type { CompressionConfig, CompressionResult, CompressionLogger, CompressionLlmAdapter } from './ai' -export { checkAndCompress, manualCompress, createCompressionLlmAdapter } from './ai' +export { DEFAULT_CONTEXT_COMPRESSION_CONFIG, checkAndCompress, manualCompress, createCompressionLlmAdapter } from './ai' export type { CreateCompressionLlmAdapterOptions } from './ai' // SkillManager @@ -398,7 +398,6 @@ export type { AIPreprocessConfig, WordFilterScheme, KeywordTemplate, - ContextCompressionSettings, } from './preferences' // Merger orchestration diff --git a/packages/node-runtime/src/preferences.test.ts b/packages/node-runtime/src/preferences.test.ts index bdaff92b..37a1aad5 100644 --- a/packages/node-runtime/src/preferences.test.ts +++ b/packages/node-runtime/src/preferences.test.ts @@ -190,6 +190,33 @@ test('loads thinkingLevels as empty object when field is absent in legacy prefer } }) +test('ignores legacy context compression preferences now that compression is automatic', () => { + const systemDir = createTempSystemDir() + try { + writeFileSync( + join(systemDir, 'preferences.json'), + JSON.stringify({ + aiGlobalSettings: { + maxMessagesPerRequest: 500, + contextCompression: { + enabled: false, + tokenThresholdPercent: 95, + bufferSizePercent: 50, + maxToolResultPercent: 80, + }, + }, + }) + ) + + const loaded = new PreferencesManager(systemDir).load() + + assert.equal(loaded.aiGlobalSettings.maxMessagesPerRequest, 500) + assert.equal('contextCompression' in loaded.aiGlobalSettings, false) + } finally { + rmSync(systemDir, { recursive: true, force: true }) + } +}) + test('persists and normalizes skipped assistant upgrade versions', () => { const systemDir = createTempSystemDir() try { diff --git a/packages/node-runtime/src/preferences.ts b/packages/node-runtime/src/preferences.ts index ec293db3..ab8ee9d6 100644 --- a/packages/node-runtime/src/preferences.ts +++ b/packages/node-runtime/src/preferences.ts @@ -14,7 +14,6 @@ import type { AIPreprocessConfig, WordFilterScheme, KeywordTemplate, - ContextCompressionSettings, DesensitizeRule, FilterHistoryItem, } from '@openchatlab/shared-types' @@ -25,7 +24,6 @@ export type { AIPreprocessConfig, WordFilterScheme, KeywordTemplate, - ContextCompressionSettings, DesensitizeRule, FilterHistoryItem, } @@ -53,12 +51,6 @@ const DEFAULTS: Preferences = { chartAutoMode: 'suggest', searchContextBefore: 2, searchContextAfter: 2, - contextCompression: { - enabled: true, - tokenThresholdPercent: 75, - bufferSizePercent: 20, - maxToolResultPercent: 50, - }, }, customKeywordTemplates: [], deletedPresetTemplateIds: [], @@ -141,16 +133,7 @@ export class PreferencesManager { aiPreprocessConfig: partial.aiPreprocessConfig ? this.normalizeAiPreprocessConfig(partial.aiPreprocessConfig) : { ...DEFAULTS.aiPreprocessConfig }, - aiGlobalSettings: partial.aiGlobalSettings - ? { - ...DEFAULTS.aiGlobalSettings, - ...partial.aiGlobalSettings, - contextCompression: { - ...DEFAULTS.aiGlobalSettings.contextCompression, - ...(partial.aiGlobalSettings.contextCompression ?? {}), - }, - } - : { ...DEFAULTS.aiGlobalSettings }, + aiGlobalSettings: this.normalizeAiGlobalSettings(partial.aiGlobalSettings), customKeywordTemplates: partial.customKeywordTemplates ?? DEFAULTS.customKeywordTemplates, deletedPresetTemplateIds: partial.deletedPresetTemplateIds ?? DEFAULTS.deletedPresetTemplateIds, wordFilter: partial.wordFilter ? { ...DEFAULTS.wordFilter, ...partial.wordFilter } : { ...DEFAULTS.wordFilter }, @@ -161,6 +144,15 @@ export class PreferencesManager { } } + private normalizeAiGlobalSettings(partial: Partial | undefined): AIGlobalSettings { + const normalized = { ...(partial ?? {}) } as Record + // Context compression became an automatic runtime capability. Ignore the + // legacy user preference so a previously disabled value cannot survive + // after the setting is removed. + delete normalized.contextCompression + return { ...DEFAULTS.aiGlobalSettings, ...(normalized as Partial) } + } + private normalizeAiPreprocessConfig(partial: Partial | undefined): AIPreprocessConfig { const defaults = DEFAULTS.aiPreprocessConfig const rules = Array.isArray(partial?.desensitizeRules) ? partial.desensitizeRules : [] diff --git a/packages/shared-types/index.ts b/packages/shared-types/index.ts index 655998e2..b7043942 100644 --- a/packages/shared-types/index.ts +++ b/packages/shared-types/index.ts @@ -195,13 +195,6 @@ export interface WordFilterScheme { createdAt: number } -export interface ContextCompressionSettings { - enabled: boolean - tokenThresholdPercent: number - bufferSizePercent: number - maxToolResultPercent: number -} - export type ChartAutoMode = 'explicit' | 'suggest' | 'aggressive' export interface AIGlobalSettings { @@ -212,7 +205,6 @@ export interface AIGlobalSettings { chartAutoMode: ChartAutoMode searchContextBefore: number searchContextAfter: number - contextCompression: ContextCompressionSettings } export interface KeywordTemplate { diff --git a/packages/web-ai-runtime/src/provider-factory.ts b/packages/web-ai-runtime/src/provider-factory.ts index 88d62949..59f5cd9a 100644 --- a/packages/web-ai-runtime/src/provider-factory.ts +++ b/packages/web-ai-runtime/src/provider-factory.ts @@ -1,9 +1,11 @@ import type { LanguageModel } from 'ai' +import { resolveRuntimeContextWindow } from '@openchatlab/ai-runtime' import type { WebModelConfig } from './types' export interface CreatedWebModel { model: LanguageModel + contextWindow?: number } export async function createWebAIModel(config: WebModelConfig, apiKey: string): Promise { @@ -20,5 +22,6 @@ export async function createWebAIModel(config: WebModelConfig, apiKey: string): apiKey, baseURL, }).chatModel(config.model), + contextWindow: resolveRuntimeContextWindow(config.provider, config.model), } } diff --git a/packages/web-ai-runtime/src/rpc-adapters.ts b/packages/web-ai-runtime/src/rpc-adapters.ts index 8d93b5c8..29ce9bf1 100644 --- a/packages/web-ai-runtime/src/rpc-adapters.ts +++ b/packages/web-ai-runtime/src/rpc-adapters.ts @@ -2,7 +2,9 @@ import type { AppendRuntimeMessageInput, ConversationRepository, RuntimeConversation, + RuntimeContextSummary, RuntimeMessage, + SaveRuntimeContextSummaryInput, RuntimeToolDefinition, ToolExecutionContext, ToolExecutor, @@ -28,6 +30,14 @@ export class RpcConversationRepository implements ConversationRepository { return this.rpc.request('ai.message.list', { conversationId }) } + getContextSummary(conversationId: string): Promise { + return this.rpc.request('ai.context-summary.get', { conversationId }) + } + + saveContextSummary(input: SaveRuntimeContextSummaryInput): Promise { + return this.rpc.request('ai.context-summary.save', input) + } + appendMessage(input: AppendRuntimeMessageInput): Promise { return this.rpc.request('ai.message.append', input) } diff --git a/packages/web-ai-runtime/src/runtime.test.ts b/packages/web-ai-runtime/src/runtime.test.ts index 839eb516..2f7c3baa 100644 --- a/packages/web-ai-runtime/src/runtime.test.ts +++ b/packages/web-ai-runtime/src/runtime.test.ts @@ -2,7 +2,12 @@ import assert from 'node:assert/strict' import { webcrypto } from 'node:crypto' import { describe, it } from 'node:test' -import type { RuntimeConversation, RuntimeMessage } from '@openchatlab/ai-runtime' +import type { + RuntimeContextSummary, + RuntimeConversation, + RuntimeMessage, + SaveRuntimeContextSummaryInput, +} from '@openchatlab/ai-runtime' import { MockLanguageModelV3, simulateReadableStream } from 'ai/test' import { WebModelConfigStore, type BrowserKeyValueStore } from './model-config-store' @@ -31,6 +36,7 @@ class MemoryKeyValueStore implements BrowserKeyValueStore { function createRpc(toolListPayloads: unknown[] = [], messageListGate?: Promise): WebRuntimeRpcPort { const conversations = new Map() + const contextSummaries = new Map() const messages: RuntimeMessage[] = [] let sequence = 0 return { @@ -76,6 +82,14 @@ function createRpc(toolListPayloads: unknown[] = [], messageListGate?: Promise= 0) messages.splice(index, 1) return { deleted: index >= 0 } as never } + case 'ai.context-summary.get': + return (contextSummaries.get((payload as { conversationId: string }).conversationId) ?? null) as never + case 'ai.context-summary.save': { + const input = payload as SaveRuntimeContextSummaryInput + const summary: RuntimeContextSummary = { ...input, updatedAt: Date.now() } + contextSummaries.set(summary.conversationId, summary) + return summary as never + } case 'ai.tool.list': toolListPayloads.push(payload) return [] as never diff --git a/packages/web-ai-runtime/src/runtime.ts b/packages/web-ai-runtime/src/runtime.ts index 48a6760a..bf8d4ef7 100644 --- a/packages/web-ai-runtime/src/runtime.ts +++ b/packages/web-ai-runtime/src/runtime.ts @@ -221,6 +221,8 @@ function filterMessagesFromHistory( getConversation: (id) => repository.getConversation(id), getMessages: async (conversationId) => (await repository.getMessages(conversationId)).filter((message) => !messageIds.has(message.id)), + getContextSummary: (conversationId) => repository.getContextSummary(conversationId), + saveContextSummary: (input) => repository.saveContextSummary(input), appendMessage: (input) => repository.appendMessage(input), updateMessage: (id, patch) => repository.updateMessage(id, patch), } diff --git a/packages/web-runtime/src/ai/browser-ai-runtime.test.ts b/packages/web-runtime/src/ai/browser-ai-runtime.test.ts index 59d91c2f..1f33fca6 100644 --- a/packages/web-runtime/src/ai/browser-ai-runtime.test.ts +++ b/packages/web-runtime/src/ai/browser-ai-runtime.test.ts @@ -62,7 +62,7 @@ describe('BrowserAIConversationRepository', () => { const repository = new BrowserAIConversationRepository(database) try { const conversation = await repository.createConversation('session-1', 'First') - await repository.appendMessage({ + const firstMessage = await repository.appendMessage({ conversationId: conversation.id, role: 'user', content: 'hello', @@ -73,6 +73,12 @@ describe('BrowserAIConversationRepository', () => { content: 'world', usage: { totalTokens: 3 }, }) + await repository.saveContextSummary({ + conversationId: conversation.id, + content: 'Earlier context summary', + boundaryMessageId: firstMessage.id, + compressedMessageCount: 1, + }) assert.equal((await repository.listConversations('session-1')).length, 1) assert.deepEqual( (await repository.getMessages(conversation.id)).map((message) => [message.role, message.content]), @@ -81,9 +87,11 @@ describe('BrowserAIConversationRepository', () => { ['assistant', 'world'], ] ) + assert.equal((await repository.getContextSummary(conversation.id))?.content, 'Earlier context summary') assert.equal(await repository.deleteBySession('session-1'), 1) assert.equal(await repository.getConversation(conversation.id), null) assert.deepEqual(await repository.getMessages(conversation.id), []) + assert.equal(await repository.getContextSummary(conversation.id), null) } finally { database.dispose() } diff --git a/packages/web-runtime/src/ai/conversation-repository.ts b/packages/web-runtime/src/ai/conversation-repository.ts index 0aa32310..33e10f2c 100644 --- a/packages/web-runtime/src/ai/conversation-repository.ts +++ b/packages/web-runtime/src/ai/conversation-repository.ts @@ -2,7 +2,9 @@ import type { AppendRuntimeMessageInput, ConversationRepository, RuntimeConversation, + RuntimeContextSummary, RuntimeMessage, + SaveRuntimeContextSummaryInput, } from '@openchatlab/ai-runtime' import type { DatabaseAdapter } from '@openchatlab/core' @@ -33,6 +35,15 @@ export const WEB_AI_SCHEMA = ` ); CREATE INDEX IF NOT EXISTS idx_ai_message_conversation_created ON ai_message(conversation_id, created_at ASC, id ASC); + + CREATE TABLE IF NOT EXISTS ai_context_summary ( + conversation_id TEXT PRIMARY KEY, + content TEXT NOT NULL, + boundary_message_id TEXT NOT NULL, + compressed_message_count INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY(conversation_id) REFERENCES ai_conversation(id) ON DELETE CASCADE + ); ` interface ConversationRow { @@ -53,6 +64,14 @@ interface MessageRow { usage_json: string | null } +interface ContextSummaryRow { + conversation_id: string + content: string + boundary_message_id: string + compressed_message_count: number + updated_at: number +} + function createId(prefix: string): string { return `${prefix}_${crypto.randomUUID()}` } @@ -88,6 +107,16 @@ function mapMessage(row: MessageRow): RuntimeMessage { } } +function mapContextSummary(row: ContextSummaryRow): RuntimeContextSummary { + return { + conversationId: row.conversation_id, + content: row.content, + boundaryMessageId: row.boundary_message_id, + compressedMessageCount: row.compressed_message_count, + updatedAt: row.updated_at, + } +} + export class BrowserAIConversationRepository implements ConversationRepository { constructor(private readonly database: WorkspaceDatabasePort) {} @@ -157,6 +186,38 @@ export class BrowserAIConversationRepository implements ConversationRepository { ) } + getContextSummary(conversationId: string): Promise { + return this.withDatabase((db) => { + const row = db.prepare('SELECT * FROM ai_context_summary WHERE conversation_id = ?').get(conversationId) as + | ContextSummaryRow + | undefined + return row ? mapContextSummary(row) : null + }) + } + + saveContextSummary(input: SaveRuntimeContextSummaryInput): Promise { + const summary: RuntimeContextSummary = { ...input, updatedAt: Date.now() } + return this.withDatabase((db) => { + db.prepare( + `INSERT INTO ai_context_summary ( + conversation_id, content, boundary_message_id, compressed_message_count, updated_at + ) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(conversation_id) DO UPDATE SET + content = excluded.content, + boundary_message_id = excluded.boundary_message_id, + compressed_message_count = excluded.compressed_message_count, + updated_at = excluded.updated_at` + ).run( + summary.conversationId, + summary.content, + summary.boundaryMessageId, + summary.compressedMessageCount, + summary.updatedAt + ) + return summary + }) + } + appendMessage(input: AppendRuntimeMessageInput): Promise { const message: RuntimeMessage = { id: input.id ?? createId('message'), diff --git a/packages/web-runtime/src/rpc/protocol.ts b/packages/web-runtime/src/rpc/protocol.ts index 639ee5fb..33791c68 100644 --- a/packages/web-runtime/src/rpc/protocol.ts +++ b/packages/web-runtime/src/rpc/protocol.ts @@ -3,7 +3,9 @@ import type { BrowserSessionCatalogItem } from '../import/session-catalog' import type { AppendRuntimeMessageInput, RuntimeConversation, + RuntimeContextSummary, RuntimeMessage, + SaveRuntimeContextSummaryInput, RuntimeToolDefinition, RuntimeToolResult, } from '@openchatlab/ai-runtime' @@ -141,6 +143,14 @@ export interface WebRuntimeTaskMap { payload: { messageId: string } result: { deleted: boolean } } + 'ai.context-summary.get': { + payload: { conversationId: string } + result: RuntimeContextSummary | null + } + 'ai.context-summary.save': { + payload: SaveRuntimeContextSummaryInput + result: RuntimeContextSummary + } 'ai.tool.list': { payload: { locale?: string } result: RuntimeToolDefinition[] @@ -350,6 +360,8 @@ const WEB_RUNTIME_TASK_TYPES: Record = { 'ai.message.append': true, 'ai.message.update': true, 'ai.message.delete': true, + 'ai.context-summary.get': true, + 'ai.context-summary.save': true, 'ai.tool.list': true, 'ai.tool.execute': true, 'analysis.hourly': true, diff --git a/packages/web-runtime/src/worker/controller.ts b/packages/web-runtime/src/worker/controller.ts index 2e35475c..70e87244 100644 --- a/packages/web-runtime/src/worker/controller.ts +++ b/packages/web-runtime/src/worker/controller.ts @@ -72,6 +72,8 @@ export type WorkerAIRepository = Pick< | 'appendMessage' | 'updateMessage' | 'deleteMessage' + | 'getContextSummary' + | 'saveContextSummary' > export type WorkerAIToolRuntime = Pick @@ -283,6 +285,10 @@ export class WebRuntimeWorkerController { return { updated: true } case 'ai.message.delete': return { deleted: await this.aiRepository.deleteMessage(request.payload.messageId) } + case 'ai.context-summary.get': + return this.aiRepository.getContextSummary(request.payload.conversationId) + case 'ai.context-summary.save': + return this.aiRepository.saveContextSummary(request.payload) case 'ai.tool.list': return this.aiToolRuntime.listTools(request.payload.locale) case 'ai.tool.execute': { diff --git a/src/components/common/Settings/AI/AIPromptConfigTab.test.ts b/src/components/common/Settings/AI/AIPromptConfigTab.test.ts deleted file mode 100644 index 0c2385f2..00000000 --- a/src/components/common/Settings/AI/AIPromptConfigTab.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import assert from 'node:assert/strict' -import { describe, it } from 'node:test' -import { MAX_TOOL_RESULT_PERCENT, normalizeMaxToolResultPercent } from './promptConfigLimits' - -describe('AIPromptConfigTab tool result budget controls', () => { - it('allows users to raise tool result budget up to 80 percent', () => { - assert.equal(MAX_TOOL_RESULT_PERCENT, 80) - assert.equal(normalizeMaxToolResultPercent(80), 80) - assert.equal(normalizeMaxToolResultPercent(81), 80) - }) -}) diff --git a/src/components/common/Settings/AI/AIPromptConfigTab.vue b/src/components/common/Settings/AI/AIPromptConfigTab.vue index 709270cf..30f73c79 100644 --- a/src/components/common/Settings/AI/AIPromptConfigTab.vue +++ b/src/components/common/Settings/AI/AIPromptConfigTab.vue @@ -4,7 +4,6 @@ import { storeToRefs } from 'pinia' import { useI18n } from 'vue-i18n' import { usePromptStore } from '@/stores/prompt' import type { ChartAutoMode } from '@openchatlab/shared-types' -import { MAX_TOOL_RESULT_PERCENT, MIN_TOOL_RESULT_PERCENT, normalizeMaxToolResultPercent } from './promptConfigLimits' const { t } = useI18n() @@ -77,50 +76,6 @@ const searchContextAfter = computed({ emit('config-changed') }, }) - -// 上下文压缩配置 -const compressionEnabled = computed({ - get: () => aiGlobalSettings.value.contextCompression?.enabled ?? false, - set: (val: boolean) => { - promptStore.updateAIGlobalSettings({ - contextCompression: { ...aiGlobalSettings.value.contextCompression, enabled: val }, - }) - emit('config-changed') - }, -}) - -const compressionThreshold = computed({ - get: () => aiGlobalSettings.value.contextCompression?.tokenThresholdPercent ?? 75, - set: (val: number) => { - const clampedVal = Math.max(30, Math.min(95, val || 75)) - promptStore.updateAIGlobalSettings({ - contextCompression: { ...aiGlobalSettings.value.contextCompression, tokenThresholdPercent: clampedVal }, - }) - emit('config-changed') - }, -}) - -const compressionBuffer = computed({ - get: () => aiGlobalSettings.value.contextCompression?.bufferSizePercent ?? 20, - set: (val: number) => { - const clampedVal = Math.max(5, Math.min(50, val || 20)) - promptStore.updateAIGlobalSettings({ - contextCompression: { ...aiGlobalSettings.value.contextCompression, bufferSizePercent: clampedVal }, - }) - emit('config-changed') - }, -}) - -const maxToolResultPercent = computed({ - get: () => aiGlobalSettings.value.contextCompression?.maxToolResultPercent ?? 50, - set: (val: number) => { - const clampedVal = normalizeMaxToolResultPercent(val) - promptStore.updateAIGlobalSettings({ - contextCompression: { ...aiGlobalSettings.value.contextCompression, maxToolResultPercent: clampedVal }, - }) - emit('config-changed') - }, -}) diff --git a/src/components/common/Settings/AI/promptConfigLimits.ts b/src/components/common/Settings/AI/promptConfigLimits.ts deleted file mode 100644 index 1355ecf7..00000000 --- a/src/components/common/Settings/AI/promptConfigLimits.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const MAX_TOOL_RESULT_PERCENT = 80 -export const MIN_TOOL_RESULT_PERCENT = 10 -export const DEFAULT_TOOL_RESULT_PERCENT = 50 - -export function normalizeMaxToolResultPercent(value: number): number { - return Math.max(MIN_TOOL_RESULT_PERCENT, Math.min(MAX_TOOL_RESULT_PERCENT, value || DEFAULT_TOOL_RESULT_PERCENT)) -} diff --git a/src/components/common/Settings/AISettingsTab.vue b/src/components/common/Settings/AISettingsTab.vue index ae82eee8..5876cfa0 100644 --- a/src/components/common/Settings/AISettingsTab.vue +++ b/src/components/common/Settings/AISettingsTab.vue @@ -25,7 +25,6 @@ const navItems = computed(() => [ { id: 'semanticIndex', label: t('settings.tabs.semanticIndex') }, { id: 'skill', label: t('settings.tabs.chatPreferences') }, { id: 'chat', label: t('settings.aiPrompt.chatSettings.title') }, - { id: 'compression', label: t('settings.tabs.compression') }, { id: 'preprocess', label: t('settings.tabs.aiPreprocess') }, { id: 'sessionIndex', label: t('settings.tabs.sessionManage') }, { id: 'export', label: t('settings.aiPrompt.exportSettings.title') }, diff --git a/src/i18n/locales/en-US/settings.json b/src/i18n/locales/en-US/settings.json index 0f6ca8b8..e7cfba6a 100644 --- a/src/i18n/locales/en-US/settings.json +++ b/src/i18n/locales/en-US/settings.json @@ -7,7 +7,6 @@ "aiDefaultModel": "Default Model", "semanticIndex": "Semantic Index", "chatPreferences": "Preferences", - "compression": "Compression", "aiPreprocess": "Preprocess", "dataManage": "Data Management", "storage": "Storage", @@ -377,17 +376,6 @@ "sqlExportFormat": { "title": "SQL Lab Export Format", "description": "File format for exporting SQL query results" - }, - "compression": { - "title": "Context Compression", - "enable": "Auto-compress context", - "enableDesc": "Automatically compress long conversation history into summaries to reduce token usage", - "threshold": "Compression threshold", - "thresholdDesc": "Trigger compression when context tokens reach this percentage of the model's context window", - "buffer": "Buffer size", - "bufferDesc": "Percentage of recent messages to keep uncompressed", - "maxToolResultPercent": "Tool result limit", - "maxToolResultPercentDesc": "Maximum data percentage per tool call sent to AI (based on model context window). Excess results are intelligently truncated" } }, "about": { diff --git a/src/i18n/locales/en-US/webAI.json b/src/i18n/locales/en-US/webAI.json index 610bf718..b9cb9268 100644 --- a/src/i18n/locales/en-US/webAI.json +++ b/src/i18n/locales/en-US/webAI.json @@ -6,6 +6,7 @@ "regenerate": "Regenerate", "footer": "AI can make mistakes. Verify important conclusions.", "usingTool": "Using {name}", + "compressingContext": "Compressing context", "conversations": { "title": "Conversations", "deleteConfirm": "Delete this AI conversation?" diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index d92537db..560b68fa 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -7,7 +7,6 @@ "aiDefaultModel": "デフォルトモデル", "semanticIndex": "セマンティックインデックス", "chatPreferences": "会話設定", - "compression": "圧縮", "aiPreprocess": "前処理", "dataManage": "データ管理", "storage": "ストレージ管理", @@ -377,17 +376,6 @@ "sqlExportFormat": { "title": "SQL Lab エクスポート形式", "description": "SQL クエリ結果をエクスポートする際のファイル形式" - }, - "compression": { - "title": "コンテキスト圧縮", - "enable": "コンテキストの自動圧縮", - "enableDesc": "会話が長くなった際に履歴を自動的に要約に圧縮し、トークン消費を削減します", - "threshold": "圧縮閾値", - "thresholdDesc": "コンテキストトークンがモデルウィンドウのこの割合に達したら圧縮を開始", - "buffer": "バッファサイズ", - "bufferDesc": "圧縮せずに保持する最新メッセージの割合", - "maxToolResultPercent": "ツール結果の上限", - "maxToolResultPercentDesc": "1回のツール呼び出しでAIに送信される最大データ割合(モデルのコンテキストウィンドウに基づいて計算)。超過分はスマートに切り捨てられます" } }, "about": { diff --git a/src/i18n/locales/ja-JP/webAI.json b/src/i18n/locales/ja-JP/webAI.json index 531178ef..86cab225 100644 --- a/src/i18n/locales/ja-JP/webAI.json +++ b/src/i18n/locales/ja-JP/webAI.json @@ -6,6 +6,7 @@ "regenerate": "再生成", "footer": "AI は誤ることがあります。重要な結論は確認してください。", "usingTool": "{name} を使用中", + "compressingContext": "コンテキストを圧縮中", "conversations": { "title": "会話", "deleteConfirm": "この AI 会話を削除しますか?" diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 67018ff4..2357b5da 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -7,7 +7,6 @@ "aiDefaultModel": "默认模型", "semanticIndex": "语义索引", "chatPreferences": "对话偏好", - "compression": "上下文压缩", "aiPreprocess": "预处理", "dataManage": "数据管理", "storage": "存储管理", @@ -377,17 +376,6 @@ "sqlExportFormat": { "title": "SQL Lab 导出格式", "description": "导出 SQL 查询结果时使用的文件格式" - }, - "compression": { - "title": "上下文压缩", - "enable": "自动压缩上下文", - "enableDesc": "对话过长时自动压缩历史为摘要,保留关键信息的同时降低 Token 消耗", - "threshold": "压缩阈值", - "thresholdDesc": "上下文 Token 达到模型窗口的百分比时触发压缩", - "buffer": "缓冲区大小", - "bufferDesc": "保留最近消息原文的比例,不参与压缩", - "maxToolResultPercent": "工具结果上限", - "maxToolResultPercentDesc": "单次工具返回给 AI 的最大数据量占比(基于模型上下文窗口计算),超出部分会被智能截断" } }, "about": { diff --git a/src/i18n/locales/zh-CN/webAI.json b/src/i18n/locales/zh-CN/webAI.json index fde21b00..c45f1d19 100644 --- a/src/i18n/locales/zh-CN/webAI.json +++ b/src/i18n/locales/zh-CN/webAI.json @@ -6,6 +6,7 @@ "regenerate": "重新生成", "footer": "AI 可能会犯错,请核对重要结论", "usingTool": "正在调用 {name}", + "compressingContext": "正在压缩上下文", "conversations": { "title": "对话", "deleteConfirm": "确定删除这条 AI 对话吗?" diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index 54d42c6b..53c64d48 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -7,7 +7,6 @@ "aiDefaultModel": "預設模型", "semanticIndex": "語意索引", "chatPreferences": "對話偏好", - "compression": "上下文壓縮", "aiPreprocess": "前處理", "dataManage": "資料管理", "storage": "儲存管理", @@ -377,17 +376,6 @@ "sqlExportFormat": { "title": "SQL Lab 匯出格式", "description": "匯出 SQL 查詢結果時使用的檔案格式" - }, - "compression": { - "title": "上下文壓縮", - "enable": "自動壓縮上下文", - "enableDesc": "對話過長時自動壓縮歷史為摘要,保留關鍵資訊的同時降低 Token 消耗", - "threshold": "壓縮閾值", - "thresholdDesc": "上下文 Token 達到模型視窗的百分比時觸發壓縮", - "buffer": "緩衝區大小", - "bufferDesc": "保留最近訊息原文的比例,不參與壓縮", - "maxToolResultPercent": "工具結果上限", - "maxToolResultPercentDesc": "單次工具回傳給 AI 的最大資料量占比(基於模型上下文視窗計算),超出部分會被智慧截斷" } }, "about": { diff --git a/src/i18n/locales/zh-TW/webAI.json b/src/i18n/locales/zh-TW/webAI.json index 6430d899..b0969086 100644 --- a/src/i18n/locales/zh-TW/webAI.json +++ b/src/i18n/locales/zh-TW/webAI.json @@ -6,6 +6,7 @@ "regenerate": "重新產生", "footer": "AI 可能會出錯,請核對重要結論", "usingTool": "正在呼叫 {name}", + "compressingContext": "正在壓縮上下文", "conversations": { "title": "對話", "deleteConfirm": "確定刪除這條 AI 對話嗎?" diff --git a/src/services/ai-stream/types.ts b/src/services/ai-stream/types.ts index c2fe2fe5..12dcdf48 100644 --- a/src/services/ai-stream/types.ts +++ b/src/services/ai-stream/types.ts @@ -100,13 +100,6 @@ export interface AgentStreamResult { error?: SerializedErrorInfo | Record } -export interface CompressionConfig { - enabled: boolean - tokenThresholdPercent: number - bufferSizePercent: number - maxToolResultPercent?: number -} - export interface AgentStreamParams { userMessage: string sessionId: string @@ -118,7 +111,6 @@ export interface AgentStreamParams { skillId?: string | null enableAutoSkill?: boolean chartAutoMode?: ChartAutoMode - compressionConfig?: CompressionConfig ownerInfo?: { platformId: string; displayName: string } mentionedMembers?: Array<{ memberId: number diff --git a/src/services/preferences/types.ts b/src/services/preferences/types.ts index ff53a8f0..47a437fb 100644 --- a/src/services/preferences/types.ts +++ b/src/services/preferences/types.ts @@ -11,7 +11,6 @@ export type { AIPreprocessConfig, WordFilterScheme, KeywordTemplate, - ContextCompressionSettings, DesensitizeRule, UiConfig, } from '@openchatlab/shared-types' diff --git a/src/stores/aiChat.ts b/src/stores/aiChat.ts index 03956239..b39c6091 100644 --- a/src/stores/aiChat.ts +++ b/src/stores/aiChat.ts @@ -1020,12 +1020,6 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => { skillId: currentSkillId, enableAutoSkill: !currentSkillId ? autoSkillEnabled : undefined, chartAutoMode: !currentSkillId ? chartAutoMode : undefined, - compressionConfig: { - enabled: aiGlobalSettings.value.contextCompression?.enabled ?? false, - tokenThresholdPercent: aiGlobalSettings.value.contextCompression?.tokenThresholdPercent ?? 75, - bufferSizePercent: aiGlobalSettings.value.contextCompression?.bufferSizePercent ?? 20, - maxToolResultPercent: aiGlobalSettings.value.contextCompression?.maxToolResultPercent ?? 50, - }, thinkingLevel: (() => { const cfg = llmStore.defaultAssistant if (!cfg?.configId || !cfg?.modelId) return undefined @@ -1619,12 +1613,6 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => { skillId: currentSkillId, enableAutoSkill: !currentSkillId ? autoSkillEnabled : undefined, chartAutoMode: !currentSkillId ? chartAutoMode : undefined, - compressionConfig: { - enabled: aiGlobalSettings.value.contextCompression?.enabled ?? false, - tokenThresholdPercent: aiGlobalSettings.value.contextCompression?.tokenThresholdPercent ?? 75, - bufferSizePercent: aiGlobalSettings.value.contextCompression?.bufferSizePercent ?? 20, - maxToolResultPercent: aiGlobalSettings.value.contextCompression?.maxToolResultPercent ?? 50, - }, thinkingLevel: (() => { const cfg = llmStore.defaultAssistant if (!cfg?.configId || !cfg?.modelId) return undefined diff --git a/src/stores/prompt.ts b/src/stores/prompt.ts index 5b847ed8..77947837 100644 --- a/src/stores/prompt.ts +++ b/src/stores/prompt.ts @@ -4,13 +4,6 @@ import type { KeywordTemplate } from '@/types/analysis' import type { ThinkingLevel } from '@openchatlab/core' import type { ChartAutoMode } from '@openchatlab/shared-types' -interface ContextCompressionSettings { - enabled: boolean - tokenThresholdPercent: number - bufferSizePercent: number - maxToolResultPercent: number -} - interface AIGlobalSettings { maxMessagesPerRequest: number exportFormat: 'markdown' | 'txt' @@ -19,12 +12,9 @@ interface AIGlobalSettings { chartAutoMode: ChartAutoMode searchContextBefore: number searchContextAfter: number - contextCompression: ContextCompressionSettings } -type AIGlobalSettingsUpdate = Partial> & { - contextCompression?: Partial -} +type AIGlobalSettingsUpdate = Partial /** * AI 配置与关键词模板相关的全局状态 @@ -41,12 +31,6 @@ export const usePromptStore = defineStore( chartAutoMode: 'suggest', searchContextBefore: 2, searchContextAfter: 2, - contextCompression: { - enabled: true, - tokenThresholdPercent: 75, - bufferSizePercent: 20, - maxToolResultPercent: 50, - }, }) const customKeywordTemplates = ref([]) const deletedPresetTemplateIds = ref([]) @@ -69,13 +53,9 @@ export const usePromptStore = defineStore( * 更新 AI 全局设置 */ function updateAIGlobalSettings(settings: AIGlobalSettingsUpdate) { - const { contextCompression, ...baseSettings } = settings aiGlobalSettings.value = { ...aiGlobalSettings.value, - ...baseSettings, - contextCompression: contextCompression - ? { ...aiGlobalSettings.value.contextCompression, ...contextCompression } - : aiGlobalSettings.value.contextCompression, + ...settings, } notifyAIConfigChanged() }