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' } from '@openchatlab/node-runtime'
import { import {
CHART_CAPABILITY_CORE_TOOLS, CHART_CAPABILITY_CORE_TOOLS,
DEFAULT_CONTEXT_COMPRESSION_CONFIG,
SkillManager, SkillManager,
buildPiModel, buildPiModel,
buildSkillMenuWithBuiltinChart, buildSkillMenuWithBuiltinChart,
@@ -93,7 +94,6 @@ export function createCliRunAgentStream(
skillId, skillId,
enableAutoSkill, enableAutoSkill,
chartAutoMode, chartAutoMode,
compressionConfig,
ownerInfo, ownerInfo,
mentionedMembers, mentionedMembers,
thinkingLevel, thinkingLevel,
@@ -110,7 +110,7 @@ export function createCliRunAgentStream(
} }
const llmConfig = llmConfigStore.getDefaultAssistantConfig() 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 contextWindow = llmConfig ? (buildPiModel(llmConfig).contextWindow ?? 128000) : 128000
const maxToolResultTokens = Math.floor(contextWindow * (maxToolResultPercent / 100)) const maxToolResultTokens = Math.floor(contextWindow * (maxToolResultPercent / 100))
@@ -196,15 +196,6 @@ export function createCliRunAgentStream(
agentTools.push(activateSkillTool as any) 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 let dataSnapshot: DataSnapshot | undefined
if (db) { if (db) {
try { try {
@@ -223,7 +214,6 @@ export function createCliRunAgentStream(
assistantSystemPrompt, assistantSystemPrompt,
skillMenu: resolvedSkillMenu, skillMenu: resolvedSkillMenu,
skillDef: resolvedSkillDef, skillDef: resolvedSkillDef,
compressionConfig: resolvedCompression,
tools: agentTools, tools: agentTools,
llmConfig, llmConfig,
aiChatManager, aiChatManager,
+4 -6
View File
@@ -7,6 +7,7 @@
import { import {
DEFAULT_MAX_TOOL_ROUNDS, DEFAULT_MAX_TOOL_ROUNDS,
DEFAULT_CONTEXT_COMPRESSION_CONFIG,
buildPiModel, buildPiModel,
buildPlanGuidance, buildPlanGuidance,
createAnalysisPlanner, createAnalysisPlanner,
@@ -27,7 +28,6 @@ import {
type PiMessage, type PiMessage,
type SimpleHistoryMessage, type SimpleHistoryMessage,
type AIChatManager, type AIChatManager,
type CompressionConfig,
type AgentTool, type AgentTool,
type DataSnapshot, type DataSnapshot,
type AIServiceConfig, type AIServiceConfig,
@@ -49,7 +49,6 @@ export interface RunAgentOptions {
assistantSystemPrompt?: string assistantSystemPrompt?: string
skillMenu?: string | null skillMenu?: string | null
skillDef?: { name: string; prompt: string } skillDef?: { name: string; prompt: string }
compressionConfig?: CompressionConfig
tools?: AgentTool[] tools?: AgentTool[]
llmConfig: AIServiceConfig | null llmConfig: AIServiceConfig | null
aiChatManager: AIChatManager aiChatManager: AIChatManager
@@ -72,7 +71,6 @@ export async function runServerAgent(options: RunAgentOptions): Promise<void> {
assistantSystemPrompt, assistantSystemPrompt,
skillMenu, skillMenu,
skillDef, skillDef,
compressionConfig,
tools = [], tools = [],
llmConfig, llmConfig,
aiChatManager, aiChatManager,
@@ -123,7 +121,7 @@ export async function runServerAgent(options: RunAgentOptions): Promise<void> {
systemPrompt, systemPrompt,
}) })
if (compressionConfig?.enabled && historyLeafMessageId === undefined) { if (historyLeafMessageId === undefined) {
const llmAdapter = createCompressionLlmAdapter({ const llmAdapter = createCompressionLlmAdapter({
piModel, piModel,
apiKey: llmConfig.apiKey, apiKey: llmConfig.apiKey,
@@ -131,7 +129,7 @@ export async function runServerAgent(options: RunAgentOptions): Promise<void> {
}) })
const compressionResult = await checkAndCompress( const compressionResult = await checkAndCompress(
aiChatId, aiChatId,
compressionConfig, DEFAULT_CONTEXT_COMPRESSION_CONFIG,
systemPrompt, systemPrompt,
llmAdapter, llmAdapter,
aiChatManager, 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', { aiLogger?.info?.('Compression', 'Skipping compression for edited branch request', {
aiChatId, aiChatId,
historyLeafMessageId, historyLeafMessageId,
+5 -5
View File
@@ -18,6 +18,7 @@ import {
buildSkillMenuWithBuiltinChart, buildSkillMenuWithBuiltinChart,
CHART_CAPABILITY_ANALYSIS_TOOLS, CHART_CAPABILITY_ANALYSIS_TOOLS,
checkAndCompress, checkAndCompress,
DEFAULT_CONTEXT_COMPRESSION_CONFIG,
createCompressionLlmAdapter, createCompressionLlmAdapter,
createDataSnapshotFromOverview, createDataSnapshotFromOverview,
formatAIError, formatAIError,
@@ -27,7 +28,7 @@ import {
resolveChartRuntimeForRequest, resolveChartRuntimeForRequest,
buildSemanticSearchGuidance, buildSemanticSearchGuidance,
} from '@openchatlab/node-runtime' } 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 { Agent, type AgentStreamChunk, type SkillContext } from './agent'
import type { ToolContext } from './tools/types' import type { ToolContext } from './tools/types'
import { buildPiModel, findModelDefinition } from './llm' import { buildPiModel, findModelDefinition } from './llm'
@@ -85,7 +86,6 @@ export function createElectronRunAgentStream(
skillId, skillId,
enableAutoSkill, enableAutoSkill,
chartAutoMode, chartAutoMode,
compressionConfig,
ownerInfo, ownerInfo,
mentionedMembers, mentionedMembers,
thinkingLevel, thinkingLevel,
@@ -113,7 +113,7 @@ export function createElectronRunAgentStream(
} }
const piModel = buildPiModel(activeAIConfig) const piModel = buildPiModel(activeAIConfig)
if (compressionConfig?.enabled && aiChatId && historyLeafMessageId === undefined) { if (aiChatId && historyLeafMessageId === undefined) {
try { try {
const tempAssistantConfig = assistantId const tempAssistantConfig = assistantId
? (assistantManager.getAssistantConfig(assistantId) ?? undefined) ? (assistantManager.getAssistantConfig(assistantId) ?? undefined)
@@ -122,7 +122,7 @@ export function createElectronRunAgentStream(
const compressionResult = await checkAndCompress( const compressionResult = await checkAndCompress(
aiChatId, aiChatId,
compressionConfig as CompressionConfig, DEFAULT_CONTEXT_COMPRESSION_CONFIG,
systemPromptForCompression, systemPromptForCompression,
buildCompressionAdapter(activeAIConfig, () => { buildCompressionAdapter(activeAIConfig, () => {
onEvent({ 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 modelDef = findModelDefinition(activeAIConfig.provider, activeAIConfig.model || '')
const resolvedContextWindow = modelDef?.contextWindow || DEFAULT_CONTEXT_WINDOW const resolvedContextWindow = modelDef?.contextWindow || DEFAULT_CONTEXT_WINDOW
const maxToolResultTokens = Math.floor(resolvedContextWindow * (maxToolResultPercent / 100)) const maxToolResultTokens = Math.floor(resolvedContextWindow * (maxToolResultPercent / 100))
@@ -288,6 +288,14 @@ function handleEvent(event: AgentStreamEvent) {
} }
return 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 const message = streamingMessage.value
if (!message) return if (!message) return
if (event.type === 'text-delta') { 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 './default-assistant'
export * from './context-compression'
export * from './errors' export * from './errors'
export * from './runtime' export * from './runtime'
export * from './token-budget' export * from './token-budget'
+190
View File
@@ -9,7 +9,10 @@ import type {
AppendRuntimeMessageInput, AppendRuntimeMessageInput,
ConversationRepository, ConversationRepository,
RuntimeConversation, RuntimeConversation,
RuntimeContextSummary,
RuntimeMessage, RuntimeMessage,
RuntimeModel,
SaveRuntimeContextSummaryInput,
RuntimeToolDefinition, RuntimeToolDefinition,
RuntimeToolResult, RuntimeToolResult,
ToolExecutionContext, ToolExecutionContext,
@@ -23,6 +26,7 @@ const EMPTY_USAGE = {
class MemoryRepository implements ConversationRepository { class MemoryRepository implements ConversationRepository {
readonly messages: RuntimeMessage[] = [] readonly messages: RuntimeMessage[] = []
contextSummary: RuntimeContextSummary | null = null
readonly conversation: RuntimeConversation = { readonly conversation: RuntimeConversation = {
id: 'conversation-1', id: 'conversation-1',
sessionId: 'session-1', sessionId: 'session-1',
@@ -57,6 +61,15 @@ class MemoryRepository implements ConversationRepository {
const message = this.messages.find((item) => item.id === id) const message = this.messages.find((item) => item.id === id)
if (message) Object.assign(message, patch) 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 { 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?.[0]?.type === 'text' ? result.message.blocks[0].text : null, '先确认数据。')
assert.equal(result.message.blocks?.[3]?.type === 'text' ? result.message.blocks[3].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 { jsonSchema, stepCountIs, streamText, tool } from 'ai'
import { normalizeRuntimeError } from './errors' import { normalizeRuntimeError } from './errors'
import { prepareContextMessages } from './context-compression'
import { truncateToolResult } from './token-budget' import { truncateToolResult } from './token-budget'
import type { import type {
AgentStreamEvent, AgentStreamEvent,
@@ -86,6 +87,7 @@ export async function runAgent(input: RunAgentInput): Promise<RunAgentResult> {
try { try {
const storedMessages = await input.repository.getMessages(input.conversationId) const storedMessages = await input.repository.getMessages(input.conversationId)
const contextMessages = await prepareContextMessages(input, storedMessages)
const blocks: RuntimeContentBlock[] = [] const blocks: RuntimeContentBlock[] = []
const toolsUsed = new Set<string>() const toolsUsed = new Set<string>()
@@ -156,7 +158,7 @@ export async function runAgent(input: RunAgentInput): Promise<RunAgentResult> {
const result = streamText({ const result = streamText({
model: input.model.model, model: input.model.model,
instructions: input.systemPrompt, instructions: input.systemPrompt,
messages: toModelMessages(storedMessages, input.userMessage), messages: toModelMessages(contextMessages, input.userMessage),
tools: toolSet, tools: toolSet,
stopWhen: stepCountIs(input.maxToolSteps ?? 8), stopWhen: stepCountIs(input.maxToolSteps ?? 8),
maxOutputTokens: input.maxOutputTokens ?? 4_096, maxOutputTokens: input.maxOutputTokens ?? 4_096,
+15
View File
@@ -21,6 +21,16 @@ export interface RuntimeMessage {
usage?: TokenUsage usage?: TokenUsage
} }
export interface RuntimeContextSummary {
conversationId: string
content: string
boundaryMessageId: string
compressedMessageCount: number
updatedAt: number
}
export type SaveRuntimeContextSummaryInput = Omit<RuntimeContextSummary, 'updatedAt'>
export type RuntimeContentBlock = export type RuntimeContentBlock =
| { type: 'text'; text: string } | { type: 'text'; text: string }
| { type: 'reasoning'; text: string } | { type: 'reasoning'; text: string }
@@ -48,6 +58,8 @@ export interface AppendRuntimeMessageInput {
export interface ConversationRepository { export interface ConversationRepository {
getConversation(id: string): Promise<RuntimeConversation | null> getConversation(id: string): Promise<RuntimeConversation | null>
getMessages(conversationId: string): Promise<RuntimeMessage[]> getMessages(conversationId: string): Promise<RuntimeMessage[]>
getContextSummary(conversationId: string): Promise<RuntimeContextSummary | null>
saveContextSummary(input: SaveRuntimeContextSummaryInput): Promise<RuntimeContextSummary>
appendMessage(input: AppendRuntimeMessageInput): Promise<RuntimeMessage> appendMessage(input: AppendRuntimeMessageInput): Promise<RuntimeMessage>
updateMessage(id: string, patch: Pick<RuntimeMessage, 'content' | 'blocks' | 'usage'>): Promise<void> updateMessage(id: string, patch: Pick<RuntimeMessage, 'content' | 'blocks' | 'usage'>): Promise<void>
} }
@@ -83,12 +95,15 @@ export interface ToolExecutor {
export interface RuntimeModel { export interface RuntimeModel {
model: LanguageModel model: LanguageModel
contextWindow?: number
} }
export type FinishReason = 'stop' | 'length' | 'content-filter' | 'tool-calls' | 'error' | 'aborted' | 'unknown' export type FinishReason = 'stop' | 'length' | 'content-filter' | 'tool-calls' | 'error' | 'aborted' | 'unknown'
export type AgentStreamEvent = export type AgentStreamEvent =
| { type: 'start'; requestId: string; messageId: string } | { type: 'start'; requestId: string; messageId: string }
| { type: 'context-compression-start' }
| { type: 'context-compression-finish'; compressed: boolean }
| { type: 'text-delta'; delta: string } | { type: 'text-delta'; delta: string }
| { type: 'reasoning-delta'; delta: string } | { type: 'reasoning-delta'; delta: string }
| { type: 'tool-start'; callId: string; name: string; input: unknown } | { type: 'tool-start'; callId: string; name: string; input: unknown }
-6
View File
@@ -39,12 +39,6 @@ export interface AgentStreamRequest {
skillId?: string | null skillId?: string | null
enableAutoSkill?: boolean enableAutoSkill?: boolean
chartAutoMode?: ChartAutoMode chartAutoMode?: ChartAutoMode
compressionConfig?: {
enabled: boolean
tokenThresholdPercent?: number
bufferSizePercent?: number
maxToolResultPercent?: number
}
ownerInfo?: { platformId: string; displayName: string } ownerInfo?: { platformId: string; displayName: string }
mentionedMembers?: Array<{ mentionedMembers?: Array<{
memberId: number memberId: number
@@ -27,7 +27,6 @@ function cleanup(dir: string): void {
} }
const CONFIG: CompressionConfig = { const CONFIG: CompressionConfig = {
enabled: true,
tokenThresholdPercent: 75, tokenThresholdPercent: 75,
bufferSizePercent: 20, bufferSizePercent: 20,
} }
@@ -70,10 +70,6 @@ export async function checkAndCompress(
convManager: AIChatManager, convManager: AIChatManager,
logger: CompressionLogger = defaultLogger logger: CompressionLogger = defaultLogger
): Promise<CompressionResult> { ): Promise<CompressionResult> {
if (!config.enabled) {
return { compressed: false, reason: 'skipped_disabled' }
}
try { try {
const contextWindow = llmAdapter.contextWindow || DEFAULT_CONTEXT_WINDOW const contextWindow = llmAdapter.contextWindow || DEFAULT_CONTEXT_WINDOW
const thresholdTokens = Math.floor(contextWindow * (config.tokenThresholdPercent / 100) * 0.95) const thresholdTokens = Math.floor(contextWindow * (config.tokenThresholdPercent / 100) * 0.95)
@@ -198,7 +194,7 @@ export async function manualCompress(
return { compressed: false, reason: 'skipped_idempotent' } 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) return checkAndCompress(aiChatId, overrideConfig, systemPrompt, llmAdapter, convManager, logger)
} }
@@ -3,5 +3,6 @@
*/ */
export type { CompressionConfig, CompressionResult, CompressionLogger, CompressionLlmAdapter } from './types' export type { CompressionConfig, CompressionResult, CompressionLogger, CompressionLlmAdapter } from './types'
export { DEFAULT_CONTEXT_COMPRESSION_CONFIG } from './types'
export { checkAndCompress, manualCompress } from './compressor' export { checkAndCompress, manualCompress } from './compressor'
export { createCompressionLlmAdapter, type CreateCompressionLlmAdapterOptions } from './adapter-factory' export { createCompressionLlmAdapter, type CreateCompressionLlmAdapterOptions } from './adapter-factory'
@@ -1,23 +1,21 @@
export interface CompressionConfig { export interface CompressionConfig {
enabled: boolean
/** 触发压缩的 token 阈值百分比(相对于 context window),默认 75 */ /** 触发压缩的 token 阈值百分比(相对于 context window),默认 75 */
tokenThresholdPercent: number tokenThresholdPercent: number
/** 保留最近消息的缓冲区大小(相对于 context window 的百分比),默认 20 */ /** 保留最近消息的缓冲区大小(相对于 context window 的百分比),默认 20 */
bufferSizePercent: number bufferSizePercent: number
/** 单次工具返回的最大上下文占比(相对于 context window 的百分比),默认 35 */ /** 单次工具返回的最大上下文占比(相对于 context window 的百分比),默认 50 */
maxToolResultPercent?: number maxToolResultPercent?: number
} }
export const DEFAULT_CONTEXT_COMPRESSION_CONFIG: Readonly<CompressionConfig> = Object.freeze({
tokenThresholdPercent: 75,
bufferSizePercent: 20,
maxToolResultPercent: 50,
})
export interface CompressionResult { export interface CompressionResult {
compressed: boolean compressed: boolean
reason: reason: 'skipped_below_threshold' | 'skipped_idempotent' | 'success' | 'fallback_truncated' | 'thrashing' | 'error'
| 'skipped_disabled'
| 'skipped_below_threshold'
| 'skipped_idempotent'
| 'success'
| 'fallback_truncated'
| 'thrashing'
| 'error'
tokensBefore?: number tokensBefore?: number
tokensAfter?: number tokensAfter?: number
summaryContent?: string summaryContent?: string
+6 -1
View File
@@ -76,7 +76,12 @@ export type { ChartSchemaGateState } from './chart-schema-gate'
// Compression // Compression
export type { CompressionConfig, CompressionResult, CompressionLogger, CompressionLlmAdapter } from './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' export type { CreateCompressionLlmAdapterOptions } from './compression'
// Preprocessor // Preprocessor
+1 -2
View File
@@ -216,7 +216,7 @@ export type {
// Compression // Compression
export type { CompressionConfig, CompressionResult, CompressionLogger, CompressionLlmAdapter } from './ai' 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' export type { CreateCompressionLlmAdapterOptions } from './ai'
// SkillManager // SkillManager
@@ -398,7 +398,6 @@ export type {
AIPreprocessConfig, AIPreprocessConfig,
WordFilterScheme, WordFilterScheme,
KeywordTemplate, KeywordTemplate,
ContextCompressionSettings,
} from './preferences' } from './preferences'
// Merger orchestration // 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', () => { test('persists and normalizes skipped assistant upgrade versions', () => {
const systemDir = createTempSystemDir() const systemDir = createTempSystemDir()
try { try {
+10 -18
View File
@@ -14,7 +14,6 @@ import type {
AIPreprocessConfig, AIPreprocessConfig,
WordFilterScheme, WordFilterScheme,
KeywordTemplate, KeywordTemplate,
ContextCompressionSettings,
DesensitizeRule, DesensitizeRule,
FilterHistoryItem, FilterHistoryItem,
} from '@openchatlab/shared-types' } from '@openchatlab/shared-types'
@@ -25,7 +24,6 @@ export type {
AIPreprocessConfig, AIPreprocessConfig,
WordFilterScheme, WordFilterScheme,
KeywordTemplate, KeywordTemplate,
ContextCompressionSettings,
DesensitizeRule, DesensitizeRule,
FilterHistoryItem, FilterHistoryItem,
} }
@@ -53,12 +51,6 @@ const DEFAULTS: Preferences = {
chartAutoMode: 'suggest', chartAutoMode: 'suggest',
searchContextBefore: 2, searchContextBefore: 2,
searchContextAfter: 2, searchContextAfter: 2,
contextCompression: {
enabled: true,
tokenThresholdPercent: 75,
bufferSizePercent: 20,
maxToolResultPercent: 50,
},
}, },
customKeywordTemplates: [], customKeywordTemplates: [],
deletedPresetTemplateIds: [], deletedPresetTemplateIds: [],
@@ -141,16 +133,7 @@ export class PreferencesManager {
aiPreprocessConfig: partial.aiPreprocessConfig aiPreprocessConfig: partial.aiPreprocessConfig
? this.normalizeAiPreprocessConfig(partial.aiPreprocessConfig) ? this.normalizeAiPreprocessConfig(partial.aiPreprocessConfig)
: { ...DEFAULTS.aiPreprocessConfig }, : { ...DEFAULTS.aiPreprocessConfig },
aiGlobalSettings: partial.aiGlobalSettings aiGlobalSettings: this.normalizeAiGlobalSettings(partial.aiGlobalSettings),
? {
...DEFAULTS.aiGlobalSettings,
...partial.aiGlobalSettings,
contextCompression: {
...DEFAULTS.aiGlobalSettings.contextCompression,
...(partial.aiGlobalSettings.contextCompression ?? {}),
},
}
: { ...DEFAULTS.aiGlobalSettings },
customKeywordTemplates: partial.customKeywordTemplates ?? DEFAULTS.customKeywordTemplates, customKeywordTemplates: partial.customKeywordTemplates ?? DEFAULTS.customKeywordTemplates,
deletedPresetTemplateIds: partial.deletedPresetTemplateIds ?? DEFAULTS.deletedPresetTemplateIds, deletedPresetTemplateIds: partial.deletedPresetTemplateIds ?? DEFAULTS.deletedPresetTemplateIds,
wordFilter: partial.wordFilter ? { ...DEFAULTS.wordFilter, ...partial.wordFilter } : { ...DEFAULTS.wordFilter }, 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 { private normalizeAiPreprocessConfig(partial: Partial<AIPreprocessConfig> | undefined): AIPreprocessConfig {
const defaults = DEFAULTS.aiPreprocessConfig const defaults = DEFAULTS.aiPreprocessConfig
const rules = Array.isArray(partial?.desensitizeRules) ? partial.desensitizeRules : [] const rules = Array.isArray(partial?.desensitizeRules) ? partial.desensitizeRules : []
-8
View File
@@ -195,13 +195,6 @@ export interface WordFilterScheme {
createdAt: number createdAt: number
} }
export interface ContextCompressionSettings {
enabled: boolean
tokenThresholdPercent: number
bufferSizePercent: number
maxToolResultPercent: number
}
export type ChartAutoMode = 'explicit' | 'suggest' | 'aggressive' export type ChartAutoMode = 'explicit' | 'suggest' | 'aggressive'
export interface AIGlobalSettings { export interface AIGlobalSettings {
@@ -212,7 +205,6 @@ export interface AIGlobalSettings {
chartAutoMode: ChartAutoMode chartAutoMode: ChartAutoMode
searchContextBefore: number searchContextBefore: number
searchContextAfter: number searchContextAfter: number
contextCompression: ContextCompressionSettings
} }
export interface KeywordTemplate { export interface KeywordTemplate {
@@ -1,9 +1,11 @@
import type { LanguageModel } from 'ai' import type { LanguageModel } from 'ai'
import { resolveRuntimeContextWindow } from '@openchatlab/ai-runtime'
import type { WebModelConfig } from './types' import type { WebModelConfig } from './types'
export interface CreatedWebModel { export interface CreatedWebModel {
model: LanguageModel model: LanguageModel
contextWindow?: number
} }
export async function createWebAIModel(config: WebModelConfig, apiKey: string): Promise<CreatedWebModel> { export async function createWebAIModel(config: WebModelConfig, apiKey: string): Promise<CreatedWebModel> {
@@ -20,5 +22,6 @@ export async function createWebAIModel(config: WebModelConfig, apiKey: string):
apiKey, apiKey,
baseURL, baseURL,
}).chatModel(config.model), }).chatModel(config.model),
contextWindow: resolveRuntimeContextWindow(config.provider, config.model),
} }
} }
@@ -2,7 +2,9 @@ import type {
AppendRuntimeMessageInput, AppendRuntimeMessageInput,
ConversationRepository, ConversationRepository,
RuntimeConversation, RuntimeConversation,
RuntimeContextSummary,
RuntimeMessage, RuntimeMessage,
SaveRuntimeContextSummaryInput,
RuntimeToolDefinition, RuntimeToolDefinition,
ToolExecutionContext, ToolExecutionContext,
ToolExecutor, ToolExecutor,
@@ -28,6 +30,14 @@ export class RpcConversationRepository implements ConversationRepository {
return this.rpc.request('ai.message.list', { conversationId }) 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> { appendMessage(input: AppendRuntimeMessageInput): Promise<RuntimeMessage> {
return this.rpc.request('ai.message.append', input) 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 { webcrypto } from 'node:crypto'
import { describe, it } from 'node:test' 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 { MockLanguageModelV3, simulateReadableStream } from 'ai/test'
import { WebModelConfigStore, type BrowserKeyValueStore } from './model-config-store' import { WebModelConfigStore, type BrowserKeyValueStore } from './model-config-store'
@@ -31,6 +36,7 @@ class MemoryKeyValueStore implements BrowserKeyValueStore {
function createRpc(toolListPayloads: unknown[] = [], messageListGate?: Promise<void>): WebRuntimeRpcPort { function createRpc(toolListPayloads: unknown[] = [], messageListGate?: Promise<void>): WebRuntimeRpcPort {
const conversations = new Map<string, RuntimeConversation>() const conversations = new Map<string, RuntimeConversation>()
const contextSummaries = new Map<string, RuntimeContextSummary>()
const messages: RuntimeMessage[] = [] const messages: RuntimeMessage[] = []
let sequence = 0 let sequence = 0
return { return {
@@ -76,6 +82,14 @@ function createRpc(toolListPayloads: unknown[] = [], messageListGate?: Promise<v
if (index >= 0) messages.splice(index, 1) if (index >= 0) messages.splice(index, 1)
return { deleted: index >= 0 } as never 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': case 'ai.tool.list':
toolListPayloads.push(payload) toolListPayloads.push(payload)
return [] as never return [] as never
+2
View File
@@ -221,6 +221,8 @@ function filterMessagesFromHistory(
getConversation: (id) => repository.getConversation(id), getConversation: (id) => repository.getConversation(id),
getMessages: async (conversationId) => getMessages: async (conversationId) =>
(await repository.getMessages(conversationId)).filter((message) => !messageIds.has(message.id)), (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), appendMessage: (input) => repository.appendMessage(input),
updateMessage: (id, patch) => repository.updateMessage(id, patch), updateMessage: (id, patch) => repository.updateMessage(id, patch),
} }
@@ -62,7 +62,7 @@ describe('BrowserAIConversationRepository', () => {
const repository = new BrowserAIConversationRepository(database) const repository = new BrowserAIConversationRepository(database)
try { try {
const conversation = await repository.createConversation('session-1', 'First') const conversation = await repository.createConversation('session-1', 'First')
await repository.appendMessage({ const firstMessage = await repository.appendMessage({
conversationId: conversation.id, conversationId: conversation.id,
role: 'user', role: 'user',
content: 'hello', content: 'hello',
@@ -73,6 +73,12 @@ describe('BrowserAIConversationRepository', () => {
content: 'world', content: 'world',
usage: { totalTokens: 3 }, 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.equal((await repository.listConversations('session-1')).length, 1)
assert.deepEqual( assert.deepEqual(
(await repository.getMessages(conversation.id)).map((message) => [message.role, message.content]), (await repository.getMessages(conversation.id)).map((message) => [message.role, message.content]),
@@ -81,9 +87,11 @@ describe('BrowserAIConversationRepository', () => {
['assistant', 'world'], ['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.deleteBySession('session-1'), 1)
assert.equal(await repository.getConversation(conversation.id), null) assert.equal(await repository.getConversation(conversation.id), null)
assert.deepEqual(await repository.getMessages(conversation.id), []) assert.deepEqual(await repository.getMessages(conversation.id), [])
assert.equal(await repository.getContextSummary(conversation.id), null)
} finally { } finally {
database.dispose() database.dispose()
} }
@@ -2,7 +2,9 @@ import type {
AppendRuntimeMessageInput, AppendRuntimeMessageInput,
ConversationRepository, ConversationRepository,
RuntimeConversation, RuntimeConversation,
RuntimeContextSummary,
RuntimeMessage, RuntimeMessage,
SaveRuntimeContextSummaryInput,
} from '@openchatlab/ai-runtime' } from '@openchatlab/ai-runtime'
import type { DatabaseAdapter } from '@openchatlab/core' 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 CREATE INDEX IF NOT EXISTS idx_ai_message_conversation_created
ON ai_message(conversation_id, created_at ASC, id ASC); 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 { interface ConversationRow {
@@ -53,6 +64,14 @@ interface MessageRow {
usage_json: string | null 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 { function createId(prefix: string): string {
return `${prefix}_${crypto.randomUUID()}` 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 { export class BrowserAIConversationRepository implements ConversationRepository {
constructor(private readonly database: WorkspaceDatabasePort) {} 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> { appendMessage(input: AppendRuntimeMessageInput): Promise<RuntimeMessage> {
const message: RuntimeMessage = { const message: RuntimeMessage = {
id: input.id ?? createId('message'), id: input.id ?? createId('message'),
+12
View File
@@ -3,7 +3,9 @@ import type { BrowserSessionCatalogItem } from '../import/session-catalog'
import type { import type {
AppendRuntimeMessageInput, AppendRuntimeMessageInput,
RuntimeConversation, RuntimeConversation,
RuntimeContextSummary,
RuntimeMessage, RuntimeMessage,
SaveRuntimeContextSummaryInput,
RuntimeToolDefinition, RuntimeToolDefinition,
RuntimeToolResult, RuntimeToolResult,
} from '@openchatlab/ai-runtime' } from '@openchatlab/ai-runtime'
@@ -141,6 +143,14 @@ export interface WebRuntimeTaskMap {
payload: { messageId: string } payload: { messageId: string }
result: { deleted: boolean } result: { deleted: boolean }
} }
'ai.context-summary.get': {
payload: { conversationId: string }
result: RuntimeContextSummary | null
}
'ai.context-summary.save': {
payload: SaveRuntimeContextSummaryInput
result: RuntimeContextSummary
}
'ai.tool.list': { 'ai.tool.list': {
payload: { locale?: string } payload: { locale?: string }
result: RuntimeToolDefinition[] result: RuntimeToolDefinition[]
@@ -350,6 +360,8 @@ const WEB_RUNTIME_TASK_TYPES: Record<WebRuntimeTaskType, true> = {
'ai.message.append': true, 'ai.message.append': true,
'ai.message.update': true, 'ai.message.update': true,
'ai.message.delete': true, 'ai.message.delete': true,
'ai.context-summary.get': true,
'ai.context-summary.save': true,
'ai.tool.list': true, 'ai.tool.list': true,
'ai.tool.execute': true, 'ai.tool.execute': true,
'analysis.hourly': true, 'analysis.hourly': true,
@@ -72,6 +72,8 @@ export type WorkerAIRepository = Pick<
| 'appendMessage' | 'appendMessage'
| 'updateMessage' | 'updateMessage'
| 'deleteMessage' | 'deleteMessage'
| 'getContextSummary'
| 'saveContextSummary'
> >
export type WorkerAIToolRuntime = Pick<BrowserAIToolRuntime, 'listTools' | 'execute'> export type WorkerAIToolRuntime = Pick<BrowserAIToolRuntime, 'listTools' | 'execute'>
@@ -283,6 +285,10 @@ export class WebRuntimeWorkerController {
return { updated: true } return { updated: true }
case 'ai.message.delete': case 'ai.message.delete':
return { deleted: await this.aiRepository.deleteMessage(request.payload.messageId) } 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': case 'ai.tool.list':
return this.aiToolRuntime.listTools(request.payload.locale) return this.aiToolRuntime.listTools(request.payload.locale)
case 'ai.tool.execute': { 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 { useI18n } from 'vue-i18n'
import { usePromptStore } from '@/stores/prompt' import { usePromptStore } from '@/stores/prompt'
import type { ChartAutoMode } from '@openchatlab/shared-types' import type { ChartAutoMode } from '@openchatlab/shared-types'
import { MAX_TOOL_RESULT_PERCENT, MIN_TOOL_RESULT_PERCENT, normalizeMaxToolResultPercent } from './promptConfigLimits'
const { t } = useI18n() const { t } = useI18n()
@@ -77,50 +76,6 @@ const searchContextAfter = computed({
emit('config-changed') 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> </script>
<template> <template>
@@ -210,82 +165,5 @@ const maxToolResultPercent = computed({
</div> </div>
</div> </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> </div>
</template> </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: 'semanticIndex', label: t('settings.tabs.semanticIndex') },
{ id: 'skill', label: t('settings.tabs.chatPreferences') }, { id: 'skill', label: t('settings.tabs.chatPreferences') },
{ id: 'chat', label: t('settings.aiPrompt.chatSettings.title') }, { id: 'chat', label: t('settings.aiPrompt.chatSettings.title') },
{ id: 'compression', label: t('settings.tabs.compression') },
{ id: 'preprocess', label: t('settings.tabs.aiPreprocess') }, { id: 'preprocess', label: t('settings.tabs.aiPreprocess') },
{ id: 'sessionIndex', label: t('settings.tabs.sessionManage') }, { id: 'sessionIndex', label: t('settings.tabs.sessionManage') },
{ id: 'export', label: t('settings.aiPrompt.exportSettings.title') }, { id: 'export', label: t('settings.aiPrompt.exportSettings.title') },
-12
View File
@@ -7,7 +7,6 @@
"aiDefaultModel": "Default Model", "aiDefaultModel": "Default Model",
"semanticIndex": "Semantic Index", "semanticIndex": "Semantic Index",
"chatPreferences": "Preferences", "chatPreferences": "Preferences",
"compression": "Compression",
"aiPreprocess": "Preprocess", "aiPreprocess": "Preprocess",
"dataManage": "Data Management", "dataManage": "Data Management",
"storage": "Storage", "storage": "Storage",
@@ -377,17 +376,6 @@
"sqlExportFormat": { "sqlExportFormat": {
"title": "SQL Lab Export Format", "title": "SQL Lab Export Format",
"description": "File format for exporting SQL query results" "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": { "about": {
+1
View File
@@ -6,6 +6,7 @@
"regenerate": "Regenerate", "regenerate": "Regenerate",
"footer": "AI can make mistakes. Verify important conclusions.", "footer": "AI can make mistakes. Verify important conclusions.",
"usingTool": "Using {name}", "usingTool": "Using {name}",
"compressingContext": "Compressing context",
"conversations": { "conversations": {
"title": "Conversations", "title": "Conversations",
"deleteConfirm": "Delete this AI conversation?" "deleteConfirm": "Delete this AI conversation?"
-12
View File
@@ -7,7 +7,6 @@
"aiDefaultModel": "デフォルトモデル", "aiDefaultModel": "デフォルトモデル",
"semanticIndex": "セマンティックインデックス", "semanticIndex": "セマンティックインデックス",
"chatPreferences": "会話設定", "chatPreferences": "会話設定",
"compression": "圧縮",
"aiPreprocess": "前処理", "aiPreprocess": "前処理",
"dataManage": "データ管理", "dataManage": "データ管理",
"storage": "ストレージ管理", "storage": "ストレージ管理",
@@ -377,17 +376,6 @@
"sqlExportFormat": { "sqlExportFormat": {
"title": "SQL Lab エクスポート形式", "title": "SQL Lab エクスポート形式",
"description": "SQL クエリ結果をエクスポートする際のファイル形式" "description": "SQL クエリ結果をエクスポートする際のファイル形式"
},
"compression": {
"title": "コンテキスト圧縮",
"enable": "コンテキストの自動圧縮",
"enableDesc": "会話が長くなった際に履歴を自動的に要約に圧縮し、トークン消費を削減します",
"threshold": "圧縮閾値",
"thresholdDesc": "コンテキストトークンがモデルウィンドウのこの割合に達したら圧縮を開始",
"buffer": "バッファサイズ",
"bufferDesc": "圧縮せずに保持する最新メッセージの割合",
"maxToolResultPercent": "ツール結果の上限",
"maxToolResultPercentDesc": "1回のツール呼び出しでAIに送信される最大データ割合(モデルのコンテキストウィンドウに基づいて計算)。超過分はスマートに切り捨てられます"
} }
}, },
"about": { "about": {
+1
View File
@@ -6,6 +6,7 @@
"regenerate": "再生成", "regenerate": "再生成",
"footer": "AI は誤ることがあります。重要な結論は確認してください。", "footer": "AI は誤ることがあります。重要な結論は確認してください。",
"usingTool": "{name} を使用中", "usingTool": "{name} を使用中",
"compressingContext": "コンテキストを圧縮中",
"conversations": { "conversations": {
"title": "会話", "title": "会話",
"deleteConfirm": "この AI 会話を削除しますか?" "deleteConfirm": "この AI 会話を削除しますか?"
-12
View File
@@ -7,7 +7,6 @@
"aiDefaultModel": "默认模型", "aiDefaultModel": "默认模型",
"semanticIndex": "语义索引", "semanticIndex": "语义索引",
"chatPreferences": "对话偏好", "chatPreferences": "对话偏好",
"compression": "上下文压缩",
"aiPreprocess": "预处理", "aiPreprocess": "预处理",
"dataManage": "数据管理", "dataManage": "数据管理",
"storage": "存储管理", "storage": "存储管理",
@@ -377,17 +376,6 @@
"sqlExportFormat": { "sqlExportFormat": {
"title": "SQL Lab 导出格式", "title": "SQL Lab 导出格式",
"description": "导出 SQL 查询结果时使用的文件格式" "description": "导出 SQL 查询结果时使用的文件格式"
},
"compression": {
"title": "上下文压缩",
"enable": "自动压缩上下文",
"enableDesc": "对话过长时自动压缩历史为摘要,保留关键信息的同时降低 Token 消耗",
"threshold": "压缩阈值",
"thresholdDesc": "上下文 Token 达到模型窗口的百分比时触发压缩",
"buffer": "缓冲区大小",
"bufferDesc": "保留最近消息原文的比例,不参与压缩",
"maxToolResultPercent": "工具结果上限",
"maxToolResultPercentDesc": "单次工具返回给 AI 的最大数据量占比(基于模型上下文窗口计算),超出部分会被智能截断"
} }
}, },
"about": { "about": {
+1
View File
@@ -6,6 +6,7 @@
"regenerate": "重新生成", "regenerate": "重新生成",
"footer": "AI 可能会犯错,请核对重要结论", "footer": "AI 可能会犯错,请核对重要结论",
"usingTool": "正在调用 {name}", "usingTool": "正在调用 {name}",
"compressingContext": "正在压缩上下文",
"conversations": { "conversations": {
"title": "对话", "title": "对话",
"deleteConfirm": "确定删除这条 AI 对话吗?" "deleteConfirm": "确定删除这条 AI 对话吗?"
-12
View File
@@ -7,7 +7,6 @@
"aiDefaultModel": "預設模型", "aiDefaultModel": "預設模型",
"semanticIndex": "語意索引", "semanticIndex": "語意索引",
"chatPreferences": "對話偏好", "chatPreferences": "對話偏好",
"compression": "上下文壓縮",
"aiPreprocess": "前處理", "aiPreprocess": "前處理",
"dataManage": "資料管理", "dataManage": "資料管理",
"storage": "儲存管理", "storage": "儲存管理",
@@ -377,17 +376,6 @@
"sqlExportFormat": { "sqlExportFormat": {
"title": "SQL Lab 匯出格式", "title": "SQL Lab 匯出格式",
"description": "匯出 SQL 查詢結果時使用的檔案格式" "description": "匯出 SQL 查詢結果時使用的檔案格式"
},
"compression": {
"title": "上下文壓縮",
"enable": "自動壓縮上下文",
"enableDesc": "對話過長時自動壓縮歷史為摘要,保留關鍵資訊的同時降低 Token 消耗",
"threshold": "壓縮閾值",
"thresholdDesc": "上下文 Token 達到模型視窗的百分比時觸發壓縮",
"buffer": "緩衝區大小",
"bufferDesc": "保留最近訊息原文的比例,不參與壓縮",
"maxToolResultPercent": "工具結果上限",
"maxToolResultPercentDesc": "單次工具回傳給 AI 的最大資料量占比(基於模型上下文視窗計算),超出部分會被智慧截斷"
} }
}, },
"about": { "about": {
+1
View File
@@ -6,6 +6,7 @@
"regenerate": "重新產生", "regenerate": "重新產生",
"footer": "AI 可能會出錯,請核對重要結論", "footer": "AI 可能會出錯,請核對重要結論",
"usingTool": "正在呼叫 {name}", "usingTool": "正在呼叫 {name}",
"compressingContext": "正在壓縮上下文",
"conversations": { "conversations": {
"title": "對話", "title": "對話",
"deleteConfirm": "確定刪除這條 AI 對話嗎?" "deleteConfirm": "確定刪除這條 AI 對話嗎?"
-8
View File
@@ -100,13 +100,6 @@ export interface AgentStreamResult {
error?: SerializedErrorInfo | Record<string, unknown> error?: SerializedErrorInfo | Record<string, unknown>
} }
export interface CompressionConfig {
enabled: boolean
tokenThresholdPercent: number
bufferSizePercent: number
maxToolResultPercent?: number
}
export interface AgentStreamParams { export interface AgentStreamParams {
userMessage: string userMessage: string
sessionId: string sessionId: string
@@ -118,7 +111,6 @@ export interface AgentStreamParams {
skillId?: string | null skillId?: string | null
enableAutoSkill?: boolean enableAutoSkill?: boolean
chartAutoMode?: ChartAutoMode chartAutoMode?: ChartAutoMode
compressionConfig?: CompressionConfig
ownerInfo?: { platformId: string; displayName: string } ownerInfo?: { platformId: string; displayName: string }
mentionedMembers?: Array<{ mentionedMembers?: Array<{
memberId: number memberId: number
-1
View File
@@ -11,7 +11,6 @@ export type {
AIPreprocessConfig, AIPreprocessConfig,
WordFilterScheme, WordFilterScheme,
KeywordTemplate, KeywordTemplate,
ContextCompressionSettings,
DesensitizeRule, DesensitizeRule,
UiConfig, UiConfig,
} from '@openchatlab/shared-types' } from '@openchatlab/shared-types'
-12
View File
@@ -1020,12 +1020,6 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
skillId: currentSkillId, skillId: currentSkillId,
enableAutoSkill: !currentSkillId ? autoSkillEnabled : undefined, enableAutoSkill: !currentSkillId ? autoSkillEnabled : undefined,
chartAutoMode: !currentSkillId ? chartAutoMode : 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: (() => { thinkingLevel: (() => {
const cfg = llmStore.defaultAssistant const cfg = llmStore.defaultAssistant
if (!cfg?.configId || !cfg?.modelId) return undefined if (!cfg?.configId || !cfg?.modelId) return undefined
@@ -1619,12 +1613,6 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
skillId: currentSkillId, skillId: currentSkillId,
enableAutoSkill: !currentSkillId ? autoSkillEnabled : undefined, enableAutoSkill: !currentSkillId ? autoSkillEnabled : undefined,
chartAutoMode: !currentSkillId ? chartAutoMode : 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: (() => { thinkingLevel: (() => {
const cfg = llmStore.defaultAssistant const cfg = llmStore.defaultAssistant
if (!cfg?.configId || !cfg?.modelId) return undefined 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 { ThinkingLevel } from '@openchatlab/core'
import type { ChartAutoMode } from '@openchatlab/shared-types' import type { ChartAutoMode } from '@openchatlab/shared-types'
interface ContextCompressionSettings {
enabled: boolean
tokenThresholdPercent: number
bufferSizePercent: number
maxToolResultPercent: number
}
interface AIGlobalSettings { interface AIGlobalSettings {
maxMessagesPerRequest: number maxMessagesPerRequest: number
exportFormat: 'markdown' | 'txt' exportFormat: 'markdown' | 'txt'
@@ -19,12 +12,9 @@ interface AIGlobalSettings {
chartAutoMode: ChartAutoMode chartAutoMode: ChartAutoMode
searchContextBefore: number searchContextBefore: number
searchContextAfter: number searchContextAfter: number
contextCompression: ContextCompressionSettings
} }
type AIGlobalSettingsUpdate = Partial<Omit<AIGlobalSettings, 'contextCompression'>> & { type AIGlobalSettingsUpdate = Partial<AIGlobalSettings>
contextCompression?: Partial<ContextCompressionSettings>
}
/** /**
* AI 配置与关键词模板相关的全局状态 * AI 配置与关键词模板相关的全局状态
@@ -41,12 +31,6 @@ export const usePromptStore = defineStore(
chartAutoMode: 'suggest', chartAutoMode: 'suggest',
searchContextBefore: 2, searchContextBefore: 2,
searchContextAfter: 2, searchContextAfter: 2,
contextCompression: {
enabled: true,
tokenThresholdPercent: 75,
bufferSizePercent: 20,
maxToolResultPercent: 50,
},
}) })
const customKeywordTemplates = ref<KeywordTemplate[]>([]) const customKeywordTemplates = ref<KeywordTemplate[]>([])
const deletedPresetTemplateIds = ref<string[]>([]) const deletedPresetTemplateIds = ref<string[]>([])
@@ -69,13 +53,9 @@ export const usePromptStore = defineStore(
* 更新 AI 全局设置 * 更新 AI 全局设置
*/ */
function updateAIGlobalSettings(settings: AIGlobalSettingsUpdate) { function updateAIGlobalSettings(settings: AIGlobalSettingsUpdate) {
const { contextCompression, ...baseSettings } = settings
aiGlobalSettings.value = { aiGlobalSettings.value = {
...aiGlobalSettings.value, ...aiGlobalSettings.value,
...baseSettings, ...settings,
contextCompression: contextCompression
? { ...aiGlobalSettings.value.contextCompression, ...contextCompression }
: aiGlobalSettings.value.contextCompression,
} }
notifyAIConfigChanged() notifyAIConfigChanged()
} }