feat(viking): add local server management for privacy-first deployment

Backend (Rust):
- viking_commands.rs: Tauri commands for server status/start/stop/restart
- memory/mod.rs: Memory module exports
- memory/context_builder.rs: Context building with memory injection
- memory/extractor.rs: Memory extraction from conversations
- llm/mod.rs: LLM integration for memory summarization

Frontend (TypeScript):
- context-builder.ts: Context building with OpenViking integration
- viking-client.ts: OpenViking API client
- viking-local.ts: Local storage fallback when Viking unavailable
- viking-memory-adapter.ts: Memory extraction and persistence

Features:
- Multi-mode adapter (local/sidecar/remote) with auto-detection
- Privacy-first: all data stored in ~/.openviking/, server only on 127.0.0.1
- Graceful degradation when local server unavailable
- Context compaction with memory flush before compression

Tests: 21 passing (viking-adapter.test.ts)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
iven
2026-03-16 09:59:14 +08:00
parent 26e64a3fff
commit 134798c430
10 changed files with 3378 additions and 0 deletions

View File

@@ -0,0 +1,446 @@
/**
* Tests for VikingAdapter and ContextBuilder
*
* Tests the ZCLAW ↔ OpenViking integration layer with mocked HTTP responses.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { VikingHttpClient, VikingError } from '../../desktop/src/lib/viking-client';
import {
VikingAdapter,
VIKING_NS,
resetVikingAdapter,
} from '../../desktop/src/lib/viking-adapter';
import {
ContextBuilder,
resetContextBuilder,
} from '../../desktop/src/lib/context-builder';
// === Mock fetch globally ===
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
function mockJsonResponse(data: unknown, status = 200) {
return {
ok: status >= 200 && status < 300,
status,
statusText: status === 200 ? 'OK' : 'Error',
json: () => Promise.resolve(data),
text: () => Promise.resolve(JSON.stringify(data)),
};
}
// === VikingHttpClient Tests ===
describe('VikingHttpClient', () => {
let client: VikingHttpClient;
beforeEach(() => {
client = new VikingHttpClient('http://localhost:1933');
mockFetch.mockReset();
});
describe('status', () => {
it('returns server status on success', async () => {
mockFetch.mockResolvedValueOnce(
mockJsonResponse({ status: 'ok', version: '0.1.18' })
);
const result = await client.status();
expect(result.status).toBe('ok');
expect(result.version).toBe('0.1.18');
});
it('throws VikingError on server error', async () => {
mockFetch.mockResolvedValueOnce(
mockJsonResponse({ error: 'internal' }, 500)
);
await expect(client.status()).rejects.toThrow(VikingError);
});
});
describe('isAvailable', () => {
it('returns true when server responds ok', async () => {
mockFetch.mockResolvedValueOnce(
mockJsonResponse({ status: 'ok' })
);
expect(await client.isAvailable()).toBe(true);
});
it('returns false when server is down', async () => {
mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED'));
expect(await client.isAvailable()).toBe(false);
});
});
describe('find', () => {
it('sends correct find request', async () => {
mockFetch.mockResolvedValueOnce(
mockJsonResponse({
results: [
{ uri: 'viking://user/memories/preferences/lang', score: 0.9, content: '中文', level: 'L1' },
],
})
);
const results = await client.find('language preference', {
scope: 'viking://user/memories/',
level: 'L1',
limit: 10,
});
expect(results).toHaveLength(1);
expect(results[0].score).toBe(0.9);
expect(results[0].content).toBe('中文');
// Verify request body
const callBody = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(callBody.query).toBe('language preference');
expect(callBody.scope).toBe('viking://user/memories/');
expect(callBody.level).toBe('L1');
});
});
describe('addResource', () => {
it('sends correct add request', async () => {
mockFetch.mockResolvedValueOnce(
mockJsonResponse({ uri: 'viking://user/memories/preferences/lang', status: 'ok' })
);
const result = await client.addResource(
'viking://user/memories/preferences/lang',
'用户偏好中文回复',
{ metadata: { type: 'preference' }, wait: true }
);
expect(result.status).toBe('ok');
const callBody = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(callBody.uri).toBe('viking://user/memories/preferences/lang');
expect(callBody.content).toBe('用户偏好中文回复');
expect(callBody.wait).toBe(true);
});
});
describe('extractMemories', () => {
it('sends session content for extraction', async () => {
mockFetch.mockResolvedValueOnce(
mockJsonResponse({
memories: [
{
category: 'user_preference',
content: '用户喜欢简洁回答',
tags: ['communication'],
importance: 8,
suggestedUri: 'viking://user/memories/preferences/communication',
},
{
category: 'agent_lesson',
content: '使用飞书API前需验证token',
tags: ['feishu', 'api'],
importance: 7,
suggestedUri: 'viking://agent/zclaw/memories/lessons/feishu_token',
},
],
summary: '讨论了飞书集成和回复风格偏好',
})
);
const result = await client.extractMemories(
'[user]: 帮我集成飞书API\n[assistant]: 好的,我来...',
'zclaw-main'
);
expect(result.memories).toHaveLength(2);
expect(result.memories[0].category).toBe('user_preference');
expect(result.memories[1].category).toBe('agent_lesson');
expect(result.summary).toContain('飞书');
});
});
});
// === VikingAdapter Tests ===
describe('VikingAdapter', () => {
let adapter: VikingAdapter;
beforeEach(() => {
resetVikingAdapter();
adapter = new VikingAdapter({ serverUrl: 'http://localhost:1933' });
mockFetch.mockReset();
});
describe('saveUserPreference', () => {
it('saves to correct viking:// URI', async () => {
mockFetch.mockResolvedValueOnce(
mockJsonResponse({ uri: 'viking://user/memories/preferences/language', status: 'ok' })
);
await adapter.saveUserPreference('language', '中文优先');
const callBody = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(callBody.uri).toBe('viking://user/memories/preferences/language');
expect(callBody.content).toBe('中文优先');
expect(callBody.metadata.type).toBe('preference');
});
});
describe('saveAgentLesson', () => {
it('saves to agent-specific lessons URI', async () => {
mockFetch.mockResolvedValueOnce(
mockJsonResponse({ uri: 'viking://agent/zclaw-main/memories/lessons_learned/123', status: 'ok' })
);
await adapter.saveAgentLesson('zclaw-main', '飞书API需要先验证token', ['feishu', 'auth']);
const callBody = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(callBody.uri).toContain('viking://agent/zclaw-main/memories/lessons_learned/');
expect(callBody.content).toBe('飞书API需要先验证token');
expect(callBody.metadata.tags).toBe('feishu,auth');
});
});
describe('buildEnhancedContext', () => {
it('performs L0 scan then L1 load', async () => {
// Mock L0 user memories search
mockFetch.mockResolvedValueOnce(
mockJsonResponse({
results: [
{ uri: 'viking://user/memories/preferences/lang', score: 0.85, content: '中文', level: 'L0' },
{ uri: 'viking://user/memories/facts/project', score: 0.7, content: '飞书集成', level: 'L0' },
],
})
);
// Mock L0 agent memories search
mockFetch.mockResolvedValueOnce(
mockJsonResponse({
results: [
{ uri: 'viking://agent/zclaw/memories/lessons/feishu', score: 0.8, content: 'API认证', level: 'L0' },
],
})
);
// Mock L1 reads for relevant items (score >= 0.5)
mockFetch.mockResolvedValueOnce(
mockJsonResponse({ content: '用户偏好中文回复,简洁风格' })
);
mockFetch.mockResolvedValueOnce(
mockJsonResponse({ content: '飞书API需要先验证app_id和app_secret' })
);
mockFetch.mockResolvedValueOnce(
mockJsonResponse({ content: '调用飞书API前确保token未过期' })
);
const result = await adapter.buildEnhancedContext('帮我处理飞书集成', 'zclaw');
expect(result.memories.length).toBeGreaterThan(0);
expect(result.totalTokens).toBeGreaterThan(0);
expect(result.systemPromptAddition).toContain('记忆');
});
it('returns empty context when Viking is unavailable', async () => {
// Both L0 searches fail
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
const result = await adapter.buildEnhancedContext('test message', 'zclaw');
expect(result.memories).toHaveLength(0);
expect(result.totalTokens).toBe(0);
});
});
describe('extractAndSaveMemories', () => {
it('extracts and saves memories to correct categories', async () => {
// Mock extraction call
mockFetch.mockResolvedValueOnce(
mockJsonResponse({
memories: [
{
category: 'user_preference',
content: '用户喜欢TypeScript',
tags: ['coding'],
importance: 8,
suggestedUri: 'viking://user/memories/preferences/coding',
},
{
category: 'agent_lesson',
content: 'Vitest配置需要在tsconfig中设置paths',
tags: ['testing', 'vitest'],
importance: 7,
suggestedUri: 'viking://agent/zclaw/memories/lessons/vitest_config',
},
],
summary: '讨论了TypeScript测试配置',
})
);
// Mock save calls (2 saves)
mockFetch.mockResolvedValueOnce(
mockJsonResponse({ uri: 'viking://user/memories/preferences/coding', status: 'ok' })
);
mockFetch.mockResolvedValueOnce(
mockJsonResponse({ uri: 'viking://agent/zclaw/memories/lessons/123', status: 'ok' })
);
const result = await adapter.extractAndSaveMemories(
[
{ role: 'user', content: '帮我配置Vitest' },
{ role: 'assistant', content: '好的需要在tsconfig中...' },
],
'zclaw'
);
expect(result.saved).toBe(2);
expect(result.userMemories).toBe(1);
expect(result.agentMemories).toBe(1);
});
it('handles extraction failure gracefully', async () => {
mockFetch.mockRejectedValueOnce(new Error('Server error'));
const result = await adapter.extractAndSaveMemories(
[{ role: 'user', content: 'test' }],
'zclaw'
);
expect(result.saved).toBe(0);
});
});
describe('VIKING_NS', () => {
it('generates correct namespace URIs', () => {
expect(VIKING_NS.userPreferences).toBe('viking://user/memories/preferences');
expect(VIKING_NS.agentLessons('zclaw')).toBe('viking://agent/zclaw/memories/lessons_learned');
expect(VIKING_NS.agentIdentity('zclaw')).toBe('viking://agent/zclaw/identity');
});
});
});
// === ContextBuilder Tests ===
describe('ContextBuilder', () => {
let builder: ContextBuilder;
beforeEach(() => {
resetContextBuilder();
resetVikingAdapter();
builder = new ContextBuilder({ enabled: true });
mockFetch.mockReset();
});
describe('buildContext', () => {
it('returns empty prompt when disabled', async () => {
builder.updateConfig({ enabled: false });
const result = await builder.buildContext('test', 'zclaw');
expect(result.systemPrompt).toBe('');
expect(result.tokensUsed).toBe(0);
});
it('returns empty prompt when Viking is unavailable', async () => {
// isAvailable check fails
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
const result = await builder.buildContext('test', 'zclaw');
expect(result.memoriesInjected).toBe(0);
});
});
describe('checkAndCompact', () => {
it('returns null when under threshold', async () => {
const messages = [
{ role: 'user' as const, content: '你好' },
{ role: 'assistant' as const, content: '你好!有什么可以帮你?' },
];
const result = await builder.checkAndCompact(messages, 'zclaw');
expect(result).toBeNull();
});
it('compacts and flushes memory when over threshold', async () => {
// Create messages that exceed the threshold
const longContent = '这是一段很长的对话内容。'.repeat(500);
const messages = [
{ role: 'user' as const, content: longContent },
{ role: 'assistant' as const, content: longContent },
{ role: 'user' as const, content: longContent },
{ role: 'assistant' as const, content: longContent },
{ role: 'user' as const, content: '最近的消息1' },
{ role: 'assistant' as const, content: '最近的消息2' },
{ role: 'user' as const, content: '最近的消息3' },
{ role: 'assistant' as const, content: '最近的消息4' },
{ role: 'user' as const, content: '最近的消息5' },
{ role: 'assistant' as const, content: '最近的回复5' },
];
// Mock memory flush extraction call
mockFetch.mockResolvedValueOnce(
mockJsonResponse({
memories: [
{
category: 'user_fact',
content: '讨论了长文本处理',
tags: ['text'],
importance: 5,
suggestedUri: 'viking://user/memories/facts/text',
},
],
summary: '长文本处理讨论',
})
);
// Mock save call for flushed memory
mockFetch.mockResolvedValueOnce(
mockJsonResponse({ uri: 'viking://user/memories/facts/text/123', status: 'ok' })
);
builder.updateConfig({ compactionThresholdTokens: 100 }); // Low threshold for test
const result = await builder.checkAndCompact(messages, 'zclaw');
expect(result).not.toBeNull();
expect(result!.compactedMessages.length).toBeLessThan(messages.length);
expect(result!.compactedMessages[0].content).toContain('对话摘要');
// Recent messages preserved
expect(result!.compactedMessages.some(m => m.content === '最近的回复5')).toBe(true);
});
});
describe('extractMemoriesFromConversation', () => {
it('skips extraction when disabled', async () => {
builder.updateConfig({ autoExtractOnComplete: false });
const result = await builder.extractMemoriesFromConversation(
[
{ role: 'user', content: '你好' },
{ role: 'assistant', content: '你好!' },
],
'zclaw'
);
expect(result.saved).toBe(0);
});
it('skips extraction for short conversations', async () => {
const result = await builder.extractMemoriesFromConversation(
[{ role: 'user', content: '你好' }],
'zclaw'
);
expect(result.saved).toBe(0);
});
});
describe('configuration', () => {
it('can update and read config', () => {
builder.updateConfig({ maxMemoryTokens: 4000, enabled: false });
const config = builder.getConfig();
expect(config.maxMemoryTokens).toBe(4000);
expect(config.enabled).toBe(false);
expect(builder.isEnabled()).toBe(false);
});
});
});