Add unified backend abstraction layer supporting multiple LLM providers: Backend (Go): - New backends package with interface, registry, and adapters - Ollama adapter wrapping existing functionality - OpenAI-compatible adapter for llama.cpp and LM Studio - Unified API routes under /api/v1/ai/* - SSE to NDJSON streaming conversion for OpenAI backends - Auto-discovery of backends on default ports Frontend (Svelte 5): - New backendsState store for backend management - Unified LLM client routing through backend API - AI Providers tab combining Backends and Models sub-tabs - Backend-aware chat streaming (uses appropriate client) - Model name display for non-Ollama backends in top nav - Persist and restore last selected backend Key features: - Switch between backends without restart - Conditional UI based on backend capabilities - Models tab only visible when Ollama active - llama.cpp/LM Studio show loaded model name
215 lines
6.5 KiB
TypeScript
215 lines
6.5 KiB
TypeScript
/**
|
|
* Summarizer utility tests
|
|
*
|
|
* Tests the pure functions for conversation summarization
|
|
*/
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
selectMessagesForSummarization,
|
|
calculateTokenSavings,
|
|
createSummaryRecord,
|
|
shouldSummarize,
|
|
formatSummaryAsContext
|
|
} from './summarizer';
|
|
import type { MessageNode } from '$lib/types/chat';
|
|
|
|
// Helper to create message nodes
|
|
function createMessageNode(
|
|
role: 'user' | 'assistant' | 'system',
|
|
content: string,
|
|
id?: string
|
|
): MessageNode {
|
|
return {
|
|
id: id || crypto.randomUUID(),
|
|
parentId: null,
|
|
childIds: [],
|
|
createdAt: new Date(),
|
|
message: {
|
|
role,
|
|
content
|
|
}
|
|
};
|
|
}
|
|
|
|
describe('selectMessagesForSummarization', () => {
|
|
it('returns empty toSummarize when messages <= preserveCount', () => {
|
|
const messages = [
|
|
createMessageNode('user', 'Hi'),
|
|
createMessageNode('assistant', 'Hello'),
|
|
createMessageNode('user', 'How are you?'),
|
|
createMessageNode('assistant', 'Good')
|
|
];
|
|
|
|
const result = selectMessagesForSummarization(messages, 1000, 4);
|
|
|
|
expect(result.toSummarize).toHaveLength(0);
|
|
expect(result.toKeep).toHaveLength(4);
|
|
});
|
|
|
|
it('keeps recent messages and marks older for summarization', () => {
|
|
const messages = [
|
|
createMessageNode('user', 'Message 1'),
|
|
createMessageNode('assistant', 'Response 1'),
|
|
createMessageNode('user', 'Message 2'),
|
|
createMessageNode('assistant', 'Response 2'),
|
|
createMessageNode('user', 'Message 3'),
|
|
createMessageNode('assistant', 'Response 3'),
|
|
createMessageNode('user', 'Message 4'),
|
|
createMessageNode('assistant', 'Response 4')
|
|
];
|
|
|
|
const result = selectMessagesForSummarization(messages, 1000, 4);
|
|
|
|
expect(result.toSummarize).toHaveLength(4);
|
|
expect(result.toKeep).toHaveLength(4);
|
|
expect(result.toSummarize[0].message.content).toBe('Message 1');
|
|
expect(result.toKeep[0].message.content).toBe('Message 3');
|
|
});
|
|
|
|
it('preserves system messages in toKeep', () => {
|
|
const messages = [
|
|
createMessageNode('system', 'System prompt'),
|
|
createMessageNode('user', 'Message 1'),
|
|
createMessageNode('assistant', 'Response 1'),
|
|
createMessageNode('user', 'Message 2'),
|
|
createMessageNode('assistant', 'Response 2'),
|
|
createMessageNode('user', 'Message 3'),
|
|
createMessageNode('assistant', 'Response 3')
|
|
];
|
|
|
|
const result = selectMessagesForSummarization(messages, 1000, 4);
|
|
|
|
// System message should be in toKeep even though it's at the start
|
|
expect(result.toKeep.some((m) => m.message.role === 'system')).toBe(true);
|
|
expect(result.toSummarize.every((m) => m.message.role !== 'system')).toBe(true);
|
|
});
|
|
|
|
it('uses default preserveCount of 4', () => {
|
|
const messages = [
|
|
createMessageNode('user', 'M1'),
|
|
createMessageNode('assistant', 'R1'),
|
|
createMessageNode('user', 'M2'),
|
|
createMessageNode('assistant', 'R2'),
|
|
createMessageNode('user', 'M3'),
|
|
createMessageNode('assistant', 'R3'),
|
|
createMessageNode('user', 'M4'),
|
|
createMessageNode('assistant', 'R4')
|
|
];
|
|
|
|
const result = selectMessagesForSummarization(messages, 1000);
|
|
|
|
expect(result.toKeep).toHaveLength(4);
|
|
});
|
|
|
|
it('handles empty messages array', () => {
|
|
const result = selectMessagesForSummarization([], 1000);
|
|
|
|
expect(result.toSummarize).toHaveLength(0);
|
|
expect(result.toKeep).toHaveLength(0);
|
|
});
|
|
|
|
it('handles single message', () => {
|
|
const messages = [createMessageNode('user', 'Only message')];
|
|
|
|
const result = selectMessagesForSummarization(messages, 1000, 4);
|
|
|
|
expect(result.toSummarize).toHaveLength(0);
|
|
expect(result.toKeep).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('calculateTokenSavings', () => {
|
|
it('calculates positive savings for longer original', () => {
|
|
const originalMessages = [
|
|
createMessageNode('user', 'This is a longer message with more words'),
|
|
createMessageNode('assistant', 'This is also a longer response with content')
|
|
];
|
|
|
|
const shortSummary = 'Brief summary.';
|
|
const savings = calculateTokenSavings(originalMessages, shortSummary);
|
|
|
|
expect(savings).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('returns zero when summary is longer', () => {
|
|
const originalMessages = [createMessageNode('user', 'Hi')];
|
|
|
|
const longSummary =
|
|
'This is a very long summary that is much longer than the original message which was just a simple greeting.';
|
|
const savings = calculateTokenSavings(originalMessages, longSummary);
|
|
|
|
expect(savings).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('createSummaryRecord', () => {
|
|
it('creates a valid summary record', () => {
|
|
const record = createSummaryRecord('conv-123', 'This is the summary', 10, 500);
|
|
|
|
expect(record.id).toBeDefined();
|
|
expect(record.conversationId).toBe('conv-123');
|
|
expect(record.summary).toBe('This is the summary');
|
|
expect(record.originalMessageCount).toBe(10);
|
|
expect(record.tokensSaved).toBe(500);
|
|
expect(record.summarizedAt).toBeInstanceOf(Date);
|
|
});
|
|
|
|
it('generates unique IDs', () => {
|
|
const record1 = createSummaryRecord('conv-1', 'Summary 1', 5, 100);
|
|
const record2 = createSummaryRecord('conv-2', 'Summary 2', 5, 100);
|
|
|
|
expect(record1.id).not.toBe(record2.id);
|
|
});
|
|
});
|
|
|
|
describe('shouldSummarize', () => {
|
|
it('returns false when message count is too low', () => {
|
|
expect(shouldSummarize(8000, 10000, 4)).toBe(false);
|
|
expect(shouldSummarize(8000, 10000, 5)).toBe(false);
|
|
});
|
|
|
|
it('returns false when summarizable messages are too few', () => {
|
|
// 6 messages total - 4 preserved = 2 to summarize (minimum)
|
|
// But with < 6 total messages, should return false
|
|
expect(shouldSummarize(8000, 10000, 5)).toBe(false);
|
|
});
|
|
|
|
it('returns true when usage is high and enough messages', () => {
|
|
// 8000/10000 = 80%
|
|
expect(shouldSummarize(8000, 10000, 10)).toBe(true);
|
|
expect(shouldSummarize(9000, 10000, 8)).toBe(true);
|
|
});
|
|
|
|
it('returns false when usage is below 80%', () => {
|
|
expect(shouldSummarize(7000, 10000, 10)).toBe(false);
|
|
expect(shouldSummarize(5000, 10000, 20)).toBe(false);
|
|
});
|
|
|
|
it('returns true at exactly 80%', () => {
|
|
expect(shouldSummarize(8000, 10000, 10)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('formatSummaryAsContext', () => {
|
|
it('formats summary as context prefix', () => {
|
|
const result = formatSummaryAsContext('User asked about weather');
|
|
|
|
expect(result).toBe('[Previous conversation summary: User asked about weather]');
|
|
});
|
|
|
|
it('handles empty summary', () => {
|
|
const result = formatSummaryAsContext('');
|
|
|
|
expect(result).toBe('[Previous conversation summary: ]');
|
|
});
|
|
|
|
it('preserves special characters in summary', () => {
|
|
const result = formatSummaryAsContext('User said "hello" & asked about <code>');
|
|
|
|
expect(result).toContain('"hello"');
|
|
expect(result).toContain('&');
|
|
expect(result).toContain('<code>');
|
|
});
|
|
});
|