feat: add manual/auto conversation compaction and preserve full export history
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "K.G.Studio",
|
"name": "K.G.Studio",
|
||||||
"version": "0.17.4-build.20260520",
|
"version": "0.19.0-build.20260531",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "K.G.Studio",
|
"name": "K.G.Studio",
|
||||||
"version": "0.17.4-build.20260520",
|
"version": "0.19.0-build.20260531",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@breezystack/lamejs": "^1.2.7",
|
"@breezystack/lamejs": "^1.2.7",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"language": "auto",
|
"language": "auto",
|
||||||
"llm_provider": "local_browser",
|
"llm_provider": "local_browser",
|
||||||
"persist_api_keys_non_localhost": false,
|
"persist_api_keys_non_localhost": false,
|
||||||
|
"auto_compact_threshold_percent": 90,
|
||||||
"openai": {
|
"openai": {
|
||||||
"api_key": "",
|
"api_key": "",
|
||||||
"flex": false,
|
"flex": false,
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
You are summarizing a K.G.Studio Musician Assistant conversation so it can continue in a smaller context window.
|
||||||
|
|
||||||
|
Your job:
|
||||||
|
- Preserve the current task objective.
|
||||||
|
- Preserve accepted constraints, decisions, and user preferences.
|
||||||
|
- Preserve important tool calls, tool outcomes, and error messages that still matter.
|
||||||
|
- Preserve relevant project context such as BPM, key, time signature, region boundaries, and track/instrument context when it affects the task.
|
||||||
|
- Preserve unfinished work and the next best action.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Be concise but specific.
|
||||||
|
- Prefer durable facts over conversational filler.
|
||||||
|
- Do not rewrite the user's intent.
|
||||||
|
- Do not include long verbatim transcript excerpts.
|
||||||
|
- Do not invent missing information.
|
||||||
|
- Make the summary usable as a direct handoff for the next model turn.
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ConversationCompactor } from './ConversationCompactor';
|
||||||
|
import type { LLMProvider } from '../llm/LLMProvider';
|
||||||
|
import type { Message } from '../core/AgentState';
|
||||||
|
|
||||||
|
function createStubProvider(summaryPrefix = 'summary'): LLMProvider {
|
||||||
|
return {
|
||||||
|
async *generateStream(messages) {
|
||||||
|
const source = messages[0]?.content ?? '';
|
||||||
|
yield { type: 'text', content: `${summaryPrefix}:${String(source).slice(0, 12)}` };
|
||||||
|
yield { type: 'done', content: '', finishReason: 'stop' };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ConversationCompactor', () => {
|
||||||
|
it('preserves the recent raw tail while compacting the prefix', async () => {
|
||||||
|
const compactor = new ConversationCompactor({
|
||||||
|
provider: createStubProvider(),
|
||||||
|
systemPrompt: 'compact prompt',
|
||||||
|
});
|
||||||
|
const messages: Message[] = [
|
||||||
|
{ id: '1', role: 'user', content: 'older user', timestamp: 1 },
|
||||||
|
{ id: '2', role: 'assistant', content: 'older reply', timestamp: 2 },
|
||||||
|
{ id: '3', role: 'user', content: 'recent user', timestamp: 3 },
|
||||||
|
{ id: '4', role: 'assistant', content: 'recent reply', timestamp: 4 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await compactor.compact(messages, 2);
|
||||||
|
|
||||||
|
expect(result.changed).toBe(true);
|
||||||
|
expect(result.summary).toContain('Compacted conversation summary:');
|
||||||
|
expect(result.compactedConversation).toContain('recent user');
|
||||||
|
expect(result.compactedConversation).toContain('recent reply');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns unchanged when there is no compactable prefix', async () => {
|
||||||
|
const compactor = new ConversationCompactor({
|
||||||
|
provider: createStubProvider(),
|
||||||
|
systemPrompt: 'compact prompt',
|
||||||
|
});
|
||||||
|
const messages: Message[] = [
|
||||||
|
{ id: '1', role: 'user', content: 'only user', timestamp: 1 },
|
||||||
|
{ id: '2', role: 'assistant', content: 'only reply', timestamp: 2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await compactor.compact(messages, 0);
|
||||||
|
|
||||||
|
expect(result.changed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits progress while generating chunk summaries', async () => {
|
||||||
|
const onProgress = vi.fn();
|
||||||
|
const compactor = new ConversationCompactor({
|
||||||
|
provider: createStubProvider(),
|
||||||
|
systemPrompt: 'compact prompt',
|
||||||
|
onProgress,
|
||||||
|
});
|
||||||
|
const messages: Message[] = [
|
||||||
|
{ id: '1', role: 'user', content: 'older user', timestamp: 1 },
|
||||||
|
{ id: '2', role: 'assistant', content: 'older reply', timestamp: 2 },
|
||||||
|
{ id: '3', role: 'user', content: 'recent user', timestamp: 3 },
|
||||||
|
{ id: '4', role: 'assistant', content: 'recent reply', timestamp: 4 },
|
||||||
|
];
|
||||||
|
|
||||||
|
await compactor.compact(messages, 2);
|
||||||
|
|
||||||
|
expect(onProgress).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import type { Message } from '../core/AgentState';
|
||||||
|
import type { LLMProvider } from '../llm/LLMProvider';
|
||||||
|
import type { OpenAIToolDefinition } from '../tools/BaseTool';
|
||||||
|
|
||||||
|
export interface CompactProgress {
|
||||||
|
chunkIndex: number;
|
||||||
|
chunkCount: number;
|
||||||
|
receivedTokenCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConversationCompactorOptions {
|
||||||
|
provider: LLMProvider;
|
||||||
|
systemPrompt: string;
|
||||||
|
tools?: OpenAIToolDefinition[];
|
||||||
|
focus?: string;
|
||||||
|
onProgress?: (progress: CompactProgress) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConversationCompactionResult {
|
||||||
|
changed: boolean;
|
||||||
|
compactedConversation: string;
|
||||||
|
summary: string;
|
||||||
|
tailStartIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHUNK_CHARACTER_BUDGET = 24_000;
|
||||||
|
|
||||||
|
function formatMessage(message: Message): string {
|
||||||
|
const parts = [`[${message.role.toUpperCase()}]`];
|
||||||
|
if (message.is_compacted_summary) {
|
||||||
|
parts.push('[COMPACTED_SUMMARY]');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.content) {
|
||||||
|
parts.push(message.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.tool_calls?.length) {
|
||||||
|
for (const toolCall of message.tool_calls) {
|
||||||
|
parts.push(
|
||||||
|
`TOOL_CALL ${toolCall.function.name}: ${toolCall.function.arguments}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.tool_call_id) {
|
||||||
|
parts.push(`TOOL_RESULT_FOR ${message.tool_call_id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitIntoChunks(serializedMessages: string[]): string[] {
|
||||||
|
const chunks: string[] = [];
|
||||||
|
let currentChunk = '';
|
||||||
|
|
||||||
|
for (const serialized of serializedMessages) {
|
||||||
|
if (!currentChunk) {
|
||||||
|
currentChunk = serialized;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((currentChunk.length + serialized.length + 2) > CHUNK_CHARACTER_BUDGET) {
|
||||||
|
chunks.push(currentChunk);
|
||||||
|
currentChunk = serialized;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentChunk += `\n\n${serialized}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentChunk) {
|
||||||
|
chunks.push(currentChunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCompactionUserPrompt(chunkText: string, chunkIndex: number, chunkCount: number, focus?: string): string {
|
||||||
|
const focusSection = focus?.trim()
|
||||||
|
? `Focus instruction from the user: ${focus.trim()}\n\n`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return `${focusSection}Summarize this conversation history chunk for future continuation.
|
||||||
|
|
||||||
|
Preserve:
|
||||||
|
- the active goal
|
||||||
|
- accepted constraints and decisions
|
||||||
|
- important tool results and errors
|
||||||
|
- relevant project, track, region, and music context
|
||||||
|
- unfinished work and next steps
|
||||||
|
|
||||||
|
Do not quote the full transcript. Produce a concise but durable handoff summary.
|
||||||
|
|
||||||
|
Chunk ${chunkIndex + 1} of ${chunkCount}:
|
||||||
|
|
||||||
|
${chunkText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ConversationCompactor {
|
||||||
|
private readonly provider: LLMProvider;
|
||||||
|
private readonly systemPrompt: string;
|
||||||
|
private readonly tools: OpenAIToolDefinition[];
|
||||||
|
private readonly focus?: string;
|
||||||
|
private readonly onProgress?: (progress: CompactProgress) => void;
|
||||||
|
|
||||||
|
constructor(options: ConversationCompactorOptions) {
|
||||||
|
this.provider = options.provider;
|
||||||
|
this.systemPrompt = options.systemPrompt;
|
||||||
|
this.tools = options.tools ?? [];
|
||||||
|
this.focus = options.focus;
|
||||||
|
this.onProgress = options.onProgress;
|
||||||
|
}
|
||||||
|
|
||||||
|
async compact(messages: Message[], tailStartIndex: number): Promise<ConversationCompactionResult> {
|
||||||
|
if (tailStartIndex <= 0 || tailStartIndex >= messages.length) {
|
||||||
|
return {
|
||||||
|
changed: false,
|
||||||
|
compactedConversation: this.renderConversation(messages),
|
||||||
|
summary: '',
|
||||||
|
tailStartIndex,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = messages.slice(0, tailStartIndex);
|
||||||
|
const serializedMessages = prefix.map(formatMessage);
|
||||||
|
const chunks = splitIntoChunks(serializedMessages);
|
||||||
|
|
||||||
|
if (chunks.length === 0) {
|
||||||
|
return {
|
||||||
|
changed: false,
|
||||||
|
compactedConversation: this.renderConversation(messages),
|
||||||
|
summary: '',
|
||||||
|
tailStartIndex,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let summaries = await Promise.all(
|
||||||
|
chunks.map((chunk, index) => this.summarizeChunk(chunk, index, chunks.length)),
|
||||||
|
);
|
||||||
|
|
||||||
|
while (summaries.length > 1) {
|
||||||
|
const mergedChunks = splitIntoChunks(summaries.map((summary, index) => `SUMMARY ${index + 1}\n${summary}`));
|
||||||
|
summaries = await Promise.all(
|
||||||
|
mergedChunks.map((chunk, index) => this.summarizeChunk(chunk, index, mergedChunks.length)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = `Compacted conversation summary:\n${summaries[0].trim()}`;
|
||||||
|
const compactedConversation = this.renderConversation([
|
||||||
|
{
|
||||||
|
...messages[0],
|
||||||
|
id: 'compacted-summary-preview',
|
||||||
|
role: 'assistant',
|
||||||
|
content: summary,
|
||||||
|
is_compacted_summary: true,
|
||||||
|
compact_trigger: 'manual',
|
||||||
|
tool_calls: undefined,
|
||||||
|
tool_call_id: undefined,
|
||||||
|
},
|
||||||
|
...messages.slice(tailStartIndex),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
changed: true,
|
||||||
|
compactedConversation,
|
||||||
|
summary,
|
||||||
|
tailStartIndex,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
renderConversation(messages: Message[]): string {
|
||||||
|
return messages.map(formatMessage).join('\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async summarizeChunk(chunkText: string, chunkIndex: number, chunkCount: number): Promise<string> {
|
||||||
|
const prompt = buildCompactionUserPrompt(chunkText, chunkIndex, chunkCount, this.focus);
|
||||||
|
const messages: Message[] = [
|
||||||
|
{
|
||||||
|
id: `compact_user_${chunkIndex}`,
|
||||||
|
role: 'user',
|
||||||
|
content: prompt,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let receivedTokenCount = 0;
|
||||||
|
let summary = '';
|
||||||
|
|
||||||
|
for await (const chunk of this.provider.generateStream(messages, this.systemPrompt, [])) {
|
||||||
|
if (chunk.type === 'text') {
|
||||||
|
summary += chunk.content;
|
||||||
|
receivedTokenCount += 1;
|
||||||
|
this.onProgress?.({
|
||||||
|
chunkIndex,
|
||||||
|
chunkCount,
|
||||||
|
receivedTokenCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,19 @@ import { useProjectStore } from '../../stores/projectStore';
|
|||||||
import type { StreamChunk } from '../llm/StreamingTypes';
|
import type { StreamChunk } from '../llm/StreamingTypes';
|
||||||
import type { ToolCall } from './AgentState';
|
import type { ToolCall } from './AgentState';
|
||||||
import type { OpenAIToolDefinition } from '../tools/BaseTool';
|
import type { OpenAIToolDefinition } from '../tools/BaseTool';
|
||||||
|
import { ConversationCompactor, type CompactProgress } from '../compact/ConversationCompactor';
|
||||||
|
import { ConfigManager } from '../../core/config/ConfigManager';
|
||||||
|
|
||||||
|
export interface CompactConversationOptions {
|
||||||
|
trigger: 'manual' | 'auto';
|
||||||
|
focus?: string;
|
||||||
|
onProgress?: (progress: CompactProgress) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompactConversationResult {
|
||||||
|
changed: boolean;
|
||||||
|
compactedConversation: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main orchestrator for the AI agent system.
|
* Main orchestrator for the AI agent system.
|
||||||
@@ -52,6 +65,10 @@ export class AgentCore {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getSystemPrompt(templatePath?: string): Promise<string> {
|
||||||
|
return SystemPrompts.getSystemPromptWithContext(templatePath);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute a single tool call and return the result
|
* Execute a single tool call and return the result
|
||||||
*/
|
*/
|
||||||
@@ -207,6 +224,105 @@ export class AgentCore {
|
|||||||
this.agentState.clearMessages();
|
this.agentState.clearMessages();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async shouldCompactBeforeNextTurn(userInput: string): Promise<boolean> {
|
||||||
|
if (!this.llmProvider?.estimateHistoryTokens || !this.llmProvider.getContextWindow) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contextWindow = this.llmProvider.getContextWindow();
|
||||||
|
if (!contextWindow) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tools = this.getToolDefinitions();
|
||||||
|
const systemPrompt = await this.getSystemPrompt(
|
||||||
|
this.llmProvider.getPreferredSystemPromptPath?.(),
|
||||||
|
);
|
||||||
|
const thresholdPercent = await this.getAutoCompactThresholdPercent();
|
||||||
|
const reservedOutputTokens = this.llmProvider.getReservedOutputTokens?.() ?? 4096;
|
||||||
|
const hypotheticalMessages = [
|
||||||
|
...this.agentState.getMessages(),
|
||||||
|
{
|
||||||
|
id: `preflight_${Date.now()}`,
|
||||||
|
role: 'user' as const,
|
||||||
|
content: userInput,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const estimatedTokens = await this.llmProvider.estimateHistoryTokens(
|
||||||
|
hypotheticalMessages,
|
||||||
|
systemPrompt,
|
||||||
|
tools,
|
||||||
|
);
|
||||||
|
const thresholdTokens = Math.floor(contextWindow * (thresholdPercent / 100));
|
||||||
|
|
||||||
|
return (estimatedTokens + reservedOutputTokens) >= thresholdTokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
async compactConversation(options: CompactConversationOptions): Promise<CompactConversationResult> {
|
||||||
|
if (!this.llmProvider) {
|
||||||
|
throw new Error('No LLM provider configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = this.agentState.getMessages();
|
||||||
|
if (messages.length < 2) {
|
||||||
|
return {
|
||||||
|
changed: false,
|
||||||
|
compactedConversation: messages.map(message => message.content ?? '').join('\n\n'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const tailStartIndex = this.agentState.findRecentTailStartIndex();
|
||||||
|
if (tailStartIndex <= 0 || tailStartIndex >= messages.length) {
|
||||||
|
return {
|
||||||
|
changed: false,
|
||||||
|
compactedConversation: messages.map(message => message.content ?? '').join('\n\n'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const compactionPrompt = await this.getSystemPrompt('prompts/system_compaction.md');
|
||||||
|
const compactor = new ConversationCompactor({
|
||||||
|
provider: this.llmProvider,
|
||||||
|
systemPrompt: compactionPrompt,
|
||||||
|
tools: this.getToolDefinitions(),
|
||||||
|
focus: options.focus,
|
||||||
|
onProgress: options.onProgress,
|
||||||
|
});
|
||||||
|
const result = await compactor.compact(messages, tailStartIndex);
|
||||||
|
if (!result.changed) {
|
||||||
|
return {
|
||||||
|
changed: false,
|
||||||
|
compactedConversation: result.compactedConversation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextMessages = this.agentState.createCompactedHistory(
|
||||||
|
result.summary,
|
||||||
|
result.tailStartIndex,
|
||||||
|
options.trigger,
|
||||||
|
);
|
||||||
|
this.agentState.replaceMessages(nextMessages);
|
||||||
|
console.log('------------ COMPACTED CONVERSATION ------------');
|
||||||
|
console.log(result.compactedConversation);
|
||||||
|
console.log('------------------------------------------------');
|
||||||
|
|
||||||
|
return {
|
||||||
|
changed: true,
|
||||||
|
compactedConversation: result.compactedConversation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async retryAfterCompaction(
|
||||||
|
userInput: string,
|
||||||
|
options: Omit<CompactConversationOptions, 'trigger'> = {},
|
||||||
|
): Promise<CompactConversationResult> {
|
||||||
|
return this.compactConversation({
|
||||||
|
trigger: 'auto',
|
||||||
|
focus: options.focus,
|
||||||
|
onProgress: options.onProgress,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
getIsWorkingOnTask(): boolean {
|
getIsWorkingOnTask(): boolean {
|
||||||
return this.agentState.getIsWorkingOnTask();
|
return this.agentState.getIsWorkingOnTask();
|
||||||
}
|
}
|
||||||
@@ -214,4 +330,22 @@ export class AgentCore {
|
|||||||
setIsWorkingOnTask(isWorking: boolean): void {
|
setIsWorkingOnTask(isWorking: boolean): void {
|
||||||
this.agentState.setIsWorkingOnTask(isWorking);
|
this.agentState.setIsWorkingOnTask(isWorking);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getAutoCompactThresholdPercent(): Promise<80 | 90 | 95> {
|
||||||
|
try {
|
||||||
|
const configManager = ConfigManager.instance();
|
||||||
|
if (!configManager.getIsInitialized()) {
|
||||||
|
await configManager.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
const configured = Number(configManager.get('general.auto_compact_threshold_percent'));
|
||||||
|
if (configured === 80 || configured === 95) {
|
||||||
|
return configured;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to load auto-compact threshold, using default.', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 90;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { AgentState } from './AgentState';
|
||||||
|
|
||||||
|
describe('AgentState compaction helpers', () => {
|
||||||
|
it('preserves the most recent exchange block as the raw tail', () => {
|
||||||
|
const state = new AgentState('conv_test');
|
||||||
|
state.addMessage('user', 'first');
|
||||||
|
state.addMessage('assistant', 'first reply');
|
||||||
|
state.addMessage('user', 'second');
|
||||||
|
state.addMessage('assistant', 'second reply');
|
||||||
|
|
||||||
|
expect(state.findRecentTailStartIndex()).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates compacted history with a synthetic summary message', () => {
|
||||||
|
const state = new AgentState('conv_test');
|
||||||
|
state.addMessage('user', 'first');
|
||||||
|
state.addMessage('assistant', 'first reply');
|
||||||
|
state.addMessage('user', 'second');
|
||||||
|
state.addMessage('assistant', 'second reply');
|
||||||
|
|
||||||
|
const compacted = state.createCompactedHistory('summary', 2, 'manual');
|
||||||
|
|
||||||
|
expect(compacted).toHaveLength(3);
|
||||||
|
expect(compacted[0]).toMatchObject({
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'summary',
|
||||||
|
is_compacted_summary: true,
|
||||||
|
compact_trigger: 'manual',
|
||||||
|
});
|
||||||
|
expect(compacted[1].content).toBe('second');
|
||||||
|
expect(compacted[2].content).toBe('second reply');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retains full history after current history is compacted', () => {
|
||||||
|
const state = new AgentState('conv_test');
|
||||||
|
state.addMessage('user', 'first');
|
||||||
|
state.addMessage('assistant', 'first reply');
|
||||||
|
state.addMessage('user', 'second');
|
||||||
|
state.addMessage('assistant', 'second reply');
|
||||||
|
|
||||||
|
const compacted = state.createCompactedHistory('summary', 2, 'manual');
|
||||||
|
state.replaceMessages(compacted);
|
||||||
|
|
||||||
|
expect(state.getMessages()).toHaveLength(3);
|
||||||
|
expect(state.getFullMessages()).toHaveLength(4);
|
||||||
|
expect(state.getFullMessages().map(message => message.content)).toEqual([
|
||||||
|
'first',
|
||||||
|
'first reply',
|
||||||
|
'second',
|
||||||
|
'second reply',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears full history when conversation is cleared', () => {
|
||||||
|
const state = new AgentState('conv_test');
|
||||||
|
state.addMessage('user', 'first');
|
||||||
|
state.addMessage('assistant', 'reply');
|
||||||
|
|
||||||
|
state.clearMessages();
|
||||||
|
|
||||||
|
expect(state.getMessages()).toHaveLength(0);
|
||||||
|
expect(state.getFullMessages()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,10 +21,13 @@ export interface Message {
|
|||||||
timestamp: number;
|
timestamp: number;
|
||||||
tool_calls?: ToolCall[]; // present on assistant messages when LLM invokes tools
|
tool_calls?: ToolCall[]; // present on assistant messages when LLM invokes tools
|
||||||
tool_call_id?: string; // present on tool-result messages, links back to ToolCall.id
|
tool_call_id?: string; // present on tool-result messages, links back to ToolCall.id
|
||||||
|
is_compacted_summary?: boolean;
|
||||||
|
compact_trigger?: 'manual' | 'auto';
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AgentState {
|
export class AgentState {
|
||||||
private messages: Message[] = [];
|
private messages: Message[] = [];
|
||||||
|
private fullMessages: Message[] = [];
|
||||||
private conversationId: string;
|
private conversationId: string;
|
||||||
private isWorkingOnTask: boolean = false;
|
private isWorkingOnTask: boolean = false;
|
||||||
|
|
||||||
@@ -39,7 +42,12 @@ export class AgentState {
|
|||||||
addMessage(
|
addMessage(
|
||||||
role: 'user' | 'assistant' | 'tool',
|
role: 'user' | 'assistant' | 'tool',
|
||||||
content: string | null,
|
content: string | null,
|
||||||
options?: { tool_calls?: ToolCall[]; tool_call_id?: string }
|
options?: {
|
||||||
|
tool_calls?: ToolCall[];
|
||||||
|
tool_call_id?: string;
|
||||||
|
is_compacted_summary?: boolean;
|
||||||
|
compact_trigger?: 'manual' | 'auto';
|
||||||
|
}
|
||||||
): string {
|
): string {
|
||||||
const message: Message = {
|
const message: Message = {
|
||||||
id: this.generateMessageId(),
|
id: this.generateMessageId(),
|
||||||
@@ -48,9 +56,12 @@ export class AgentState {
|
|||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
...(options?.tool_calls ? { tool_calls: options.tool_calls } : {}),
|
...(options?.tool_calls ? { tool_calls: options.tool_calls } : {}),
|
||||||
...(options?.tool_call_id ? { tool_call_id: options.tool_call_id } : {}),
|
...(options?.tool_call_id ? { tool_call_id: options.tool_call_id } : {}),
|
||||||
|
...(options?.is_compacted_summary ? { is_compacted_summary: true } : {}),
|
||||||
|
...(options?.compact_trigger ? { compact_trigger: options.compact_trigger } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.messages.push(message);
|
this.messages.push(message);
|
||||||
|
this.fullMessages.push({ ...message });
|
||||||
return message.id;
|
return message.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +75,17 @@ export class AgentState {
|
|||||||
if (options?.tool_calls) {
|
if (options?.tool_calls) {
|
||||||
this.messages[messageIndex].tool_calls = options.tool_calls;
|
this.messages[messageIndex].tool_calls = options.tool_calls;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullMessageIndex = this.fullMessages.findIndex(msg => msg.id === messageId);
|
||||||
|
if (fullMessageIndex !== -1) {
|
||||||
|
this.fullMessages[fullMessageIndex].content = content;
|
||||||
|
if (options?.tool_calls) {
|
||||||
|
this.fullMessages[fullMessageIndex].tool_calls = options.tool_calls;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messageIndex !== -1 || fullMessageIndex !== -1) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -76,6 +98,14 @@ export class AgentState {
|
|||||||
const messageIndex = this.messages.findIndex(msg => msg.id === messageId);
|
const messageIndex = this.messages.findIndex(msg => msg.id === messageId);
|
||||||
if (messageIndex !== -1) {
|
if (messageIndex !== -1) {
|
||||||
this.messages.splice(messageIndex, 1);
|
this.messages.splice(messageIndex, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullMessageIndex = this.fullMessages.findIndex(msg => msg.id === messageId);
|
||||||
|
if (fullMessageIndex !== -1) {
|
||||||
|
this.fullMessages.splice(fullMessageIndex, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messageIndex !== -1 || fullMessageIndex !== -1) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -86,6 +116,7 @@ export class AgentState {
|
|||||||
*/
|
*/
|
||||||
removeLastMessages(count: number): void {
|
removeLastMessages(count: number): void {
|
||||||
this.messages.splice(-count, count);
|
this.messages.splice(-count, count);
|
||||||
|
this.fullMessages.splice(-count, count);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -95,6 +126,10 @@ export class AgentState {
|
|||||||
return [...this.messages];
|
return [...this.messages];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getFullMessages(): Message[] {
|
||||||
|
return [...this.fullMessages];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the conversation ID
|
* Get the conversation ID
|
||||||
*/
|
*/
|
||||||
@@ -107,6 +142,11 @@ export class AgentState {
|
|||||||
*/
|
*/
|
||||||
clearMessages(): void {
|
clearMessages(): void {
|
||||||
this.messages = [];
|
this.messages = [];
|
||||||
|
this.fullMessages = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
replaceMessages(messages: Message[]): void {
|
||||||
|
this.messages = [...messages];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -116,6 +156,29 @@ export class AgentState {
|
|||||||
return this.messages.slice(-count);
|
return this.messages.slice(-count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
findRecentTailStartIndex(): number {
|
||||||
|
for (let i = this.messages.length - 1; i >= 0; i -= 1) {
|
||||||
|
if (this.messages[i].role === 'user') {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.messages.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
createCompactedHistory(summary: string, tailStartIndex: number, trigger: 'manual' | 'auto'): Message[] {
|
||||||
|
const preservedTail = this.messages.slice(Math.max(0, tailStartIndex));
|
||||||
|
const summaryMessage: Message = {
|
||||||
|
id: this.generateMessageId(),
|
||||||
|
role: 'assistant',
|
||||||
|
content: summary,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
is_compacted_summary: true,
|
||||||
|
compact_trigger: trigger,
|
||||||
|
};
|
||||||
|
|
||||||
|
return [summaryMessage, ...preservedTail];
|
||||||
|
}
|
||||||
|
|
||||||
private generateConversationId(): string {
|
private generateConversationId(): string {
|
||||||
return `conv_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
|
return `conv_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,18 @@ import OpenAI from 'openai';
|
|||||||
import type { StreamChunk } from './StreamingTypes';
|
import type { StreamChunk } from './StreamingTypes';
|
||||||
import type { Message, ToolCall } from '../core/AgentState';
|
import type { Message, ToolCall } from '../core/AgentState';
|
||||||
import type { OpenAIToolDefinition } from '../tools/BaseTool';
|
import type { OpenAIToolDefinition } from '../tools/BaseTool';
|
||||||
|
import { getModelTokenLimits } from './modelTokenLimits';
|
||||||
|
|
||||||
export interface LLMProvider {
|
export interface LLMProvider {
|
||||||
getPreferredSystemPromptPath?(): string | undefined;
|
getPreferredSystemPromptPath?(): string | undefined;
|
||||||
|
getContextWindow?(): number | undefined;
|
||||||
|
getReservedOutputTokens?(): number | undefined;
|
||||||
|
estimateHistoryTokens?(
|
||||||
|
messages: Message[],
|
||||||
|
systemPrompt?: string,
|
||||||
|
tools?: OpenAIToolDefinition[],
|
||||||
|
): Promise<number> | number;
|
||||||
|
isContextTooLongError?(error: unknown): boolean;
|
||||||
generateStream(
|
generateStream(
|
||||||
messages: Message[],
|
messages: Message[],
|
||||||
systemPrompt?: string,
|
systemPrompt?: string,
|
||||||
@@ -12,6 +21,28 @@ export interface LLMProvider {
|
|||||||
): AsyncIterableIterator<StreamChunk>;
|
): AsyncIterableIterator<StreamChunk>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractErrorDetails(error: unknown): { code?: string; message?: string } {
|
||||||
|
if (!error || typeof error !== 'object') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const asRecord = error as Record<string, unknown>;
|
||||||
|
const nestedError = asRecord.error && typeof asRecord.error === 'object'
|
||||||
|
? asRecord.error as Record<string, unknown>
|
||||||
|
: undefined;
|
||||||
|
const code = typeof asRecord.code === 'string'
|
||||||
|
? asRecord.code
|
||||||
|
: typeof nestedError?.code === 'string'
|
||||||
|
? nestedError.code
|
||||||
|
: undefined;
|
||||||
|
const message = typeof asRecord.message === 'string'
|
||||||
|
? asRecord.message
|
||||||
|
: typeof nestedError?.message === 'string'
|
||||||
|
? nestedError.message
|
||||||
|
: undefined;
|
||||||
|
return { code, message };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OpenAI-compatible provider implementation.
|
* OpenAI-compatible provider implementation.
|
||||||
* Works with OpenAI and OpenAI-compatible APIs (OpenRouter, Ollama, vLLM, etc.)
|
* Works with OpenAI and OpenAI-compatible APIs (OpenRouter, Ollama, vLLM, etc.)
|
||||||
@@ -31,6 +62,53 @@ export class OpenAICompatibleLLMProvider implements LLMProvider {
|
|||||||
this.model = model;
|
this.model = model;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getContextWindow(): number | undefined {
|
||||||
|
return getModelTokenLimits(this.model)?.contextWindow;
|
||||||
|
}
|
||||||
|
|
||||||
|
getReservedOutputTokens(): number | undefined {
|
||||||
|
const limits = getModelTokenLimits(this.model);
|
||||||
|
if (!limits) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof limits.reservedOutputTokens === 'number') {
|
||||||
|
return limits.reservedOutputTokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof limits.maxOutputTokens === 'number') {
|
||||||
|
return Math.min(limits.maxOutputTokens, 8_192);
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
estimateHistoryTokens(
|
||||||
|
messages: Message[],
|
||||||
|
systemPrompt?: string,
|
||||||
|
tools?: OpenAIToolDefinition[],
|
||||||
|
): number {
|
||||||
|
const openaiMessages = this.convertMessages(messages, systemPrompt);
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
model: this.model,
|
||||||
|
messages: openaiMessages,
|
||||||
|
tools: tools ?? [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Conservative browser-side estimate for preflight checks.
|
||||||
|
return Math.ceil(payload.length / 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
isContextTooLongError(error: unknown): boolean {
|
||||||
|
const { code, message } = extractErrorDetails(error);
|
||||||
|
if (code === 'context_length_exceeded') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeof message === 'string'
|
||||||
|
&& /context window|maximum context length|input exceeds the context window|context too long/i.test(message);
|
||||||
|
}
|
||||||
|
|
||||||
private convertMessages(
|
private convertMessages(
|
||||||
messages: Message[],
|
messages: Message[],
|
||||||
systemPrompt?: string,
|
systemPrompt?: string,
|
||||||
|
|||||||
@@ -60,6 +60,33 @@ export class LocalBrowserLLMProvider implements LLMProvider {
|
|||||||
return 'prompts/system_compact.md';
|
return 'prompts/system_compact.md';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getContextWindow(): number {
|
||||||
|
return this.getConfiguredContextLength();
|
||||||
|
}
|
||||||
|
|
||||||
|
getReservedOutputTokens(): number {
|
||||||
|
const contextWindow = this.getConfiguredContextLength();
|
||||||
|
return Math.max(1024, Math.min(4096, Math.floor(contextWindow * 0.1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async estimateHistoryTokens(
|
||||||
|
messages: Message[],
|
||||||
|
systemPrompt?: string,
|
||||||
|
tools?: OpenAIToolDefinition[],
|
||||||
|
): Promise<number> {
|
||||||
|
const inference = await this.ensureInference();
|
||||||
|
const prompt = this.renderPrompt(messages, systemPrompt, tools);
|
||||||
|
return inference.sizeInTokens(prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
isContextTooLongError(error: unknown): boolean {
|
||||||
|
if (!(error instanceof Error)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return /context|token|maxTokens|kv-cache|too long|overflow/i.test(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
private async ensureInference(): Promise<GemmaInference> {
|
private async ensureInference(): Promise<GemmaInference> {
|
||||||
await LocalLLMModelManager.ensureRuntimeSupported();
|
await LocalLLMModelManager.ensureRuntimeSupported();
|
||||||
if (this.inference) {
|
if (this.inference) {
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { getModelTokenLimits } from './modelTokenLimits';
|
||||||
|
|
||||||
|
describe('modelTokenLimits', () => {
|
||||||
|
it('returns OpenAI limits for supported GPT models', () => {
|
||||||
|
expect(getModelTokenLimits('gpt-5.2')).toEqual({
|
||||||
|
contextWindow: 400_000,
|
||||||
|
maxOutputTokens: 128_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getModelTokenLimits('gpt-4o')).toEqual({
|
||||||
|
contextWindow: 128_000,
|
||||||
|
maxOutputTokens: 16_384,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns Claude limits for supported direct and OpenRouter aliases', () => {
|
||||||
|
expect(getModelTokenLimits('claude-sonnet-4.6')).toEqual({
|
||||||
|
contextWindow: 200_000,
|
||||||
|
reservedOutputTokens: 8_192,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getModelTokenLimits('anthropic/claude-opus-4.6')).toEqual({
|
||||||
|
contextWindow: 200_000,
|
||||||
|
reservedOutputTokens: 8_192,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches snapshot-style suffixes by prefix', () => {
|
||||||
|
expect(getModelTokenLimits('gpt-5.2-2025-12-11')).toEqual({
|
||||||
|
contextWindow: 400_000,
|
||||||
|
maxOutputTokens: 128_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getModelTokenLimits('anthropic/claude-sonnet-4.6-20260101')).toEqual({
|
||||||
|
contextWindow: 200_000,
|
||||||
|
reservedOutputTokens: 8_192,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns Gemini limits for supported Gemini models', () => {
|
||||||
|
expect(getModelTokenLimits('gemini-2.5-flash')).toEqual({
|
||||||
|
contextWindow: 1_048_576,
|
||||||
|
maxOutputTokens: 65_536,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for unknown models', () => {
|
||||||
|
expect(getModelTokenLimits('custom-company-model')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
export interface ModelTokenLimits {
|
||||||
|
contextWindow: number;
|
||||||
|
maxOutputTokens?: number;
|
||||||
|
reservedOutputTokens?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODEL_TOKEN_LIMITS: Record<string, ModelTokenLimits> = {
|
||||||
|
// OpenAI official model pages / compare docs
|
||||||
|
'gpt-5.2': { contextWindow: 400_000, maxOutputTokens: 128_000 },
|
||||||
|
'gpt-5-mini': { contextWindow: 400_000, maxOutputTokens: 128_000 },
|
||||||
|
'gpt-5-nano': { contextWindow: 400_000, maxOutputTokens: 128_000 },
|
||||||
|
'gpt-5': { contextWindow: 400_000, maxOutputTokens: 128_000 },
|
||||||
|
'gpt-4o': { contextWindow: 128_000, maxOutputTokens: 16_384 },
|
||||||
|
|
||||||
|
// GPT-5.4 family is present in current OpenAI docs, but exact limit pages were not surfaced.
|
||||||
|
// Use GPT-5 family limits as a best-effort alias until exact per-model docs are available.
|
||||||
|
'gpt-5.4': { contextWindow: 400_000, maxOutputTokens: 128_000 },
|
||||||
|
'gpt-5.4-mini': { contextWindow: 400_000, maxOutputTokens: 128_000 },
|
||||||
|
'gpt-5.4-nano': { contextWindow: 400_000, maxOutputTokens: 128_000 },
|
||||||
|
|
||||||
|
// Claude official docs: 200k standard context window across these families.
|
||||||
|
// Anthropic docs do not expose a simple per-model max output token table for these aliases,
|
||||||
|
// so we keep a conservative preflight reserve instead of claiming an exact output maximum.
|
||||||
|
'claude-sonnet-4.6': { contextWindow: 200_000, reservedOutputTokens: 8_192 },
|
||||||
|
'claude-opus-4.6': { contextWindow: 200_000, reservedOutputTokens: 8_192 },
|
||||||
|
'claude-sonnet-4.5': { contextWindow: 200_000, reservedOutputTokens: 8_192 },
|
||||||
|
'claude-opus-4.5': { contextWindow: 200_000, reservedOutputTokens: 8_192 },
|
||||||
|
'claude-sonnet-4': { contextWindow: 200_000, reservedOutputTokens: 8_192 },
|
||||||
|
'claude-opus-4.1': { contextWindow: 200_000, reservedOutputTokens: 8_192 },
|
||||||
|
'anthropic/claude-sonnet-4.6': { contextWindow: 200_000, reservedOutputTokens: 8_192 },
|
||||||
|
'anthropic/claude-opus-4.6': { contextWindow: 200_000, reservedOutputTokens: 8_192 },
|
||||||
|
'anthropic/claude-sonnet-4.5': { contextWindow: 200_000, reservedOutputTokens: 8_192 },
|
||||||
|
'anthropic/claude-opus-4.5': { contextWindow: 200_000, reservedOutputTokens: 8_192 },
|
||||||
|
'anthropic/claude-sonnet-4': { contextWindow: 200_000, reservedOutputTokens: 8_192 },
|
||||||
|
'anthropic/claude-opus-4.1': { contextWindow: 200_000, reservedOutputTokens: 8_192 },
|
||||||
|
|
||||||
|
// Gemini official model docs
|
||||||
|
'gemini-2.5-flash': { contextWindow: 1_048_576, maxOutputTokens: 65_536 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getModelTokenLimits(model: string): ModelTokenLimits | undefined {
|
||||||
|
const exact = MODEL_TOKEN_LIMITS[model];
|
||||||
|
if (exact) {
|
||||||
|
return exact;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = Object.keys(MODEL_TOKEN_LIMITS).find(key => model.startsWith(`${key}-`));
|
||||||
|
return prefix ? MODEL_TOKEN_LIMITS[prefix] : undefined;
|
||||||
|
}
|
||||||
@@ -313,6 +313,28 @@
|
|||||||
color: #909090;
|
color: #909090;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-divider-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-divider-banner-line {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(90deg, rgba(102, 102, 102, 0.2) 0%, rgba(112, 112, 112, 0.55) 50%, rgba(102, 102, 102, 0.2) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-divider-banner-label {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: #8c8c8c;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* Abort link styling */
|
/* Abort link styling */
|
||||||
.abort-link {
|
.abort-link {
|
||||||
background: none !important;
|
background: none !important;
|
||||||
@@ -326,7 +348,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.abort-link:hover {
|
.abort-link:hover {
|
||||||
color: #9b88ff !important;
|
color: #5a9fd4 !important;
|
||||||
text-decoration: none !important;
|
text-decoration: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,28 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
import ChatBox from './ChatBox';
|
import ChatBox from './ChatBox';
|
||||||
import { I18nContext } from '../i18n/I18nProvider';
|
import { I18nContext } from '../i18n/I18nProvider';
|
||||||
import type { ResolvedLocaleCode } from '../i18n/types';
|
import type { ResolvedLocaleCode } from '../i18n/types';
|
||||||
import { translate } from '../i18n/translate';
|
import { translate } from '../i18n/translate';
|
||||||
|
|
||||||
|
const {
|
||||||
|
agentCoreMock,
|
||||||
|
processUserMessageMock,
|
||||||
|
processStreamMock,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
agentCoreMock: {
|
||||||
|
setLLMProvider: vi.fn(),
|
||||||
|
getLLMProvider: vi.fn(() => ({ getPreferredSystemPromptPath: vi.fn() })),
|
||||||
|
abortCurrentRequest: vi.fn(),
|
||||||
|
getAgentState: vi.fn(() => ({ getMessages: vi.fn(() => []) })),
|
||||||
|
compactConversation: vi.fn(async () => ({ changed: true, compactedConversation: 'summary' })),
|
||||||
|
shouldCompactBeforeNextTurn: vi.fn(async () => false),
|
||||||
|
},
|
||||||
|
processUserMessageMock: vi.fn(),
|
||||||
|
processStreamMock: vi.fn(async () => ''),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('./chat', () => ({
|
vi.mock('./chat', () => ({
|
||||||
UserMessage: ({ content }: { content: string }) => <div>{content}</div>,
|
UserMessage: ({ content }: { content: string }) => <div>{content}</div>,
|
||||||
AssistantMessage: ({ content }: { content: string }) => <div>{content}</div>,
|
AssistantMessage: ({ content }: { content: string }) => <div>{content}</div>,
|
||||||
@@ -13,12 +30,7 @@ vi.mock('./chat', () => ({
|
|||||||
|
|
||||||
vi.mock('../agent/core/AgentCore', () => ({
|
vi.mock('../agent/core/AgentCore', () => ({
|
||||||
AgentCore: {
|
AgentCore: {
|
||||||
instance: () => ({
|
instance: () => agentCoreMock,
|
||||||
setLLMProvider: vi.fn(),
|
|
||||||
getLLMProvider: vi.fn(),
|
|
||||||
abortCurrentRequest: vi.fn(),
|
|
||||||
getAgentState: vi.fn(() => ({ getMessages: vi.fn(() => []) })),
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -66,18 +78,29 @@ vi.mock('../util/chatUtil', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../util/messageFilter/UserMessageFilter', () => ({
|
vi.mock('../util/messageFilter/UserMessageFilter', () => ({
|
||||||
processUserMessage: vi.fn(),
|
processUserMessage: processUserMessageMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../hooks/useStreamProcessor', () => ({
|
vi.mock('../hooks/useStreamProcessor', () => ({
|
||||||
useStreamProcessor: () => ({
|
useStreamProcessor: () => ({
|
||||||
abortController: null,
|
abortController: null,
|
||||||
processStream: vi.fn(),
|
processStream: processStreamMock,
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../utils/chatMessageUtils', () => ({
|
vi.mock('../utils/chatMessageUtils', () => ({
|
||||||
createMessage: vi.fn(),
|
createMessage: vi.fn((role: 'user' | 'assistant', content: string) => ({
|
||||||
|
id: `${role}-${content}`,
|
||||||
|
role,
|
||||||
|
content,
|
||||||
|
})),
|
||||||
|
createStreamingMessage: vi.fn(() => ({
|
||||||
|
id: 'streaming-message',
|
||||||
|
role: 'assistant',
|
||||||
|
content: '<span class="processing-wave">Thinking...</span> click here to abort.',
|
||||||
|
isStreaming: true,
|
||||||
|
tokenCount: 0,
|
||||||
|
})),
|
||||||
addWelcomeMessage: vi.fn().mockResolvedValue(null),
|
addWelcomeMessage: vi.fn().mockResolvedValue(null),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -134,6 +157,13 @@ describe('ChatBox', () => {
|
|||||||
Element.prototype.scrollIntoView = vi.fn();
|
Element.prototype.scrollIntoView = vi.fn();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
processUserMessageMock.mockReset();
|
||||||
|
processStreamMock.mockClear();
|
||||||
|
agentCoreMock.compactConversation.mockClear();
|
||||||
|
agentCoreMock.shouldCompactBeforeNextTurn.mockResolvedValue(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('renders the English assistant title under en_us', () => {
|
it('renders the English assistant title under en_us', () => {
|
||||||
renderWithLocale('en_us');
|
renderWithLocale('en_us');
|
||||||
|
|
||||||
@@ -151,4 +181,28 @@ describe('ChatBox', () => {
|
|||||||
|
|
||||||
expect(screen.getByRole('heading', { level: 3, name: 'Assistant musical K.G.Studio' })).toBeTruthy();
|
expect(screen.getByRole('heading', { level: 3, name: 'Assistant musical K.G.Studio' })).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows compacting status and completion for /compact', async () => {
|
||||||
|
processUserMessageMock.mockResolvedValue({
|
||||||
|
displayUserMessage: false,
|
||||||
|
sendToLLM: false,
|
||||||
|
finalMessageForLLM: null,
|
||||||
|
pseudoAssistantResponse: null,
|
||||||
|
metadata: {
|
||||||
|
command: 'compact',
|
||||||
|
focus: 'keep the latest work',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithLocale('en_us');
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText('Press Enter to send message, Shift + Enter for new line');
|
||||||
|
fireEvent.change(input, { target: { value: '/compact keep the latest work' } });
|
||||||
|
fireEvent.keyDown(input, { key: 'Enter', shiftKey: false });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(agentCoreMock.compactConversation).toHaveBeenCalled();
|
||||||
|
expect(screen.getByText('Conversation Compacted')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { SystemPrompts } from '../agent/core/SystemPrompts';
|
|||||||
import { clearChatHistoryAndUI, registerClearChatUICallback } from '../util/chatUtil';
|
import { clearChatHistoryAndUI, registerClearChatUICallback } from '../util/chatUtil';
|
||||||
import { processUserMessage } from '../util/messageFilter/UserMessageFilter';
|
import { processUserMessage } from '../util/messageFilter/UserMessageFilter';
|
||||||
import { useStreamProcessor } from '../hooks/useStreamProcessor';
|
import { useStreamProcessor } from '../hooks/useStreamProcessor';
|
||||||
import { createMessage, addWelcomeMessage } from '../utils/chatMessageUtils';
|
import { createMessage, createStreamingMessage, addWelcomeMessage } from '../utils/chatMessageUtils';
|
||||||
import { formatLocalDateTime } from '../util/timeUtil';
|
import { formatLocalDateTime } from '../util/timeUtil';
|
||||||
import { downloadBlob, buildTimestampSuffix } from '../util/miscUtil';
|
import { downloadBlob, buildTimestampSuffix } from '../util/miscUtil';
|
||||||
import { LocalLLMModelManager, type LocalLLMModelState } from '../util/localLLMModelManager';
|
import { LocalLLMModelManager, type LocalLLMModelState } from '../util/localLLMModelManager';
|
||||||
@@ -70,6 +70,7 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
|||||||
|
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const [isCompacting, setIsCompacting] = useState(false);
|
||||||
const [lastUserMessage, setLastUserMessage] = useState<string>('');
|
const [lastUserMessage, setLastUserMessage] = useState<string>('');
|
||||||
const [localModelState, setLocalModelState] = useState<LocalLLMModelState>(LocalLLMModelManager.getState());
|
const [localModelState, setLocalModelState] = useState<LocalLLMModelState>(LocalLLMModelManager.getState());
|
||||||
const [activeProvider, setActiveProvider] = useState<string>('openai');
|
const [activeProvider, setActiveProvider] = useState<string>('openai');
|
||||||
@@ -87,7 +88,7 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
|||||||
const handleExportOptionSelect = (option: string) => {
|
const handleExportOptionSelect = (option: string) => {
|
||||||
if (option === 'Export conversation as JSON') {
|
if (option === 'Export conversation as JSON') {
|
||||||
try {
|
try {
|
||||||
const agentMessages = AgentCore.instance().getAgentState().getMessages();
|
const agentMessages = AgentCore.instance().getAgentState().getFullMessages();
|
||||||
const exportMessages = agentMessages.map((m) => ({
|
const exportMessages = agentMessages.map((m) => ({
|
||||||
id: m.id,
|
id: m.id,
|
||||||
role: m.role,
|
role: m.role,
|
||||||
@@ -105,7 +106,7 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
|||||||
} else if (option === 'Export conversation as Markdown') {
|
} else if (option === 'Export conversation as Markdown') {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const agentMessages = AgentCore.instance().getAgentState().getMessages();
|
const agentMessages = AgentCore.instance().getAgentState().getFullMessages();
|
||||||
const templateUrl = `${import.meta.env.BASE_URL}chat/export_conversation_template.md`;
|
const templateUrl = `${import.meta.env.BASE_URL}chat/export_conversation_template.md`;
|
||||||
const res = await fetch(templateUrl);
|
const res = await fetch(templateUrl);
|
||||||
const template = await res.text();
|
const template = await res.text();
|
||||||
@@ -204,6 +205,7 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
|||||||
changedKeys.includes('general.llm_provider') ||
|
changedKeys.includes('general.llm_provider') ||
|
||||||
changedKeys.includes('general.local_browser.context_length') ||
|
changedKeys.includes('general.local_browser.context_length') ||
|
||||||
changedKeys.some(k => k.startsWith('general.openai.')) ||
|
changedKeys.some(k => k.startsWith('general.openai.')) ||
|
||||||
|
changedKeys.some(k => k.startsWith('general.claude_openrouter.')) ||
|
||||||
changedKeys.some(k => k.startsWith('general.openai_compatible.'))
|
changedKeys.some(k => k.startsWith('general.openai_compatible.'))
|
||||||
) {
|
) {
|
||||||
applyProviderFromConfig();
|
applyProviderFromConfig();
|
||||||
@@ -255,8 +257,78 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
|||||||
clearChatHistoryAndUI(setStatus);
|
clearChatHistoryAndUI(setStatus);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const runCompactionWithStatus = useCallback(async (
|
||||||
|
trigger: 'manual' | 'auto',
|
||||||
|
focus?: string,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
const agentCore = AgentCore.instance();
|
||||||
|
const statusMessage = createMessage('assistant', 'Compacting Conversation');
|
||||||
|
const progressMessage = createStreamingMessage();
|
||||||
|
let progressTokenCount = 0;
|
||||||
|
|
||||||
|
handleMessageAdd(statusMessage);
|
||||||
|
handleMessageAdd(progressMessage);
|
||||||
|
setIsCompacting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await agentCore.compactConversation({
|
||||||
|
trigger,
|
||||||
|
focus,
|
||||||
|
onProgress: () => {
|
||||||
|
progressTokenCount += 1;
|
||||||
|
handleMessageUpdate(progressMessage.id, (msg) => ({
|
||||||
|
...msg,
|
||||||
|
content: `<span class="processing-wave">Processing...</span>${progressTokenCount > 0 ? ` ${progressTokenCount} tokens received.` : ''} click here to abort.`,
|
||||||
|
tokenCount: progressTokenCount,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
handleMessageUpdate(statusMessage.id, (msg) => ({
|
||||||
|
...msg,
|
||||||
|
content: result.changed ? 'Conversation Compacted' : 'Nothing to Compact Yet',
|
||||||
|
}));
|
||||||
|
handleMessageRemove(progressMessage.id);
|
||||||
|
return result.changed;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Conversation compaction failed:', error);
|
||||||
|
handleMessageUpdate(statusMessage.id, (msg) => ({
|
||||||
|
...msg,
|
||||||
|
content: `Compaction failed: ${error instanceof Error ? error.message : 'Unable to compact the conversation.'}`,
|
||||||
|
}));
|
||||||
|
handleMessageRemove(progressMessage.id);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setIsCompacting(false);
|
||||||
|
}
|
||||||
|
}, [handleMessageAdd, handleMessageRemove, handleMessageUpdate]);
|
||||||
|
|
||||||
|
const sendWithCompactionRecovery = useCallback(async (
|
||||||
|
llmInput: string,
|
||||||
|
originalUserMessage: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await streamProcessor.processStream(llmInput, 'USER');
|
||||||
|
} catch (error) {
|
||||||
|
const provider = AgentCore.instance().getLLMProvider();
|
||||||
|
if (provider?.isContextTooLongError?.(error)) {
|
||||||
|
const compacted = await runCompactionWithStatus('auto');
|
||||||
|
if (compacted) {
|
||||||
|
try {
|
||||||
|
await streamProcessor.processStream(llmInput, 'USER');
|
||||||
|
} catch (retryError) {
|
||||||
|
console.error('Retry after compaction failed:', retryError, originalUserMessage);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Failed to process user message:', error, originalUserMessage);
|
||||||
|
}
|
||||||
|
}, [runCompactionWithStatus, streamProcessor]);
|
||||||
|
|
||||||
const handleSend = async () => {
|
const handleSend = async () => {
|
||||||
if (inputValue.trim() && !isProcessing) {
|
if (inputValue.trim() && !isProcessing && !isCompacting) {
|
||||||
const userMessage = inputValue.trim();
|
const userMessage = inputValue.trim();
|
||||||
setLastUserMessage(userMessage);
|
setLastUserMessage(userMessage);
|
||||||
setInputValue('');
|
setInputValue('');
|
||||||
@@ -273,6 +345,14 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
|||||||
handleMessageAdd(pseudoMessage);
|
handleMessageAdd(pseudoMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filterResult.metadata?.command === 'compact') {
|
||||||
|
await runCompactionWithStatus(
|
||||||
|
'manual',
|
||||||
|
typeof filterResult.metadata.focus === 'string' ? filterResult.metadata.focus : undefined,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!filterResult.sendToLLM || !filterResult.finalMessageForLLM) {
|
if (!filterResult.sendToLLM || !filterResult.finalMessageForLLM) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -294,7 +374,12 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Process through stream processor — AgentCore handles the full agentic loop internally
|
// Process through stream processor — AgentCore handles the full agentic loop internally
|
||||||
await streamProcessor.processStream(filterResult.finalMessageForLLM, 'USER');
|
const agentCore = AgentCore.instance();
|
||||||
|
if (await agentCore.shouldCompactBeforeNextTurn(filterResult.finalMessageForLLM)) {
|
||||||
|
await runCompactionWithStatus('auto');
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendWithCompactionRecovery(filterResult.finalMessageForLLM, userMessage);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -333,13 +418,13 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
|||||||
|
|
||||||
// Auto-focus input when processing completes
|
// Auto-focus input when processing completes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isProcessing && textareaRef.current) {
|
if (!isProcessing && !isCompacting && textareaRef.current) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
textareaRef.current?.focus();
|
textareaRef.current?.focus();
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
}, [isProcessing]);
|
}, [isCompacting, isProcessing]);
|
||||||
|
|
||||||
const localRuntimeMessage = localModelState.runtimeSupport.reason;
|
const localRuntimeMessage = localModelState.runtimeSupport.reason;
|
||||||
const hasLocalRuntimeHardFailure = !localModelState.runtimeSupport.supported;
|
const hasLocalRuntimeHardFailure = !localModelState.runtimeSupport.supported;
|
||||||
@@ -448,7 +533,7 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
|||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isProcessing && (
|
{!isProcessing && !isCompacting && (
|
||||||
<div className="chatbox-input-area">
|
<div className="chatbox-input-area">
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
|
|||||||
@@ -93,4 +93,21 @@ describe('AssistantMessage', () => {
|
|||||||
expect(codeElement).toBeInTheDocument();
|
expect(codeElement).toBeInTheDocument();
|
||||||
expect(codeElement).toHaveTextContent('const value = 1;');
|
expect(codeElement).toHaveTextContent('const value = 1;');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders compacting and compacted messages as divider banners', () => {
|
||||||
|
const { rerender, container } = render(
|
||||||
|
<AssistantMessage content="Compacting Conversation" />
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.querySelector('.message-divider-banner')).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText('Compacting Conversation')).toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(<AssistantMessage content="Conversation Compacted" />);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText('Conversation Compacted')).toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(<AssistantMessage content="Nothing to Compact Yet" />);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText('Nothing to Compact Yet')).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ const formatTps = (value?: number): string | null => {
|
|||||||
|
|
||||||
const THINKING_LABEL = 'Thinking...';
|
const THINKING_LABEL = 'Thinking...';
|
||||||
const PROCESSING_LABEL = 'Processing...';
|
const PROCESSING_LABEL = 'Processing...';
|
||||||
|
const COMPACTION_IN_PROGRESS_LABEL = 'Compacting Conversation';
|
||||||
|
const COMPACTION_DONE_LABEL = 'Conversation Compacted';
|
||||||
|
const COMPACTION_EMPTY_LABEL = 'Nothing to Compact Yet';
|
||||||
|
|
||||||
const formatThinkingDuration = (elapsedSeconds: number): string => {
|
const formatThinkingDuration = (elapsedSeconds: number): string => {
|
||||||
if (elapsedSeconds < 60) {
|
if (elapsedSeconds < 60) {
|
||||||
@@ -62,6 +65,9 @@ const AssistantMessage: React.FC<AssistantMessageProps> = ({ content, isStreamin
|
|||||||
const [thinkingElapsedSeconds, setThinkingElapsedSeconds] = useState(0);
|
const [thinkingElapsedSeconds, setThinkingElapsedSeconds] = useState(0);
|
||||||
const processingWaveLabels = [THINKING_LABEL, PROCESSING_LABEL];
|
const processingWaveLabels = [THINKING_LABEL, PROCESSING_LABEL];
|
||||||
const isThinking = isStreaming && content.includes(`<span class="processing-wave">${THINKING_LABEL}</span>`);
|
const isThinking = isStreaming && content.includes(`<span class="processing-wave">${THINKING_LABEL}</span>`);
|
||||||
|
const isCompactionBanner = content === COMPACTION_IN_PROGRESS_LABEL
|
||||||
|
|| content === COMPACTION_DONE_LABEL
|
||||||
|
|| content === COMPACTION_EMPTY_LABEL;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isThinking) {
|
if (!isThinking) {
|
||||||
@@ -83,6 +89,16 @@ const AssistantMessage: React.FC<AssistantMessageProps> = ({ content, isStreamin
|
|||||||
}, [isThinking]);
|
}, [isThinking]);
|
||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
|
if (isCompactionBanner) {
|
||||||
|
return (
|
||||||
|
<div className="message-divider-banner" aria-label={content}>
|
||||||
|
<span className="message-divider-banner-line" aria-hidden="true" />
|
||||||
|
<span className="message-divider-banner-label">{content}</span>
|
||||||
|
<span className="message-divider-banner-line" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Handle special abort link for streaming messages
|
// Handle special abort link for streaming messages
|
||||||
if (isStreaming && onAbort && content.includes('click here to abort')) {
|
if (isStreaming && onAbort && content.includes('click here to abort')) {
|
||||||
const processingWaveMarkup = processingWaveLabels
|
const processingWaveMarkup = processingWaveLabels
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const configState = new Map<string, unknown>([
|
|||||||
['general.language', 'auto'],
|
['general.language', 'auto'],
|
||||||
['general.llm_provider', 'local_browser'],
|
['general.llm_provider', 'local_browser'],
|
||||||
['general.persist_api_keys_non_localhost', false],
|
['general.persist_api_keys_non_localhost', false],
|
||||||
|
['general.auto_compact_threshold_percent', 90],
|
||||||
['general.openai.api_key', ''],
|
['general.openai.api_key', ''],
|
||||||
['general.openai.model', 'gpt-5.4-mini'],
|
['general.openai.model', 'gpt-5.4-mini'],
|
||||||
['general.openai.flex', false],
|
['general.openai.flex', false],
|
||||||
@@ -107,6 +108,7 @@ describe('GeneralSettings', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
configState.set('general.language', 'auto');
|
configState.set('general.language', 'auto');
|
||||||
configState.set('general.local_browser.context_length', 65536);
|
configState.set('general.local_browser.context_length', 65536);
|
||||||
|
configState.set('general.auto_compact_threshold_percent', 90);
|
||||||
configManagerMock.get.mockClear();
|
configManagerMock.get.mockClear();
|
||||||
configManagerMock.set.mockClear();
|
configManagerMock.set.mockClear();
|
||||||
localModelState.isCached = false;
|
localModelState.isCached = false;
|
||||||
@@ -173,6 +175,19 @@ describe('GeneralSettings', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders and persists the auto-compact threshold', async () => {
|
||||||
|
renderSettings();
|
||||||
|
|
||||||
|
const select = await screen.findByLabelText('Auto-Compact Threshold');
|
||||||
|
expect((select as HTMLSelectElement).value).toBe('90');
|
||||||
|
|
||||||
|
fireEvent.change(select, { target: { value: '80' } });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(configManagerMock.set).toHaveBeenCalledWith('general.auto_compact_threshold_percent', 80);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('renders and persists local runtime download URLs', async () => {
|
it('renders and persists local runtime download URLs', async () => {
|
||||||
renderSettings();
|
renderSettings();
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ const GeneralSettings: React.FC = () => {
|
|||||||
const [claudeOpenRouterModel, setClaudeOpenRouterModel] = useState<string>('');
|
const [claudeOpenRouterModel, setClaudeOpenRouterModel] = useState<string>('');
|
||||||
const [openaiFlex, setOpenaiFlex] = useState<boolean>(false);
|
const [openaiFlex, setOpenaiFlex] = useState<boolean>(false);
|
||||||
const [persistApiKeysNonLocalhost, setPersistApiKeysNonLocalhost] = useState<boolean>(false);
|
const [persistApiKeysNonLocalhost, setPersistApiKeysNonLocalhost] = useState<boolean>(false);
|
||||||
|
const [autoCompactThresholdPercent, setAutoCompactThresholdPercent] = useState<80 | 90 | 95>(90);
|
||||||
const [compatibleKey, setCompatibleKey] = useState<string>('');
|
const [compatibleKey, setCompatibleKey] = useState<string>('');
|
||||||
const [compatibleBaseUrl, setCompatibleBaseUrl] = useState<string>('');
|
const [compatibleBaseUrl, setCompatibleBaseUrl] = useState<string>('');
|
||||||
const [compatibleModel, setCompatibleModel] = useState<string>('');
|
const [compatibleModel, setCompatibleModel] = useState<string>('');
|
||||||
@@ -107,6 +108,9 @@ const GeneralSettings: React.FC = () => {
|
|||||||
setOpenaiModel((configManager.get('general.openai.model') as string) || '');
|
setOpenaiModel((configManager.get('general.openai.model') as string) || '');
|
||||||
setOpenaiFlex((configManager.get('general.openai.flex') as boolean) ?? false);
|
setOpenaiFlex((configManager.get('general.openai.flex') as boolean) ?? false);
|
||||||
setPersistApiKeysNonLocalhost((configManager.get('general.persist_api_keys_non_localhost') as boolean) ?? false);
|
setPersistApiKeysNonLocalhost((configManager.get('general.persist_api_keys_non_localhost') as boolean) ?? false);
|
||||||
|
setAutoCompactThresholdPercent(
|
||||||
|
((configManager.get('general.auto_compact_threshold_percent') as 80 | 90 | 95 | undefined) ?? 90),
|
||||||
|
);
|
||||||
setGeminiKey((configManager.get('general.gemini.api_key') as string) || '');
|
setGeminiKey((configManager.get('general.gemini.api_key') as string) || '');
|
||||||
setGeminiModel((configManager.get('general.gemini.model') as string) || '');
|
setGeminiModel((configManager.get('general.gemini.model') as string) || '');
|
||||||
setClaudeKey((configManager.get('general.claude.api_key') as string) || '');
|
setClaudeKey((configManager.get('general.claude.api_key') as string) || '');
|
||||||
@@ -206,6 +210,18 @@ const GeneralSettings: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAutoCompactThresholdChange = async (value: string) => {
|
||||||
|
const parsed = Number(value);
|
||||||
|
const normalized: 80 | 90 | 95 = parsed === 80 || parsed === 95 ? parsed : 90;
|
||||||
|
setAutoCompactThresholdPercent(normalized);
|
||||||
|
try {
|
||||||
|
await configManager.set('general.auto_compact_threshold_percent', normalized);
|
||||||
|
console.log('Auto-compact threshold changed to:', normalized);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save auto-compact threshold:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleGeminiKeyChange = (value: string) => {
|
const handleGeminiKeyChange = (value: string) => {
|
||||||
setGeminiKey(value);
|
setGeminiKey(value);
|
||||||
debouncedSave('general.gemini.api_key', value);
|
debouncedSave('general.gemini.api_key', value);
|
||||||
@@ -407,6 +423,25 @@ const GeneralSettings: React.FC = () => {
|
|||||||
{t('settings.general.persistKeys.help')}
|
{t('settings.general.persistKeys.help')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-item">
|
||||||
|
<label className="settings-label" htmlFor="general-auto-compact-threshold">
|
||||||
|
Auto-Compact Threshold
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="general-auto-compact-threshold"
|
||||||
|
className="settings-select"
|
||||||
|
value={autoCompactThresholdPercent}
|
||||||
|
onChange={(e) => void handleAutoCompactThresholdChange(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="95">Conservative (95%)</option>
|
||||||
|
<option value="90">Standard (90%)</option>
|
||||||
|
<option value="80">Early (80%)</option>
|
||||||
|
</select>
|
||||||
|
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
|
||||||
|
Compact the conversation before the next request when estimated context usage reaches this level.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="settings-group">
|
<div className="settings-group">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface AppConfig {
|
|||||||
language: LanguageSetting;
|
language: LanguageSetting;
|
||||||
llm_provider: 'local_browser' | 'openai' | 'gemini' | 'claude' | 'claude_openrouter' | 'openai_compatible';
|
llm_provider: 'local_browser' | 'openai' | 'gemini' | 'claude' | 'claude_openrouter' | 'openai_compatible';
|
||||||
persist_api_keys_non_localhost: boolean;
|
persist_api_keys_non_localhost: boolean;
|
||||||
|
auto_compact_threshold_percent: 80 | 90 | 95;
|
||||||
local_browser: {
|
local_browser: {
|
||||||
context_length: 32768 | 65536 | 131072;
|
context_length: 32768 | 65536 | 131072;
|
||||||
model_url: string;
|
model_url: string;
|
||||||
@@ -209,6 +210,7 @@ export class ConfigManager {
|
|||||||
language: 'auto',
|
language: 'auto',
|
||||||
llm_provider: 'local_browser',
|
llm_provider: 'local_browser',
|
||||||
persist_api_keys_non_localhost: false,
|
persist_api_keys_non_localhost: false,
|
||||||
|
auto_compact_threshold_percent: 90,
|
||||||
openai: {
|
openai: {
|
||||||
api_key: '',
|
api_key: '',
|
||||||
flex: false,
|
flex: false,
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ export const useStreamProcessor = (options: StreamProcessorOptions): StreamProce
|
|||||||
isStreaming: false,
|
isStreaming: false,
|
||||||
tokenCount: undefined
|
tokenCount: undefined
|
||||||
}));
|
}));
|
||||||
return '';
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
setAbortController(null);
|
setAbortController(null);
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
|
|||||||
@@ -287,14 +287,29 @@ describe('processUserMessage slash commands', () => {
|
|||||||
expect(result.pseudoAssistantResponse).toContain('chat/hotkeys.md');
|
expect(result.pseudoAssistantResponse).toContain('chat/hotkeys.md');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('parses /compact and returns manual compaction metadata', async () => {
|
||||||
|
const result = await processUserMessage('/compact keep the current chord decisions');
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
displayUserMessage: false,
|
||||||
|
sendToLLM: false,
|
||||||
|
finalMessageForLLM: null,
|
||||||
|
pseudoAssistantResponse: null,
|
||||||
|
metadata: {
|
||||||
|
command: 'compact',
|
||||||
|
focus: 'keep the current chord decisions',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('lists the new hotkeys commands for unknown slash commands', async () => {
|
it('lists the new hotkeys commands for unknown slash commands', async () => {
|
||||||
const result = await processUserMessage('/unknown foo');
|
const result = await processUserMessage('/unknown foo');
|
||||||
|
|
||||||
expect(storeState.setStatus).toHaveBeenCalledWith(
|
expect(storeState.setStatus).toHaveBeenCalledWith(
|
||||||
'Unknown command: /unknown. Available commands: /clear, /welcome, /help, /hotkeys, /hotkey'
|
'Unknown command: /unknown. Available commands: /clear, /welcome, /help, /hotkeys, /hotkey, /compact'
|
||||||
);
|
);
|
||||||
expect(result.pseudoAssistantResponse).toBe(
|
expect(result.pseudoAssistantResponse).toBe(
|
||||||
'Unknown command: /unknown foo.\nAvailable commands: /clear, /welcome, /help, /hotkeys, /hotkey'
|
'Unknown command: /unknown foo.\nAvailable commands: /clear, /welcome, /help, /hotkeys, /hotkey, /compact'
|
||||||
);
|
);
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
displayUserMessage: false,
|
displayUserMessage: false,
|
||||||
|
|||||||
@@ -218,9 +218,22 @@ export async function processUserMessage(originalMessage: string): Promise<UserM
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case '/compact': {
|
||||||
|
return {
|
||||||
|
displayUserMessage: false,
|
||||||
|
sendToLLM: false,
|
||||||
|
finalMessageForLLM: null,
|
||||||
|
pseudoAssistantResponse: null,
|
||||||
|
metadata: {
|
||||||
|
command: 'compact',
|
||||||
|
focus: argString.trim() || undefined,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
const { setStatus } = useProjectStore.getState();
|
const { setStatus } = useProjectStore.getState();
|
||||||
const help = 'Available commands: /clear, /welcome, /help, /hotkeys, /hotkey';
|
const help = 'Available commands: /clear, /welcome, /help, /hotkeys, /hotkey, /compact';
|
||||||
setStatus(`Unknown command: ${command}. ${help}`);
|
setStatus(`Unknown command: ${command}. ${help}`);
|
||||||
return {
|
return {
|
||||||
displayUserMessage: false,
|
displayUserMessage: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user