mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
feat(ai): enable automatic context compression
This commit is contained in:
1 parent
5c7ad41b23
commit
17320045dc
43 files changed
+546
-309
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
export * from './default-assistant'
|
||||
export * from './context-compression'
|
||||
export * from './errors'
|
||||
export * from './runtime'
|
||||
export * from './token-budget'
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 : []
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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') },
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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?"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"regenerate": "再生成",
|
||||
"footer": "AI は誤ることがあります。重要な結論は確認してください。",
|
||||
"usingTool": "{name} を使用中",
|
||||
"compressingContext": "コンテキストを圧縮中",
|
||||
"conversations": {
|
||||
"title": "会話",
|
||||
"deleteConfirm": "この AI 会話を削除しますか?"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"regenerate": "重新生成",
|
||||
"footer": "AI 可能会犯错,请核对重要结论",
|
||||
"usingTool": "正在调用 {name}",
|
||||
"compressingContext": "正在压缩上下文",
|
||||
"conversations": {
|
||||
"title": "对话",
|
||||
"deleteConfirm": "确定删除这条 AI 对话吗?"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"regenerate": "重新產生",
|
||||
"footer": "AI 可能會出錯,請核對重要結論",
|
||||
"usingTool": "正在呼叫 {name}",
|
||||
"compressingContext": "正在壓縮上下文",
|
||||
"conversations": {
|
||||
"title": "對話",
|
||||
"deleteConfirm": "確定刪除這條 AI 對話嗎?"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,7 +11,6 @@ export type {
|
||||
AIPreprocessConfig,
|
||||
WordFilterScheme,
|
||||
KeywordTemplate,
|
||||
ContextCompressionSettings,
|
||||
DesensitizeRule,
|
||||
UiConfig,
|
||||
} from '@openchatlab/shared-types'
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user