Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions src/main/config/config-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export const PROVIDER_PRESETS = {
{ id: 'z-ai/glm-4.7', name: 'GLM-4.7' },
],
keyPlaceholder: 'sk-or-v1-...',
keyHint: 'openrouter.ai/keys 获取',
keyHint: 'Get from openrouter.ai/keys',
},
anthropic: {
name: 'Anthropic',
Expand All @@ -76,7 +76,7 @@ export const PROVIDER_PRESETS = {
{ id: 'claude-haiku-4-5', name: 'claude-haiku-4-5' },
],
keyPlaceholder: 'sk-ant-...',
keyHint: 'console.anthropic.com 获取',
keyHint: 'Get from console.anthropic.com',
},
openai: {
name: 'OpenAI',
Expand All @@ -87,18 +87,18 @@ export const PROVIDER_PRESETS = {
{ id: 'gpt-5.2-mini', name: 'gpt-5.2-mini' },
],
keyPlaceholder: 'sk-...',
keyHint: 'platform.openai.com 获取',
keyHint: 'Get from platform.openai.com',
},
custom: {
name: '更多模型',
name: 'More Models',
baseUrl: 'https://open.bigmodel.cn/api/anthropic',
models: [
{ id: 'glm-4.7', name: 'GLM-4.7' },
{ id: 'glm-4-plus', name: 'GLM-4-Plus' },
{ id: 'glm-4-air', name: 'GLM-4-Air' },
],
keyPlaceholder: 'sk-xxx',
keyHint: '输入你的 API Key',
keyHint: 'Enter your API Key',
},
};

Expand Down
7 changes: 6 additions & 1 deletion src/main/db/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,12 @@ function initializeSchema(database: Database.Database): void {
created_at INTEGER NOT NULL
)
`);


// Create FTS5 virtual table for memory search
database.exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS memory_fts USING fts5(content)
`);

log('[Database] Schema initialized');
}

Expand Down
9 changes: 8 additions & 1 deletion src/main/session/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -611,10 +611,17 @@ export class SessionManager {
}
}

// Clean up pending permissions for this session
for (const [toolUseId, resolver] of this.pendingPermissions.entries()) {
// Resolve pending permissions with 'deny' to unblock any waiters
resolver('deny');
this.pendingPermissions.delete(toolUseId);
}

// Delete from database (messages will be deleted automatically via CASCADE)
this.db.sessions.delete(sessionId);
this.sessionTitleAttempts.delete(sessionId);

log('[SessionManager] Session deleted:', sessionId);
}

Expand Down
9 changes: 5 additions & 4 deletions src/renderer/components/MessageCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ interface MessageCardProps {
}

export function MessageCard({ message, isStreaming }: MessageCardProps) {
const { t } = useTranslation();
const isUser = message.role === 'user';
const isQueued = message.localStatus === 'queued';
const isCancelled = message.localStatus === 'cancelled';
Expand Down Expand Up @@ -77,13 +78,13 @@ export function MessageCard({ message, isStreaming }: MessageCardProps) {
{isQueued && (
<div className="mb-1 flex items-center gap-1 text-[11px] text-text-muted">
<Clock className="w-3 h-3" />
<span>排队中</span>
<span>{t('messageCard.queued')}</span>
</div>
)}
{isCancelled && (
<div className="mb-1 flex items-center gap-1 text-[11px] text-text-muted">
<XCircle className="w-3 h-3" />
<span>已取消</span>
<span>{t('messageCard.cancelled')}</span>
</div>
)}
{contentBlocks.length === 0 ? (
Expand All @@ -102,7 +103,7 @@ export function MessageCard({ message, isStreaming }: MessageCardProps) {
<button
onClick={handleCopy}
className="mt-1 w-6 h-6 flex items-center justify-center rounded-md bg-surface-muted hover:bg-surface-active transition-all opacity-0 group-hover:opacity-100 flex-shrink-0"
title="复制消息"
title={t('messageCard.copyMessage')}
>
{copied ? (
<Check className="w-3 h-3 text-success" />
Expand Down Expand Up @@ -163,7 +164,7 @@ function ContentBlockView({ block, isUser, isStreaming, allBlocks, message }: Co
}
}}
className={getFileLinkButtonClassName()}
title="在文件夹中定位"
title={t('messageCard.locateInFolder')}
>
{value}
</button>
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ export function Sidebar() {
const handleDeleteAllSessions = () => {
if (sessions.length === 0) return;

const confirmed = window.confirm(`确定要删除所有 ${sessions.length} 个对话吗?此操作无法撤销。`);
const confirmed = window.confirm(t('sidebar.deleteAllConfirm', { count: sessions.length }));
if (confirmed) {
// Delete all sessions
sessions.forEach(session => {
Expand Down
7 changes: 6 additions & 1 deletion src/renderer/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@
"expandToView": "Expand to view tasks",
"noTasks": "No tasks yet",
"deleteAll": "Delete all conversations",
"deleteAllConfirm": "Are you sure you want to delete all {{count}} conversations? This action cannot be undone.",
"localTasks": "These tasks run locally and aren't synced across devices.",
"apiConfigured": "API Configured",
"apiNotConfigured": "API Not Configured",
Expand Down Expand Up @@ -304,7 +305,11 @@
"output": "Output:"
},
"messageCard": {
"request": "Request"
"request": "Request",
"queued": "Queued",
"cancelled": "Cancelled",
"copyMessage": "Copy message",
"locateInFolder": "Show in folder"
},
"permission": {
"permissionRequired": "Permission Required",
Expand Down
7 changes: 6 additions & 1 deletion src/renderer/i18n/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@
"expandToView": "展开查看任务",
"noTasks": "暂无任务",
"deleteAll": "删除所有对话",
"deleteAllConfirm": "确定要删除所有 {{count}} 个对话吗?此操作无法撤销。",
"localTasks": "这些任务在本地运行,不会在设备间同步。",
"apiConfigured": "API 已配置",
"apiNotConfigured": "API 未配置",
Expand Down Expand Up @@ -303,7 +304,11 @@
"output": "输出:"
},
"messageCard": {
"request": "请求"
"request": "请求",
"queued": "排队中",
"cancelled": "已取消",
"copyMessage": "复制消息",
"locateInFolder": "在文件夹中定位"
},
"permission": {
"permissionRequired": "需要权限",
Expand Down
173 changes: 173 additions & 0 deletions tests/config-store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';

// Mock electron-store as a proper class constructor
vi.mock('electron-store', () => {
class MockStore {
private data: Record<string, unknown> = {};
constructor(opts?: any) {
if (opts?.defaults) {
this.data = { ...opts.defaults };
}
}
get(key: string) { return this.data[key]; }
set(key: string, value: unknown) { this.data[key] = value; }
clear() { this.data = {}; }
get path() { return '/tmp/test-config.json'; }
}
return { default: MockStore };
});

vi.mock('electron', () => ({
app: {
getPath: () => '/tmp/test-userdata',
getVersion: () => '3.1.0',
},
}));

vi.mock('fs', () => ({
existsSync: () => true,
mkdirSync: () => {},
createWriteStream: () => ({ write: () => {}, end: () => {} }),
readdirSync: () => [],
statSync: () => ({ size: 100, mtime: new Date() }),
unlinkSync: () => {},
}));

import { configStore, PROVIDER_PRESETS } from '../src/main/config/config-store';

describe('configStore', () => {
beforeEach(() => {
vi.clearAllMocks();
});

describe('getAll', () => {
it('returns config object with expected keys', () => {
const config = configStore.getAll();
expect(config).toHaveProperty('provider');
expect(config).toHaveProperty('apiKey');
expect(config).toHaveProperty('model');
});
});

describe('get', () => {
it('returns specific config value after set', () => {
configStore.set('apiKey', 'test-value');
const value = configStore.get('apiKey');
expect(value).toBe('test-value');
});
});

describe('set', () => {
it('sets a config value', () => {
configStore.set('apiKey', 'new-key');
expect(configStore.get('apiKey')).toBe('new-key');
});
});

describe('update', () => {
it('updates multiple values', () => {
configStore.update({ apiKey: 'key1', model: 'model1' });
expect(configStore.get('apiKey')).toBe('key1');
expect(configStore.get('model')).toBe('model1');
});
});

describe('isConfigured', () => {
it('returns true when configured with API key', () => {
configStore.set('isConfigured', true);
configStore.set('apiKey', 'test-key');
expect(configStore.isConfigured()).toBe(true);
});

it('returns false when not configured', () => {
configStore.set('isConfigured', false);
configStore.set('apiKey', '');
expect(configStore.isConfigured()).toBe(false);
});
});

describe('applyToEnv', () => {
it('sets env vars for anthropic provider', () => {
configStore.update({
provider: 'anthropic',
apiKey: 'sk-ant-test',
baseUrl: 'https://api.anthropic.com',
customProtocol: 'anthropic',
model: 'claude-sonnet-4-5',
isConfigured: true,
});

configStore.applyToEnv();
expect(process.env.ANTHROPIC_API_KEY).toBe('sk-ant-test');
expect(process.env.CLAUDE_MODEL).toBe('claude-sonnet-4-5');
});

it('sets env vars for openai provider', () => {
configStore.update({
provider: 'openai',
apiKey: 'sk-test',
baseUrl: 'https://api.openai.com/v1',
customProtocol: 'openai',
model: 'gpt-5.2',
openaiMode: 'responses',
isConfigured: true,
});

configStore.applyToEnv();
expect(process.env.OPENAI_API_KEY).toBe('sk-test');
expect(process.env.OPENAI_MODEL).toBe('gpt-5.2');
expect(process.env.OPENAI_API_MODE).toBe('responses');
});
});

describe('reset', () => {
it('resets the store', () => {
configStore.set('apiKey', 'some-key');
configStore.reset();
// After reset, values should be defaults (empty/undefined)
const config = configStore.getAll();
expect(config).toBeDefined();
});
});

describe('getPath', () => {
it('returns the store file path', () => {
expect(configStore.getPath()).toBe('/tmp/test-config.json');
});
});
});

describe('PROVIDER_PRESETS', () => {
it('has all required providers', () => {
expect(PROVIDER_PRESETS).toHaveProperty('openrouter');
expect(PROVIDER_PRESETS).toHaveProperty('anthropic');
expect(PROVIDER_PRESETS).toHaveProperty('openai');
expect(PROVIDER_PRESETS).toHaveProperty('custom');
});

it('each preset has required fields', () => {
for (const [, preset] of Object.entries(PROVIDER_PRESETS)) {
expect(preset).toHaveProperty('name');
expect(preset).toHaveProperty('baseUrl');
expect(preset).toHaveProperty('models');
expect(preset).toHaveProperty('keyPlaceholder');
expect(preset).toHaveProperty('keyHint');
expect(Array.isArray(preset.models)).toBe(true);
expect(preset.models.length).toBeGreaterThan(0);
}
});

it('preset names are in English', () => {
expect(PROVIDER_PRESETS.openrouter.name).toBe('OpenRouter');
expect(PROVIDER_PRESETS.anthropic.name).toBe('Anthropic');
expect(PROVIDER_PRESETS.openai.name).toBe('OpenAI');
expect(PROVIDER_PRESETS.custom.name).toBe('More Models');
});

it('key hints are in English', () => {
for (const [, preset] of Object.entries(PROVIDER_PRESETS)) {
// Ensure no Chinese characters in keyHint
expect(preset.keyHint).not.toMatch(/[\u4e00-\u9fff]/);
}
});
});
Loading
Loading