feat(ai): enable automatic context compression

This commit is contained in:
digua committed 2026-08-07 15:35:29 +08:00
1 parent 5c7ad41b23
commit 17320045dc
43 files changed
+546 -309

No files matched your search

+2 -12
View File
@@ -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,
+4 -6
View File
@@ -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<void> {
assistantSystemPrompt,
skillMenu,
skillDef,
compressionConfig,
tools = [],
llmConfig,
aiChatManager,
@@ -123,7 +121,7 @@ export async function runServerAgent(options: RunAgentOptions): Promise<void> {
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<void> {
})
const compressionResult = await checkAndCompress(
aiChatId,
compressionConfig,
DEFAULT_CONTEXT_COMPRESSION_CONFIG,
systemPrompt,
llmAdapter,
aiChatManager,
@@ -148,7 +146,7 @@ export async function runServerAgent(options: RunAgentOptions): Promise<void> {
},
})
}
} else if (compressionConfig?.enabled && historyLeafMessageId !== undefined) {
} else {
aiLogger?.info?.('Compression', 'Skipping compression for edited branch request', {
aiChatId,
historyLeafMessageId,
+5 -5
View File
@@ -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))
@@ -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') {
@@ -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<RuntimeMessage[]> {
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')
}
+1
View File
@@ -1,4 +1,5 @@
export * from './default-assistant'
export * from './context-compression'
export * from './errors'
export * from './runtime'
export * from './token-budget'
+190
View File
@@ -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<RuntimeContextSummary | null> {
return this.contextSummary?.conversationId === conversationId ? this.contextSummary : null
}
async saveContextSummary(input: SaveRuntimeContextSummaryInput): Promise<RuntimeContextSummary> {
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 }
)
})
})
+3 -1
View File
@@ -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<RunAgentResult> {
try {
const storedMessages = await input.repository.getMessages(input.conversationId)
const contextMessages = await prepareContextMessages(input, storedMessages)
const blocks: RuntimeContentBlock[] = []
const toolsUsed = new Set<string>()
@@ -156,7 +158,7 @@ export async function runAgent(input: RunAgentInput): Promise<RunAgentResult> {
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,
+15
View File
@@ -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<RuntimeContextSummary, 'updatedAt'>
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<RuntimeConversation | null>
getMessages(conversationId: string): Promise<RuntimeMessage[]>
getContextSummary(conversationId: string): Promise<RuntimeContextSummary | null>
saveContextSummary(input: SaveRuntimeContextSummaryInput): Promise<RuntimeContextSummary>
appendMessage(input: AppendRuntimeMessageInput): Promise<RuntimeMessage>
updateMessage(id: string, patch: Pick<RuntimeMessage, 'content' | 'blocks' | 'usage'>): Promise<void>
}
@@ -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 }
-6
View File
@@ -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
@@ -27,7 +27,6 @@ function cleanup(dir: string): void {
}
const CONFIG: CompressionConfig = {
enabled: true,
tokenThresholdPercent: 75,
bufferSizePercent: 20,
}
@@ -70,10 +70,6 @@ export async function checkAndCompress(
convManager: AIChatManager,
logger: CompressionLogger = defaultLogger
): Promise<CompressionResult> {
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)
}
@@ -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'
@@ -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<CompressionConfig> = 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
+6 -1
View File
@@ -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
+1 -2
View File
@@ -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
@@ -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 {
+10 -18
View File
@@ -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<AIGlobalSettings> | undefined): AIGlobalSettings {
const normalized = { ...(partial ?? {}) } as Record<string, unknown>
// 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<AIGlobalSettings>) }
}
private normalizeAiPreprocessConfig(partial: Partial<AIPreprocessConfig> | undefined): AIPreprocessConfig {
const defaults = DEFAULTS.aiPreprocessConfig
const rules = Array.isArray(partial?.desensitizeRules) ? partial.desensitizeRules : []
-8
View File
@@ -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 {
@@ -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<CreatedWebModel> {
@@ -20,5 +22,6 @@ export async function createWebAIModel(config: WebModelConfig, apiKey: string):
apiKey,
baseURL,
}).chatModel(config.model),
contextWindow: resolveRuntimeContextWindow(config.provider, config.model),
}
}
@@ -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<RuntimeContextSummary | null> {
return this.rpc.request('ai.context-summary.get', { conversationId })
}
saveContextSummary(input: SaveRuntimeContextSummaryInput): Promise<RuntimeContextSummary> {
return this.rpc.request('ai.context-summary.save', input)
}
appendMessage(input: AppendRuntimeMessageInput): Promise<RuntimeMessage> {
return this.rpc.request('ai.message.append', input)
}
+15 -1
View File
@@ -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<void>): WebRuntimeRpcPort {
const conversations = new Map<string, RuntimeConversation>()
const contextSummaries = new Map<string, RuntimeContextSummary>()
const messages: RuntimeMessage[] = []
let sequence = 0
return {
@@ -76,6 +82,14 @@ function createRpc(toolListPayloads: unknown[] = [], messageListGate?: Promise<v
if (index >= 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
+2
View File
@@ -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),
}
@@ -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()
}
@@ -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<RuntimeContextSummary | null> {
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<RuntimeContextSummary> {
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<RuntimeMessage> {
const message: RuntimeMessage = {
id: input.id ?? createId('message'),
+12
View File
@@ -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<WebRuntimeTaskType, true> = {
'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,
@@ -72,6 +72,8 @@ export type WorkerAIRepository = Pick<
| 'appendMessage'
| 'updateMessage'
| 'deleteMessage'
| 'getContextSummary'
| 'saveContextSummary'
>
export type WorkerAIToolRuntime = Pick<BrowserAIToolRuntime, 'listTools' | 'execute'>
@@ -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': {
@@ -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)
})
})
@@ -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')
},
})
</script>
<template>
@@ -210,82 +165,5 @@ const maxToolResultPercent = computed({
</div>
</div>
</div>
<!-- 上下文压缩设置 -->
<div :ref="(el) => setPromptSectionRef('compression', el as HTMLElement | null)">
<h4 class="mb-3 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
<UIcon name="i-heroicons-archive-box-arrow-down" class="h-4 w-4 text-purple-500" />
{{ t('settings.aiPrompt.compression.title') }}
</h4>
<div class="space-y-4 rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/50">
<!-- 压缩开关 -->
<div class="flex items-center justify-between">
<div class="flex-1 pr-4">
<p class="text-sm font-medium text-gray-900 dark:text-white">
{{ t('settings.aiPrompt.compression.enable') }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('settings.aiPrompt.compression.enableDesc') }}
</p>
</div>
<USwitch v-model="compressionEnabled" />
</div>
<!-- 工具结果上限(始终显示,不依赖压缩开关) -->
<div class="flex items-center justify-between">
<div class="flex-1 pr-4">
<p class="text-sm font-medium text-gray-900 dark:text-white">
{{ t('settings.aiPrompt.compression.maxToolResultPercent') }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('settings.aiPrompt.compression.maxToolResultPercentDesc') }}
</p>
</div>
<div class="flex items-center gap-1">
<UInputNumber
v-model="maxToolResultPercent"
:min="MIN_TOOL_RESULT_PERCENT"
:max="MAX_TOOL_RESULT_PERCENT"
class="w-24"
/>
<span class="text-xs text-gray-400">%</span>
</div>
</div>
<template v-if="compressionEnabled">
<!-- 压缩阈值 -->
<div class="flex items-center justify-between">
<div class="flex-1 pr-4">
<p class="text-sm font-medium text-gray-900 dark:text-white">
{{ t('settings.aiPrompt.compression.threshold') }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('settings.aiPrompt.compression.thresholdDesc') }}
</p>
</div>
<div class="flex items-center gap-1">
<UInputNumber v-model="compressionThreshold" :min="30" :max="95" class="w-24" />
<span class="text-xs text-gray-400">%</span>
</div>
</div>
<!-- 缓冲区大小 -->
<div class="flex items-center justify-between">
<div class="flex-1 pr-4">
<p class="text-sm font-medium text-gray-900 dark:text-white">
{{ t('settings.aiPrompt.compression.buffer') }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('settings.aiPrompt.compression.bufferDesc') }}
</p>
</div>
<div class="flex items-center gap-1">
<UInputNumber v-model="compressionBuffer" :min="5" :max="50" class="w-24" />
<span class="text-xs text-gray-400">%</span>
</div>
</div>
</template>
</div>
</div>
</div>
</template>
@@ -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))
}
@@ -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') },
-12
View File
@@ -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": {
+1
View File
@@ -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?"
-12
View File
@@ -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": {
+1
View File
@@ -6,6 +6,7 @@
"regenerate": "再生成",
"footer": "AI は誤ることがあります。重要な結論は確認してください。",
"usingTool": "{name} を使用中",
"compressingContext": "コンテキストを圧縮中",
"conversations": {
"title": "会話",
"deleteConfirm": "この AI 会話を削除しますか?"
-12
View File
@@ -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": {
+1
View File
@@ -6,6 +6,7 @@
"regenerate": "重新生成",
"footer": "AI 可能会犯错,请核对重要结论",
"usingTool": "正在调用 {name}",
"compressingContext": "正在压缩上下文",
"conversations": {
"title": "对话",
"deleteConfirm": "确定删除这条 AI 对话吗?"
-12
View File
@@ -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": {
+1
View File
@@ -6,6 +6,7 @@
"regenerate": "重新產生",
"footer": "AI 可能會出錯,請核對重要結論",
"usingTool": "正在呼叫 {name}",
"compressingContext": "正在壓縮上下文",
"conversations": {
"title": "對話",
"deleteConfirm": "確定刪除這條 AI 對話嗎?"
-8
View File
@@ -100,13 +100,6 @@ export interface AgentStreamResult {
error?: SerializedErrorInfo | Record<string, unknown>
}
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
-1
View File
@@ -11,7 +11,6 @@ export type {
AIPreprocessConfig,
WordFilterScheme,
KeywordTemplate,
ContextCompressionSettings,
DesensitizeRule,
UiConfig,
} from '@openchatlab/shared-types'
-12
View File
@@ -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
+2 -22
View File
@@ -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<Omit<AIGlobalSettings, 'contextCompression'>> & {
contextCompression?: Partial<ContextCompressionSettings>
}
type AIGlobalSettingsUpdate = Partial<AIGlobalSettings>
/**
* 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<KeywordTemplate[]>([])
const deletedPresetTemplateIds = ref<string[]>([])
@@ -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()
}