feat(desktop): embed BoosAPI Desktop client as a subproject

Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as
a monorepo subdirectory under desktop/. Source only — node_modules,
dist, Cargo target and the upstream .git history are excluded.

desktop/ keeps its own independent Cargo workspace (desktop/src-tauri
with crates/*); it is not part of the backend boosapi crate, so a
`cargo check` at the metapi root still compiles only the backend. Build
the desktop app from desktop/src-tauri per desktop/README.md.

Note: app icons are still placeholder assets pending the final BoosAPI
logo. The updater signing keys live outside the repo and are not
included.
This commit is contained in:
boos
2026-06-22 11:32:57 +08:00
parent d9c78ecc73
commit e0a5f57793
2305 changed files with 449355 additions and 0 deletions
@@ -0,0 +1,103 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { handleCommand } from '../browserMock';
type GatewayTemplate = {
id: string;
target: string;
content: string;
};
describe('browserMock gateway templates', () => {
beforeEach(() => {
localStorage.clear();
});
it('returns Claude and Cursor templates that match BoosApi runtime contracts', async () => {
const templates = await handleCommand<GatewayTemplate[]>('list_gateway_templates');
const cursor = templates.find((template) => template.target === 'cursor');
expect(cursor).toBeDefined();
expect(cursor?.content).toContain('"openai.apiKey"');
expect(cursor?.content).toContain('"openai.apiBaseUrl"');
expect(cursor?.content).not.toContain('"api_key"');
expect(cursor?.content).not.toContain('"api_base"');
const claude = templates.find((template) => template.target === 'claude_code');
expect(claude).toBeDefined();
expect(claude?.content).toContain('ANTHROPIC_BASE_URL=');
expect(claude?.content).toContain('ANTHROPIC_AUTH_TOKEN=');
expect(claude?.content).not.toContain('ANTHROPIC_API_KEY=');
});
it('maps backup manifests into files-page backup rows and cleans up missing entries', async () => {
await handleCommand('create_backup', { format: 'sqlite' });
const rows = await handleCommand<any[]>('list_files_page_entries', { category: 'backups' });
expect(rows).toHaveLength(1);
expect(rows[0].id).toMatch(/^backup_manifest::/);
expect(rows[0].category).toBe('backups');
expect(rows[0].path).toContain('/mock/path/');
await handleCommand('cleanup_missing_files_page_entry', { entryId: rows[0].id });
const backups = await handleCommand<any[]>('list_backups');
expect(backups).toHaveLength(0);
});
it('stores S3 config and supports S3 backup list/delete commands', async () => {
await handleCommand('save_s3_config', {
config: {
bucket: 'boosapi-backups',
region: 'us-west-2',
prefix: 'desktop/',
endpointUrl: null,
forcePathStyle: false,
useDefaultCredentials: false,
accessKeyId: 'access',
secretAccessKey: 'secret',
sessionToken: null,
},
});
const config = await handleCommand<any>('get_s3_config');
expect(config.bucket).toBe('boosapi-backups');
const fileName = await handleCommand<string>('s3_backup');
const backups = await handleCommand<any[]>('s3_list_backups');
expect(backups[0].fileName).toBe(fileName);
await handleCommand('s3_delete_backup', { fileName });
const remaining = await handleCommand<any[]>('s3_list_backups');
expect(remaining).toHaveLength(0);
});
it('flattens MCP create input and updates only input fields', async () => {
const created = await handleCommand<any>('create_mcp_server', {
input: {
name: 'Remote MCP',
transport: 'http',
endpoint: 'https://example.com/mcp',
headersJson: JSON.stringify({ Authorization: 'Bearer old' }),
enabled: false,
},
});
expect(created.name).toBe('Remote MCP');
expect(created.transport).toBe('http');
expect(created.endpoint).toBe('https://example.com/mcp');
expect(created.headersJson).toBe(JSON.stringify({ Authorization: 'Bearer old' }));
expect(created.input).toBeUndefined();
const updated = await handleCommand<any>('update_mcp_server', {
id: created.id,
input: {
headersJson: JSON.stringify({ Authorization: 'Bearer new' }),
},
});
expect(updated.id).toBe(created.id);
expect(updated.headersJson).toBe(JSON.stringify({ Authorization: 'Bearer new' }));
expect(updated.input).toBeUndefined();
});
});
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';
import { getMarkdown, parseMarkdownToStructure } from 'stream-markdown-parser';
import {
normalizeHtmlRenderContent,
normalizeHtmlRenderMarkers,
shouldFallbackIncompleteHtmlRender,
} from '../chatHtmlRender';
describe('chat html render marker compatibility', () => {
it('converts completed comment markers to html-render tags', () => {
expect(normalizeHtmlRenderMarkers('before\n<!-- html-render-start --><div>ok</div><!-- html-render-end -->\nafter')).toBe(
'before\n<html-render><div>ok</div></html-render>\nafter',
);
});
it('converts multiple completed marker pairs independently', () => {
expect(normalizeHtmlRenderMarkers('<!-- html-render-start --><b>a</b><!-- html-render-end -->\n<!-- html-render-start --><i>b</i><!-- html-render-end -->')).toBe(
'<html-render><b>a</b></html-render>\n<html-render><i>b</i></html-render>',
);
});
it('keeps incomplete comment markers renderable while streaming', () => {
expect(normalizeHtmlRenderMarkers('before <!-- html-render-start --><div>draft', { final: false })).toBe(
'before <html-render><div>draft',
);
});
it('falls incomplete comment markers back to source text after streaming ends', () => {
const source = 'before <!-- html-render-start --><div>draft';
expect(normalizeHtmlRenderMarkers(source, { final: true })).toBe(source);
});
it('detects incomplete html-render tags only after final output', () => {
expect(shouldFallbackIncompleteHtmlRender('<html-render><div>draft', { final: false })).toBe(false);
expect(shouldFallbackIncompleteHtmlRender('<html-render><div>draft', { final: true })).toBe(true);
expect(shouldFallbackIncompleteHtmlRender('<html-render><div>ok</div></html-render>', { final: true })).toBe(false);
});
it('temporarily closes incomplete html-render tags while streaming', () => {
expect(normalizeHtmlRenderContent('<html-render><div>draft', { final: false })).toBe(
'<html-render>\n<div>draft</html-render>',
);
});
it('temporarily closes incomplete comment marker html-render blocks while streaming', () => {
expect(normalizeHtmlRenderContent('before <!-- html-render-start --><div>draft', { final: false })).toBe(
'before <html-render>\n<div>draft</html-render>',
);
});
it('keeps streaming html-render content parseable before the final closing tag arrives', () => {
const normalized = normalizeHtmlRenderContent('<html-render><div class="card">draft', { final: false });
const md = getMarkdown('chat-html-render-streaming-test', { customHtmlTags: ['html-render'] });
const nodes = parseMarkdownToStructure(normalized, md, {
customHtmlTags: ['html-render'],
final: false,
});
expect(nodes).toHaveLength(1);
expect(nodes[0]).toMatchObject({
type: 'html-render',
content: '<div class="card">draft',
});
});
});
@@ -0,0 +1,32 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { resolveImageBlob } from '../chatImageActions';
const invokeMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/invoke', () => ({
isTauri: () => true,
invoke: (cmd: string, args?: Record<string, unknown>) => invokeMock(cmd, args),
}));
describe('chat image actions', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new Error('frontend fetch should not be used'))));
});
it('loads remote images through the Tauri backend to avoid WebView CORS failures', async () => {
invokeMock.mockResolvedValueOnce({
data: 'aGVsbG8=',
mimeType: 'image/png',
});
const blob = await resolveImageBlob('https://example.com/generated.png');
expect(invokeMock).toHaveBeenCalledWith('fetch_remote_image', {
url: 'https://example.com/generated.png',
});
expect(fetch).not.toHaveBeenCalled();
expect(blob.type).toBe('image/png');
await expect(blob.text()).resolves.toBe('hello');
});
});
@@ -0,0 +1,202 @@
import { describe, expect, it } from 'vitest';
import {
parseChatMarkdown,
safeParseChatMarkdown,
shouldUsePlainTextChatContent,
stripBoosapiTags,
} from '../chatMarkdown';
describe('parseChatMarkdown', () => {
it('parses fenced code blocks into markdown nodes', () => {
const nodes = parseChatMarkdown('```ts\nconst value = 1;\n```');
expect(nodes.length).toBeGreaterThan(0);
expect(nodes.some((node) => node.type === 'code_block')).toBe(true);
});
it('parses stored assistant d2 replies as a single d2 code block node', () => {
const nodes = parseChatMarkdown(`\`\`\`d2
User: 用户
UI: 登录页
Auth: 认证服务
DB: 用户库
MFA: 二次验证
Token: Token/Session
App: 业务系统
User -> UI: 输入账号/密码
UI -> Auth: 提交凭证
Auth -> DB: 查询用户 + 校验密码哈希
DB -> Auth: 返回用户记录
Auth -> MFA: 需要二次验证?
MFA -> User: 发送验证码/Push
User -> UI: 输入验证码
UI -> Auth: 提交验证码
Auth -> MFA: 校验验证码
Auth -> Token: 签发 JWT/Session
Token -> Auth: 返回令牌/会话
Auth -> UI: 登录成功(含token)
UI -> App: 携带token访问
\`\`\``);
expect(nodes).toHaveLength(1);
expect(nodes[0]).toMatchObject({
type: 'code_block',
language: 'd2',
});
});
it('parses inline native think tags as a think node before answer text', () => {
const nodes = parseChatMarkdown('<think>好的,用户让我讲个笑话。</think>\n\nanswer');
expect(nodes[0]).toMatchObject({
type: 'think',
content: '好的,用户让我讲个笑话。',
});
expect(nodes[1]).toMatchObject({
type: 'paragraph',
});
});
it('does not normalize think-looking text inside fenced code blocks', () => {
const nodes = parseChatMarkdown('```html\n<think>literal</think>\n```');
expect(nodes).toHaveLength(1);
expect(nodes[0]).toMatchObject({
type: 'code_block',
});
expect(String(nodes[0].code)).toContain('<think>literal</think>');
});
it('parses html-render tags as custom render nodes', () => {
const nodes = parseChatMarkdown('<html-render><div class="card">ok</div></html-render>');
expect(nodes).toHaveLength(1);
expect(nodes[0]).toMatchObject({
type: 'html-render',
content: '<div class="card">ok</div>',
});
});
it('does not parse html-render tags inside fenced code blocks', () => {
const nodes = parseChatMarkdown('```html\n<html-render><div>literal</div></html-render>\n```');
expect(nodes).toHaveLength(1);
expect(nodes[0]).toMatchObject({
type: 'code_block',
});
expect(String(nodes[0].code)).toContain('<html-render><div>literal</div></html-render>');
});
it('parses completed html-render comment markers as custom render nodes', () => {
const nodes = parseChatMarkdown('<!-- html-render-start --><section>legacy</section><!-- html-render-end -->');
expect(nodes).toHaveLength(1);
expect(nodes[0]).toMatchObject({
type: 'html-render',
content: '<section>legacy</section>',
});
});
it('falls incomplete final html-render tags back to source text', () => {
const nodes = parseChatMarkdown('<html-render><div>draft');
const serialized = JSON.stringify(nodes);
expect(nodes.some((node) => node.type === 'html-render')).toBe(false);
expect(serialized).toContain('<html-render><div>draft');
});
it('strips think and boosapi-only tags when preparing export-safe transcript text', () => {
const cleaned = stripBoosapiTags(`Final answer
<think>Hidden reasoning</think>
<web-search-query data-boosapi="1">query</web-search-query>
<knowledge-retrieval data-boosapi="1">retrieved</knowledge-retrieval>
<web-search data-boosapi="1">[{"title":"A"}]</web-search>
:::mcp tool
payload
:::
Visible tail`);
expect(cleaned).toBe('Final answer\nVisible tail');
});
it('keeps user-visible html-render content in export-safe transcript text', () => {
const content = '<html-render><div>visible html</div></html-render>';
expect(stripBoosapiTags(content)).toBe(content);
});
it('preserves user pasted think-like logs when think stripping is disabled', () => {
const content = 'before\n<think>literal user log</think>\nafter';
expect(stripBoosapiTags(content, { stripThink: false })).toBe(content);
expect(stripBoosapiTags(content, { stripThink: true })).toBe('before\nafter');
});
it('keeps malformed or non-boosapi display tags as literal transcript text', () => {
const content = [
'visible',
'<web-search status="done">user-visible</web-search>',
'<knowledge-retrieval data-boosapi="1">missing close',
'<tool-call data-boosapi="1">also missing close',
'tail',
].join('\n');
expect(stripBoosapiTags(content)).toBe(content);
});
it('strips only complete mcp fenced blocks and preserves incomplete user logs', () => {
const complete = 'before\n:::mcp tool\npayload\n:::\nafter';
const incomplete = 'before\n:::mcp tool\npayload\nafter';
expect(stripBoosapiTags(complete)).toBe('before\nafter');
expect(stripBoosapiTags(incomplete)).toBe(incomplete);
});
it('handles a 130kb malformed ai-system-log paste without dropping user text', () => {
const malformedLog = [
'<system-reminder>do not drop this',
'<user_input>keep this input',
'<think>literal log start',
...Array.from({ length: 38 }, (_, index) => `<tool_call_${index}>${'x'.repeat(3200)}`),
'visible tail',
].join('\n').slice(0, 130_268);
expect(() => stripBoosapiTags(malformedLog, { stripThink: false })).not.toThrow();
const cleaned = stripBoosapiTags(malformedLog, { stripThink: false });
expect(cleaned).toContain('<system-reminder>do not drop this');
expect(cleaned).toContain('<user_input>keep this input');
expect(cleaned).toContain('<think>literal log start');
});
it('classifies long malformed user logs for plain-text rendering', () => {
const content = [
'<system-reminder>open',
'<user_input>open',
...Array.from({ length: 28 }, (_, index) => `<tag-${index}>${'x'.repeat(4000)}`),
].join('\n');
expect(content.length).toBeGreaterThan(100_000);
expect(shouldUsePlainTextChatContent(content, { role: 'user', isStreaming: false })).toBe(true);
expect(shouldUsePlainTextChatContent('short <tag> log', { role: 'user', isStreaming: false })).toBe(false);
expect(shouldUsePlainTextChatContent(content, { role: 'assistant', isStreaming: false })).toBe(false);
});
it('returns plain text nodes from the safe parser when markdown parsing throws', () => {
const nodes = safeParseChatMarkdown('literal <broken>', {
parse: () => {
throw new Error('parser failed');
},
});
expect(nodes).toHaveLength(1);
expect(nodes[0]).toMatchObject({
type: 'paragraph',
raw: 'literal <broken>',
});
expect(JSON.stringify(nodes)).toContain('literal <broken>');
});
});
@@ -0,0 +1,396 @@
import { describe, expect, it } from 'vitest';
import type { Message } from '@/types';
import {
getMessageVersionGroupKey,
getLatestVersionsByModel,
hasMultipleModelVersions,
applyMultiModelStreamError,
insertModelVersionPlaceholder,
mergeAssistantVersionGroup,
mergeAssistantVersionsAfterSwitch,
resolvePendingDisplayVersionSelection,
selectDisplayVersionsByModel,
selectRenderableVersionSet,
selectNextAssistantVersion,
shouldRenderStandaloneAssistantError,
} from '../chatMultiModel';
function makeMessage(overrides: Partial<Message>): Message {
return {
id: 'msg-1',
conversation_id: 'conv-1',
role: 'assistant',
content: '',
provider_id: 'provider-1',
model_id: 'model-1',
token_count: null,
attachments: [],
thinking: null,
tool_calls_json: null,
tool_call_id: null,
created_at: 1,
parent_message_id: 'user-1',
version_index: 0,
is_active: true,
status: 'complete',
...overrides,
};
}
describe('chatMultiModel helpers', () => {
it('does not downgrade non-tabs multi-model errors into standalone alerts', () => {
expect(shouldRenderStandaloneAssistantError('error', true)).toBe(false);
expect(shouldRenderStandaloneAssistantError('error', false)).toBe(true);
});
it('keeps distinct error versions even when model metadata is missing', () => {
const latest = getLatestVersionsByModel([
makeMessage({ id: 'error-1', model_id: null, provider_id: null, status: 'error', created_at: 1 }),
makeMessage({ id: 'error-2', model_id: null, provider_id: null, status: 'error', created_at: 2 }),
makeMessage({ id: 'model-2', model_id: 'model-2', created_at: 3 }),
]);
expect(latest.map((message) => message.id)).toEqual(['error-1', 'error-2', 'model-2']);
});
it('keeps the current active answer visible while adding a new model response', () => {
const current = makeMessage({ id: 'active', model_id: 'model-a', is_active: true, content: 'ready' });
const placeholder = makeMessage({
id: 'temp-assistant-2',
model_id: 'model-b',
provider_id: 'provider-2',
is_active: false,
status: 'partial',
});
const next = insertModelVersionPlaceholder([current], current.parent_message_id!, placeholder);
expect(next).toHaveLength(2);
expect(next.find((message) => message.id === 'active')?.is_active).toBe(true);
expect(next.find((message) => message.id === 'temp-assistant-2')?.is_active).toBe(false);
});
it('removes deleted local versions when backend returns the remaining version set', () => {
const deleted = makeMessage({ id: 'error-version', model_id: null, provider_id: null, status: 'error', is_active: false });
const remaining = makeMessage({ id: 'good-version', model_id: 'model-b', is_active: true, content: 'ok' });
const unrelated = makeMessage({ id: 'other-parent', parent_message_id: 'user-2', content: 'keep me' });
const next = mergeAssistantVersionsAfterSwitch(
[deleted, remaining, unrelated],
deleted.parent_message_id!,
[remaining],
remaining.id,
);
expect(next.map((message) => message.id)).toEqual(['good-version', 'other-parent']);
expect(next.find((message) => message.id === 'good-version')?.is_active).toBe(true);
});
it('detects when cached versions are no longer multi-model', () => {
expect(hasMultipleModelVersions([
makeMessage({ id: 'model-a', model_id: 'model-a' }),
makeMessage({ id: 'model-b', model_id: 'model-b' }),
])).toBe(true);
expect(hasMultipleModelVersions([
makeMessage({ id: 'single', model_id: 'model-a' }),
])).toBe(false);
});
it('uses the active same-model version instead of the latest version for display', () => {
const oldVersion = makeMessage({
id: 'model-a-old',
model_id: 'model-a',
content: 'old answer',
is_active: true,
version_index: 0,
created_at: 1,
});
const latestVersion = makeMessage({
id: 'model-a-latest',
model_id: 'model-a',
content: 'latest answer',
is_active: false,
version_index: 1,
created_at: 2,
});
const otherModel = makeMessage({
id: 'model-b',
model_id: 'model-b',
content: 'other answer',
is_active: false,
version_index: 0,
created_at: 3,
});
const displayVersions = selectDisplayVersionsByModel(
[oldVersion, latestVersion, otherModel],
oldVersion.id,
);
expect(displayVersions.map((message) => message.id)).toEqual(['model-a-old', 'model-b']);
});
it('keeps latest versions for models that are not currently active', () => {
const oldVersion = makeMessage({
id: 'model-a-old',
model_id: 'model-a',
content: 'old answer',
is_active: false,
version_index: 0,
created_at: 1,
});
const latestVersion = makeMessage({
id: 'model-a-latest',
model_id: 'model-a',
content: 'latest answer',
is_active: false,
version_index: 1,
created_at: 2,
});
const activeOtherModel = makeMessage({
id: 'model-b',
model_id: 'model-b',
content: 'other answer',
is_active: true,
version_index: 0,
created_at: 3,
});
const displayVersions = selectDisplayVersionsByModel(
[oldVersion, latestVersion, activeOtherModel],
activeOtherModel.id,
);
expect(displayVersions.map((message) => message.id)).toEqual(['model-a-latest', 'model-b']);
});
it('uses a per-model display override without changing the active context version', () => {
const activeOldVersion = makeMessage({
id: 'model-a-old',
model_id: 'model-a',
provider_id: 'provider-a',
content: 'old answer',
is_active: true,
version_index: 0,
created_at: 1,
});
const displayOverrideVersion = makeMessage({
id: 'model-a-new',
model_id: 'model-a',
provider_id: 'provider-a',
content: 'new answer',
is_active: false,
version_index: 1,
created_at: 2,
});
const otherModel = makeMessage({
id: 'model-b',
model_id: 'model-b',
provider_id: 'provider-b',
content: 'other answer',
is_active: false,
version_index: 0,
created_at: 3,
});
const displayVersions = selectDisplayVersionsByModel(
[activeOldVersion, displayOverrideVersion, otherModel],
activeOldVersion.id,
new Map([[getMessageVersionGroupKey(displayOverrideVersion), displayOverrideVersion.id]]),
);
expect(displayVersions.map((message) => message.id)).toEqual(['model-a-new', 'model-b']);
expect(activeOldVersion.is_active).toBe(true);
});
it('falls back to the active or latest model version when a display override is stale', () => {
const activeOldVersion = makeMessage({
id: 'model-a-old',
model_id: 'model-a',
provider_id: 'provider-a',
is_active: true,
version_index: 0,
created_at: 1,
});
const latestVersion = makeMessage({
id: 'model-a-new',
model_id: 'model-a',
provider_id: 'provider-a',
is_active: false,
version_index: 1,
created_at: 2,
});
const displayVersions = selectDisplayVersionsByModel(
[activeOldVersion, latestVersion],
activeOldVersion.id,
new Map([[getMessageVersionGroupKey(activeOldVersion), 'deleted-version']]),
);
expect(displayVersions.map((message) => message.id)).toEqual(['model-a-old']);
});
it('keeps a pending regenerated page selected until the real streaming version appears', () => {
const previousVersion = makeMessage({
id: 'model-a-v4',
model_id: 'model-a',
provider_id: 'provider-a',
is_active: false,
version_index: 3,
created_at: 4,
});
const modelKey = getMessageVersionGroupKey(previousVersion);
const unresolved = resolvePendingDisplayVersionSelection(
[previousVersion],
modelKey,
'temp-assistant-v5',
{ messageId: 'temp-assistant-v5', versionIndex: 4, createdAt: 5 },
);
expect(unresolved).toBe('temp-assistant-v5');
});
it('resolves a pending regenerated page from temp id to the real streaming id', () => {
const previousVersion = makeMessage({
id: 'model-a-v4',
model_id: 'model-a',
provider_id: 'provider-a',
is_active: false,
version_index: 3,
created_at: 4,
});
const streamingVersion = makeMessage({
id: 'model-a-v5',
model_id: 'model-a',
provider_id: 'provider-a',
is_active: false,
status: 'partial',
version_index: 4,
created_at: 5,
});
const modelKey = getMessageVersionGroupKey(streamingVersion);
const resolved = resolvePendingDisplayVersionSelection(
[previousVersion, streamingVersion],
modelKey,
'temp-assistant-v5',
{ messageId: 'temp-assistant-v5', versionIndex: 4, createdAt: 5 },
);
expect(resolved).toBe('model-a-v5');
});
it('picks a remaining fallback version after deleting the active one', () => {
const fallback = makeMessage({ id: 'fallback', model_id: 'model-b', version_index: 0, created_at: 1 });
const deleted = makeMessage({ id: 'deleted', model_id: 'model-a', version_index: 1, created_at: 2, status: 'error' });
expect(selectNextAssistantVersion([fallback, deleted], deleted.id)?.id).toBe('fallback');
});
it('merges a complete version group back after an active-only message refresh', () => {
const user = makeMessage({
id: 'user-1',
role: 'user',
content: 'question',
provider_id: null,
model_id: null,
parent_message_id: null,
});
const activeError = makeMessage({
id: 'active-error',
model_id: 'model-a',
provider_id: 'provider-a',
status: 'error',
content: 'boom',
is_active: true,
version_index: 0,
});
const inactiveSuccess = makeMessage({
id: 'inactive-success',
model_id: 'model-b',
provider_id: 'provider-b',
content: 'ok',
is_active: false,
version_index: 1,
});
const merged = mergeAssistantVersionGroup(
[user, activeError],
user.id,
[activeError, inactiveSuccess],
activeError.id,
);
expect(merged.map((message) => message.id)).toEqual(['user-1', 'active-error', 'inactive-success']);
expect(merged.find((message) => message.id === 'active-error')?.is_active).toBe(true);
expect(merged.find((message) => message.id === 'inactive-success')?.is_active).toBe(false);
});
it('resolves a temp first-model placeholder when the stream errors before any chunk arrives', () => {
const user = makeMessage({
id: 'user-1',
role: 'user',
content: 'question',
provider_id: null,
model_id: null,
parent_message_id: null,
});
const placeholder = makeMessage({
id: 'temp-assistant-1',
model_id: 'model-a',
provider_id: 'provider-a',
content: '',
parent_message_id: user.id,
is_active: true,
status: 'partial',
});
const result = applyMultiModelStreamError([user, placeholder], {
conversationId: 'conv-1',
parentMessageId: user.id,
streamingMessageId: placeholder.id,
messageId: 'db-assistant-1',
error: 'provider failed',
modelId: 'model-a',
providerId: 'provider-a',
});
expect(result.streamingMessageId).toBe('db-assistant-1');
expect(result.messages.map((message) => message.id)).toEqual(['user-1', 'db-assistant-1']);
expect(result.messages[1]).toMatchObject({
content: 'provider failed',
status: 'error',
model_id: 'model-a',
provider_id: 'provider-a',
parent_message_id: 'user-1',
is_active: true,
});
});
it('prefers hydrated store versions over a larger stale cached version list', () => {
const active = makeMessage({
id: 'active',
model_id: 'model-a',
is_active: true,
version_index: 0,
});
const remaining = makeMessage({
id: 'remaining',
model_id: 'model-b',
is_active: false,
version_index: 1,
});
const deleted = makeMessage({
id: 'deleted',
model_id: 'model-c',
is_active: false,
version_index: 2,
});
expect(selectRenderableVersionSet([active, remaining], [active, remaining, deleted]).map((message) => message.id))
.toEqual(['active', 'remaining']);
});
});
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { normalizeAutoConversationTitle } from '../conversationTitle';
describe('normalizeAutoConversationTitle', () => {
it('truncates long auto titles without splitting multibyte text', () => {
const title = '这是一个用于测试自动会话标题截断逻辑的超长用户问题内容,需要继续追加更多文字';
expect(normalizeAutoConversationTitle(title)).toBe(
`${Array.from(title).slice(0, 30).join('')}...`,
);
});
it('trims whitespace and common quote wrappers', () => {
expect(normalizeAutoConversationTitle(' 「项目排期讨论」 ')).toBe('项目排期讨论');
});
});
@@ -0,0 +1,64 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
hasDesktopCapability,
loadDevtoolsContextMenuEnabled,
openDevtoolsWithDiagnostics,
} from '@/lib/desktopCapabilities';
const mocks = vi.hoisted(() => ({
invoke: vi.fn(),
writeStartupDiagnostic: vi.fn(),
}));
vi.mock('@/lib/invoke', () => ({
invoke: mocks.invoke,
}));
vi.mock('@/lib/startupDiagnostics', () => ({
writeStartupDiagnostic: mocks.writeStartupDiagnostic,
}));
describe('desktopCapabilities', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.writeStartupDiagnostic.mockResolvedValue(undefined);
});
it('detects supported capabilities by key', () => {
expect(hasDesktopCapability([
{ key: 'notification', supported: true },
{ key: 'devtools_context_menu', supported: true },
], 'devtools_context_menu')).toBe(true);
expect(hasDesktopCapability([
{ key: 'devtools_context_menu', supported: false },
], 'devtools_context_menu')).toBe(false);
});
it('keeps devtools context menu hidden in release mode without capability', async () => {
mocks.invoke.mockResolvedValue([
{ key: 'devtools_context_menu', supported: false },
]);
await expect(loadDevtoolsContextMenuEnabled(false)).resolves.toBe(false);
});
it('enables devtools context menu in release mode when capability is present', async () => {
mocks.invoke.mockResolvedValue([
{ key: 'devtools_context_menu', supported: true },
]);
await expect(loadDevtoolsContextMenuEnabled(false)).resolves.toBe(true);
});
it('logs open_devtools failures', async () => {
mocks.invoke.mockRejectedValue(new Error('permission denied'));
await openDevtoolsWithDiagnostics();
expect(mocks.writeStartupDiagnostic).toHaveBeenCalledWith(
'error',
expect.stringContaining('permission denied'),
);
});
});
@@ -0,0 +1,252 @@
import { describe, expect, it } from 'vitest';
import type { DrawingSettings, ProviderConfig } from '@/types';
import type { DrawingParamRenderContext } from '@/components/drawing/params/types';
import {
getDrawingBackgroundOptions,
getDrawingModelOptions,
getDrawingParamConfig,
getDrawingOutputFormatOptions,
getDrawingProvidersForModel,
getDrawingQualityOptions,
getDrawingReferenceImageFormatOptions,
getDrawingReferenceImageModeOptions,
getDrawingSizeOptions,
isDrawingOutputCompressionSupported,
normalizeDrawingSettingsByConfig,
} from '../drawingModels';
function providerFixture(overrides: Partial<ProviderConfig>): ProviderConfig {
return {
id: 'provider',
name: 'Provider',
provider_type: 'openai',
api_host: 'https://api.openai.com',
api_path: null,
enabled: true,
models: [],
keys: [],
proxy_config: null,
custom_headers: null,
icon: null,
builtin_id: null,
sort_order: 0,
created_at: 0,
updated_at: 0,
...overrides,
};
}
function settingsFixture(overrides: Partial<DrawingSettings> = {}): DrawingSettings {
return {
providerId: 'provider',
modelId: 'gpt-image-2',
size: 'auto',
quality: 'auto',
outputFormat: 'png',
background: 'auto',
outputCompression: undefined,
referenceImageMode: 'base64',
referenceImageFormat: 'object',
referenceImageParamName: 'images',
n: 1,
generationApiPath: '/images/generations',
editApiPath: '/images/edits',
...overrides,
};
}
function renderContext(settings: DrawingSettings): DrawingParamRenderContext {
return {
settings,
providers: [],
modelOptions: [],
providerOptions: [],
t: (_key, fallback) => fallback,
getProvidersForModel: () => [],
};
}
describe('drawing model/provider filtering', () => {
it('always exposes the built-in drawing model list', () => {
expect(getDrawingModelOptions([]).map((item) => item.value)).toEqual([
'gpt-image-2',
'gpt-image-1.5',
'gpt-image-1',
'gpt-image-1-mini',
]);
});
it('only exposes providers that have the selected enabled Image model', () => {
const providers: ProviderConfig[] = [
providerFixture({
id: 'openai-1',
name: 'OpenAI A',
models: [
{
provider_id: 'openai-1',
model_id: 'gpt-image-2',
name: 'gpt-image-2',
group_name: 'gpt-image',
model_type: 'Image',
capabilities: [],
max_tokens: null,
enabled: true,
param_overrides: null,
},
],
}),
providerFixture({
id: 'chat-only',
name: 'Chat Only',
models: [
{
provider_id: 'chat-only',
model_id: 'gpt-image-2',
name: 'gpt-image-2',
group_name: 'gpt-image',
model_type: 'Chat',
capabilities: ['TextChat'],
max_tokens: null,
enabled: true,
param_overrides: null,
},
],
}),
providerFixture({
id: 'disabled-provider',
name: 'Disabled',
enabled: false,
models: [
{
provider_id: 'disabled-provider',
model_id: 'gpt-image-2',
name: 'gpt-image-2',
group_name: 'gpt-image',
model_type: 'Image',
capabilities: [],
max_tokens: null,
enabled: true,
param_overrides: null,
},
],
}),
];
expect(getDrawingModelOptions(providers).map((item) => item.value)).toEqual([
'gpt-image-2',
'gpt-image-1.5',
'gpt-image-1',
'gpt-image-1-mini',
]);
expect(getDrawingProvidersForModel(providers, 'gpt-image-2').map((item) => item.id)).toEqual(['openai-1']);
});
it('returns localized drawing parameter options', () => {
const labels: Record<string, string> = {
'drawing.option.auto': '自动',
'drawing.option.quality.low': '低',
'drawing.option.quality.medium': '中',
'drawing.option.quality.high': '高',
'drawing.option.background.opaque': '不透明',
'drawing.option.background.transparent': '透明',
};
const t = (key: string, fallback: string) => labels[key] ?? fallback;
expect(getDrawingSizeOptions(t)[0]).toEqual({ label: '自动', value: 'auto' });
expect(getDrawingQualityOptions(t).map((item) => item.label)).toEqual(['自动', '低', '中', '高']);
expect(getDrawingOutputFormatOptions(t).map((item) => item.label)).toEqual(['PNG', 'JPEG', 'WEBP']);
expect(getDrawingBackgroundOptions(t).map((item) => item.label)).toEqual(['自动', '不透明', '透明']);
expect(getDrawingReferenceImageModeOptions(t)).toEqual([
{ label: 'Multipart', value: 'multipart' },
{ label: 'Base64', value: 'base64' },
]);
expect(getDrawingReferenceImageFormatOptions(t)).toEqual([
{ label: '对象数组', value: 'object' },
{ label: '字符串数组', value: 'string' },
]);
});
it('hides unsupported gpt-image-2 parameters instead of disabling them', () => {
const labels: Record<string, string> = {
'drawing.option.auto': '自动',
'drawing.option.background.opaque': '不透明',
'drawing.option.background.transparent': '透明',
};
const t = (key: string, fallback: string) => labels[key] ?? fallback;
expect(getDrawingBackgroundOptions(t, 'gpt-image-2').map((item) => item.value)).toEqual([
'auto',
'opaque',
]);
expect(getDrawingBackgroundOptions(t, 'gpt-image-1').map((item) => item.value)).toContain('transparent');
expect(isDrawingOutputCompressionSupported('gpt-image-2', 'jpeg')).toBe(false);
expect(isDrawingOutputCompressionSupported('gpt-image-1', 'jpeg')).toBe(true);
expect(isDrawingOutputCompressionSupported('gpt-image-1', 'png')).toBe(false);
});
it('returns the GPT-Image parameter schema for GPT-Image models', () => {
const config = getDrawingParamConfig('gpt-image-2');
const basic = config.groups.find((group) => group.id === 'basic');
const advanced = config.groups.find((group) => group.id === 'advanced');
expect(config.id).toBe('gpt-image');
expect(basic?.fields.map((field) => field.id)).toEqual([
'model',
'provider',
'size',
'quality',
'outputFormat',
'background',
'batchCount',
'references',
]);
expect(advanced?.fields.map((field) => field.id)).toEqual([
'generationApiPath',
'editApiPath',
'referenceImageMode',
'referenceImageFormat',
'referenceImageParamName',
'compression',
]);
});
it('uses schema visibility for model-specific GPT-Image fields', () => {
const config = getDrawingParamConfig('gpt-image-2');
const advanced = config.groups.find((group) => group.id === 'advanced');
const compression = advanced?.fields.find((field) => field.id === 'compression');
expect(compression?.visibleWhen?.(renderContext(settingsFixture({
modelId: 'gpt-image-2',
outputFormat: 'jpeg',
})))).toBe(false);
expect(compression?.visibleWhen?.(renderContext(settingsFixture({
modelId: 'gpt-image-1',
outputFormat: 'jpeg',
})))).toBe(true);
expect(compression?.visibleWhen?.(renderContext(settingsFixture({
modelId: 'gpt-image-1',
outputFormat: 'png',
})))).toBe(false);
});
it('normalizes settings through the selected schema rules', () => {
expect(normalizeDrawingSettingsByConfig(settingsFixture({
modelId: 'gpt-image-2',
outputFormat: 'jpeg',
background: 'transparent',
outputCompression: 80,
}))).toMatchObject({
background: 'auto',
outputCompression: undefined,
});
expect(normalizeDrawingSettingsByConfig(settingsFixture({
modelId: 'gpt-image-1',
outputFormat: 'jpeg',
background: 'transparent',
outputCompression: 80,
}))).toMatchObject({
background: 'transparent',
outputCompression: 80,
});
});
});
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { getErrorMessage, isFormValidationError } from '../errorMessage';
describe('errorMessage', () => {
it('extracts readable messages from structured errors', () => {
expect(getErrorMessage({ message: 'Invalid access key' })).toBe(
'Invalid access key',
);
expect(getErrorMessage({ error: 'Access denied' })).toBe('Access denied');
expect(getErrorMessage({ detail: 'Bucket not found' })).toBe(
'Bucket not found',
);
});
it('detects Ant Design form validation rejections', () => {
expect(
isFormValidationError({
values: {},
errorFields: [{ name: ['bucket'], errors: ['Bucket is required'] }],
}),
).toBe(true);
expect(isFormValidationError({ message: 'network failed' })).toBe(false);
});
});
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest';
import { resolveModelParamDefaults } from '../modelParams';
import type { AppSettings, Model } from '@/types';
type ModelForDefaults = Pick<Model, 'param_overrides'>;
type SettingsForDefaults = Pick<
AppSettings,
| 'default_temperature'
| 'default_top_p'
| 'default_max_tokens'
| 'default_frequency_penalty'
>;
function modelWithOverrides(overrides: Model['param_overrides']): ModelForDefaults {
return { param_overrides: overrides };
}
const globalDefaults: SettingsForDefaults = {
default_temperature: 0.9,
default_top_p: 0.95,
default_max_tokens: 32768,
default_frequency_penalty: 1.2,
};
describe('resolveModelParamDefaults', () => {
it('prefers selected model sampling params but not model max token presets', () => {
const defaults = resolveModelParamDefaults(
modelWithOverrides({
temperature: 0.2,
top_p: 0.8,
max_tokens: 4096,
frequency_penalty: 0.4,
}),
globalDefaults,
);
expect(defaults).toEqual({
temperature: 0.2,
topP: 0.8,
maxTokens: 32768,
frequencyPenalty: 0.4,
});
});
it('falls back to global defaults and then built-in defaults', () => {
expect(resolveModelParamDefaults(modelWithOverrides(null), globalDefaults)).toEqual({
temperature: 0.9,
topP: 0.95,
maxTokens: 32768,
frequencyPenalty: 1.2,
});
expect(resolveModelParamDefaults(null, null)).toEqual({
temperature: 0.7,
topP: 1,
maxTokens: 4096,
frequencyPenalty: 0,
});
});
});
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest';
import { parseProviderDeepLink } from '../providerDeepLink';
describe('parseProviderDeepLink', () => {
it('parses provider import payload from boosapi scheme URL', () => {
const payload = parseProviderDeepLink(
'boosapi://providers?name=Example%20AI&baseurl=https%3A%2F%2Fapi.example.com%2Fv1!&apikey=sk-test&type=openai',
);
expect(payload).toEqual({
name: 'Example AI',
baseurl: 'https://api.example.com/v1!',
apikey: 'sk-test',
type: 'openai',
});
});
it('parses provider import payload from triple-slash URL', () => {
const payload = parseProviderDeepLink(
'boosapi:///providers?name=Claude&baseurl=https%3A%2F%2Fapi.anthropic.com&apikey=sk-ant&type=anthropic',
);
expect(payload).toEqual({
name: 'Claude',
baseurl: 'https://api.anthropic.com',
apikey: 'sk-ant',
type: 'anthropic',
});
});
it('accepts dedicated OpenAI-compatible provider types', () => {
for (const type of ['deepseek', 'xai', 'glm', 'siliconflow'] as const) {
const payload = parseProviderDeepLink(
`boosapi://providers?name=${type}&baseurl=https%3A%2F%2Fapi.example.com&apikey=sk-test&type=${type}`,
);
expect(payload?.type).toBe(type);
}
});
it('ignores links that are not provider imports', () => {
expect(parseProviderDeepLink('boosapi://chat?name=Example')).toBeNull();
expect(parseProviderDeepLink('https://example.com')).toBeNull();
});
it('ignores links missing required provider parameters', () => {
expect(parseProviderDeepLink('boosapi://providers?name=Example&baseurl=https%3A%2F%2Fapi.example.com&apikey=sk')).toBeNull();
expect(parseProviderDeepLink('boosapi://providers?name=Example&baseurl=https%3A%2F%2Fapi.example.com&type=openai')).toBeNull();
});
});
@@ -0,0 +1,119 @@
import { describe, expect, it } from 'vitest';
import { resolveReasoningProfile, resolveReasoningRequest } from '../reasoningProfile';
import type { Model, ProviderType } from '@/types';
function model(modelId: string, overrides: Model['param_overrides'] = null): Model {
return {
provider_id: 'provider-1',
model_id: modelId,
name: modelId,
model_type: 'Chat',
capabilities: ['Reasoning'],
max_tokens: 128000,
enabled: true,
param_overrides: overrides,
};
}
function optionKeys(providerType: ProviderType, modelId: string, overrides: Model['param_overrides'] = null) {
return resolveReasoningProfile(providerType, model(modelId, overrides)).options.map((option) => option.key);
}
describe('reasoning profile resolution', () => {
it('uses OpenAI reasoning effort options for GPT-5 models', () => {
const profile = resolveReasoningProfile('openai', model('gpt-5.1'));
expect(profile.apiStyle).toBe('openai_reasoning_effort');
expect(profile.options.map((option) => option.key)).toEqual(['default', 'none', 'low', 'medium', 'high', 'xhigh']);
expect(resolveReasoningRequest(profile, 'xhigh')).toEqual({
level: 'xhigh',
apiStyle: 'openai_reasoning_effort',
reasoningEffort: 'xhigh',
suppressSamplingParams: true,
});
});
it('uses Gemini thinkingLevel options and removes minimal for 3.1 Pro', () => {
expect(optionKeys('gemini', 'gemini-3.1-flash')).toEqual(['default', 'minimal', 'low', 'medium', 'high']);
expect(optionKeys('gemini', 'gemini-3-flash-preview')).toEqual(['default', 'minimal', 'low', 'medium', 'high']);
expect(optionKeys('gemini', 'gemini-3.1-pro')).toEqual(['default', 'low', 'medium', 'high']);
const profile = resolveReasoningProfile('gemini', model('gemini-3.1-flash'));
expect(resolveReasoningRequest(profile, 'minimal')).toMatchObject({
level: 'minimal',
apiStyle: 'gemini_thinking_level',
thinkingLevel: 'minimal',
});
});
it('uses Anthropic adaptive levels for direct Claude Opus model families', () => {
expect(optionKeys('anthropic', 'claude-opus-4.6')).toEqual(['default', 'off', 'low', 'medium', 'high', 'max']);
expect(optionKeys('anthropic', 'claude-opus-4.7')).toEqual(['default', 'off', 'low', 'medium', 'high', 'xhigh', 'max']);
const profile = resolveReasoningProfile('anthropic', model('claude-opus-4.7'));
expect(resolveReasoningRequest(profile, 'max')).toMatchObject({
level: 'max',
apiStyle: 'anthropic_adaptive',
reasoningEffort: 'max',
suppressSamplingParams: true,
});
});
it('lets model overrides force Vertex-compatible Claude budget tokens', () => {
const profile = resolveReasoningProfile(
'custom',
model('claude-opus-4.7@vertex', { reasoning_profile: 'anthropic_budget_tokens' }),
);
expect(profile.apiStyle).toBe('anthropic_budget_tokens');
expect(profile.options.map((option) => option.key)).toEqual(['default', 'off', 'low', 'medium', 'high', 'xhigh', 'max']);
expect(resolveReasoningRequest(profile, 'high')).toMatchObject({
level: 'high',
apiStyle: 'anthropic_budget_tokens',
budgetTokens: 8192,
suppressSamplingParams: true,
});
});
it('ignores unknown reasoning profile overrides', () => {
const profile = resolveReasoningProfile('gemini', model('gemini-3.1-flash', { reasoning_profile: 'unknown' }));
expect(profile.apiStyle).toBe('gemini_thinking_level');
expect(profile.options.map((option) => option.key)).toEqual(['default', 'minimal', 'low', 'medium', 'high']);
});
it('uses dedicated OpenAI-compatible provider profiles', () => {
expect(optionKeys('deepseek', 'deepseek-v4-flash')).toEqual(['default', 'none', 'high', 'max']);
expect(optionKeys('xai', 'grok-4.3')).toEqual(['default', 'none', 'low', 'medium', 'high']);
expect(optionKeys('xai', 'grok-3-mini')).toEqual(['default']);
expect(optionKeys('glm', 'glm-4.6')).toEqual(['default', 'none', 'high']);
expect(optionKeys('siliconflow', 'Qwen/Qwen3-235B-A22B')).toEqual(['default', 'none', 'low', 'medium', 'high']);
const deepSeekProfile = resolveReasoningProfile('deepseek', model('deepseek-v4-flash'));
expect(deepSeekProfile.options.map((option) => option.key)).not.toContain('low');
expect(deepSeekProfile.options.map((option) => option.key)).not.toContain('medium');
expect(deepSeekProfile.options.map((option) => option.key)).not.toContain('xhigh');
expect(resolveReasoningRequest(deepSeekProfile, 'max')).toMatchObject({
level: 'max',
apiStyle: 'openai_reasoning_effort',
reasoningEffort: 'max',
suppressSamplingParams: true,
});
const glmProfile = resolveReasoningProfile('glm', model('glm-4.6'));
expect(resolveReasoningRequest(glmProfile, 'high')).toEqual({
level: 'high',
apiStyle: 'glm_thinking',
suppressSamplingParams: true,
});
const siliconFlowProfile = resolveReasoningProfile('siliconflow', model('Qwen/Qwen3-235B-A22B'));
expect(resolveReasoningRequest(siliconFlowProfile, 'medium')).toMatchObject({
level: 'medium',
apiStyle: 'siliconflow_enable_thinking',
enableThinking: true,
budgetTokens: 4096,
suppressSamplingParams: true,
});
});
});
@@ -0,0 +1,180 @@
import { describe, expect, it } from 'vitest';
import type { Message } from '@/types';
import {
buildContextualSearchQuery,
buildSearchQueryTag,
buildSearchTag,
formatSearchContent,
parseSearchContent,
} from '../searchUtils';
function makeMessage(overrides: Partial<Message> & Pick<Message, 'id' | 'role' | 'content'>): Message {
return {
id: overrides.id,
conversation_id: 'conv-1',
role: overrides.role,
content: overrides.content,
provider_id: overrides.provider_id ?? null,
model_id: overrides.model_id ?? null,
token_count: overrides.token_count ?? null,
prompt_tokens: overrides.prompt_tokens ?? null,
completion_tokens: overrides.completion_tokens ?? null,
attachments: overrides.attachments ?? [],
thinking: overrides.thinking ?? null,
tool_calls_json: overrides.tool_calls_json ?? null,
tool_call_id: overrides.tool_call_id ?? null,
created_at: overrides.created_at ?? 1,
parent_message_id: overrides.parent_message_id ?? null,
version_index: overrides.version_index ?? 0,
is_active: overrides.is_active ?? true,
status: overrides.status ?? 'complete',
tokens_per_second: overrides.tokens_per_second ?? null,
first_token_latency_ms: overrides.first_token_latency_ms ?? null,
};
}
describe('buildContextualSearchQuery', () => {
it('combines recent conversation context with the current follow-up question', () => {
const query = buildContextualSearchQuery([
makeMessage({
id: 'user-1',
role: 'user',
content: '我想修改网站权限,让浏览器允许访问某个页面。',
}),
makeMessage({
id: 'assistant-1',
role: 'assistant',
content: '可以在浏览器站点设置里调整权限。',
}),
], '我已经给你权限了,继续怎么操作?');
expect(query).toContain('我想修改网站权限');
expect(query).toContain('浏览器站点设置');
expect(query).toContain('我已经给你权限了,继续怎么操作?');
});
it('uses the previous user search intent instead of current permission meta instructions', () => {
const query = buildContextualSearchQuery([
makeMessage({
id: 'user-1',
role: 'user',
content: '帮我搜索 BoosApi Desktop 0.0.76 Windows 下载地址。',
}),
makeMessage({
id: 'assistant-1',
role: 'assistant',
content: '需要联网搜索才能确认下载地址。',
}),
], '没事,给你权限了,你可以搜索和打开任何网页了');
expect(query).toContain('BoosApi Desktop 0.0.76 Windows 下载地址');
expect(query).not.toContain('给你权限');
expect(query).not.toContain('打开任何网页');
expect(query).not.toContain('联网搜索才能确认');
});
it('strips old search, RAG, think, and MCP display content from history', () => {
const oldSearchContent = formatSearchContent([
{
title: '旧搜索标题',
url: 'https://example.com/old-search',
content: 'OLD_SEARCH_BODY_SHOULD_NOT_APPEAR',
},
], '上一轮原始问题');
const assistantDisplay = [
buildSearchTag('done', [
{
title: '助手搜索卡片',
url: 'https://example.com/card',
content: 'ASSISTANT_SEARCH_CARD_SHOULD_NOT_APPEAR',
},
]),
'<knowledge-retrieval status="done" data-boosapi="1">KB_SHOULD_NOT_APPEAR</knowledge-retrieval>',
'<memory-retrieval status="done" data-boosapi="1">MEMORY_SHOULD_NOT_APPEAR</memory-retrieval>',
'<think>THINK_SHOULD_NOT_APPEAR</think>',
':::mcp call\nMCP_SHOULD_NOT_APPEAR\n:::\n',
'上一轮最终回答',
].join('\n\n');
const query = buildContextualSearchQuery([
makeMessage({ id: 'user-1', role: 'user', content: oldSearchContent }),
makeMessage({ id: 'assistant-1', role: 'assistant', content: assistantDisplay }),
], '继续搜索这个主题');
expect(query).toContain('上一轮原始问题');
expect(query).toContain('上一轮最终回答');
expect(query).toContain('继续搜索这个主题');
expect(query).not.toContain('OLD_SEARCH_BODY_SHOULD_NOT_APPEAR');
expect(query).not.toContain('https://example.com/old-search');
expect(query).not.toContain('ASSISTANT_SEARCH_CARD_SHOULD_NOT_APPEAR');
expect(query).not.toContain('KB_SHOULD_NOT_APPEAR');
expect(query).not.toContain('MEMORY_SHOULD_NOT_APPEAR');
expect(query).not.toContain('THINK_SHOULD_NOT_APPEAR');
expect(query).not.toContain('MCP_SHOULD_NOT_APPEAR');
});
it('returns the current question when there is no usable history', () => {
const query = buildContextualSearchQuery([
makeMessage({ id: 'system-1', role: 'system', content: 'system prompt' }),
makeMessage({ id: 'error-1', role: 'assistant', content: 'broken', status: 'error' }),
makeMessage({ id: 'partial-1', role: 'assistant', content: 'loading', status: 'partial' }),
], '当前问题');
expect(query).toBe('当前问题');
});
it('limits context size and only uses the latest six complete user or assistant messages', () => {
const messages = Array.from({ length: 8 }, (_, index) => makeMessage({
id: `message-${index + 1}`,
role: index % 2 === 0 ? 'user' : 'assistant',
content: `历史消息${index + 1} ${'很长的上下文'.repeat(80)}`,
created_at: index + 1,
}));
const query = buildContextualSearchQuery(messages, `当前问题 ${'需要检索'.repeat(200)}`);
expect(query).not.toContain('历史消息1');
expect(query).not.toContain('历史消息2');
expect(query).toContain('历史消息3');
expect(query).toContain('历史消息8');
expect(query).toContain('当前问题');
expect(query.length).toBeLessThanOrEqual(1800);
});
});
describe('buildSearchTag', () => {
it('renders summarizing status and escapes summarized query attributes', () => {
expect(buildSearchQueryTag('summarizing')).toContain('status="summarizing"');
const tag = buildSearchQueryTag('done', 'BoosApi "Windows" <download>');
expect(tag).toContain('status="done"');
expect(tag).toContain('query="BoosApi &quot;Windows&quot; &lt;download&gt;"');
});
it('renders query summary failure details', () => {
const tag = buildSearchQueryTag('error', undefined, '模型返回 <empty>');
expect(tag).toContain('status="error"');
expect(tag).toContain('模型返回 &lt;empty&gt;');
});
});
describe('search content metadata', () => {
it('stores and parses the summarized search query without changing displayed user content', () => {
const content = formatSearchContent([
{
title: 'BoosApi Release',
url: 'https://example.com/boosapi',
content: 'download',
},
], '原始问题', {
query: 'BoosApi Desktop 下载',
});
const parsed = parseSearchContent(content);
expect(parsed.query).toBe('BoosApi Desktop 下载');
expect(parsed.userContent).toBe('原始问题');
});
});
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import type { AppSettings } from '@/types';
import {
DEFAULT_SHORTCUT_BINDINGS,
SHORTCUT_ACTIONS,
SHORTCUT_SETTING_ACTIONS,
getShortcutBinding,
matchesShortcutEvent,
} from '../shortcuts';
describe('shortcuts', () => {
it('exposes send message as a configurable input shortcut without global handling', () => {
expect(DEFAULT_SHORTCUT_BINDINGS.sendMessage).toBe('Enter');
expect(SHORTCUT_SETTING_ACTIONS).toContain('sendMessage');
expect(SHORTCUT_ACTIONS).not.toContain('sendMessage');
});
it('matches the configured send message shortcut', () => {
const defaultBinding = DEFAULT_SHORTCUT_BINDINGS.sendMessage;
expect(matchesShortcutEvent(new KeyboardEvent('keydown', { key: 'Enter' }), defaultBinding)).toBe(true);
expect(matchesShortcutEvent(new KeyboardEvent('keydown', { key: 'Enter', shiftKey: true }), defaultBinding)).toBe(false);
const settings = { shortcut_send_message: 'CmdOrCtrl+Enter' } as AppSettings;
const customBinding = getShortcutBinding(settings, 'sendMessage');
expect(matchesShortcutEvent(new KeyboardEvent('keydown', { key: 'Enter', ctrlKey: true }), customBinding)).toBe(true);
expect(matchesShortcutEvent(new KeyboardEvent('keydown', { key: 'Enter' }), customBinding)).toBe(false);
});
});
@@ -0,0 +1,43 @@
import { describe, expect, it, vi } from 'vitest';
import {
formatStartupError,
installStartupDiagnostics,
renderStartupError,
} from '@/lib/startupDiagnostics';
describe('startupDiagnostics', () => {
it('formats errors with stack or message details', () => {
const error = new Error('module failed');
expect(formatStartupError(error)).toContain('module failed');
});
it('renders a visible startup error panel with log instructions', () => {
const root = document.createElement('div');
renderStartupError(root, new Error('bootstrap failed'));
expect(root.textContent).toContain('BoosAPI Desktop startup failed');
expect(root.textContent).toContain('bootstrap failed');
expect(root.textContent).toContain('BOOSAPI_LOG_FILE');
});
it('logs window startup errors through the provided writer', () => {
const writeLog = vi.fn();
const cleanup = installStartupDiagnostics(writeLog);
const event = new ErrorEvent('error', {
message: 'render exploded',
filename: 'index.js',
lineno: 10,
colno: 20,
});
window.dispatchEvent(event);
cleanup();
expect(writeLog).toHaveBeenCalledWith(
'error',
expect.stringContaining('render exploded'),
);
});
});
File diff suppressed because it is too large Load Diff
+132
View File
@@ -0,0 +1,132 @@
const HTML_RENDER_START_MARKER = '<!-- html-render-start -->';
const HTML_RENDER_END_MARKER = '<!-- html-render-end -->';
const HTML_RENDER_OPEN_RE = /<html-render\b[^>]*>/gi;
const HTML_RENDER_TAG_RE = /<\/?html-render\b[^>]*>/gi;
export type HtmlRenderNormalizeOptions = {
final?: boolean;
};
function escapeHtml(value: string) {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function findUnmatchedHtmlRenderStart(content: string) {
let depth = 0;
let unmatchedStart = -1;
HTML_RENDER_TAG_RE.lastIndex = 0;
for (;;) {
const match = HTML_RENDER_TAG_RE.exec(content);
if (!match) break;
const raw = match[0].toLowerCase();
if (raw.startsWith('</')) {
if (depth > 0) {
depth -= 1;
if (depth === 0) unmatchedStart = -1;
}
continue;
}
depth += 1;
if (depth === 1) unmatchedStart = match.index;
}
return depth > 0 ? unmatchedStart : -1;
}
function closeStreamingHtmlRender(content: string) {
const startIndex = findUnmatchedHtmlRenderStart(content);
if (startIndex === -1) return content;
HTML_RENDER_OPEN_RE.lastIndex = startIndex;
const openMatch = HTML_RENDER_OPEN_RE.exec(content);
if (!openMatch || openMatch.index !== startIndex) {
return `${content}</html-render>`;
}
const openEnd = startIndex + openMatch[0].length;
const needsBlockBoundary = content[openEnd] && content[openEnd] !== '\n';
return `${content.slice(0, openEnd)}${needsBlockBoundary ? '\n' : ''}${content.slice(openEnd)}</html-render>`;
}
export function normalizeHtmlRenderMarkers(
content: string,
options: HtmlRenderNormalizeOptions = {},
) {
if (!content.includes(HTML_RENDER_START_MARKER)) return content;
const final = options.final ?? true;
let index = 0;
let result = '';
for (;;) {
const startIndex = content.indexOf(HTML_RENDER_START_MARKER, index);
if (startIndex === -1) {
result += content.slice(index);
break;
}
const contentStart = startIndex + HTML_RENDER_START_MARKER.length;
const endIndex = content.indexOf(HTML_RENDER_END_MARKER, contentStart);
result += content.slice(index, startIndex);
if (endIndex === -1) {
result += final
? content.slice(startIndex)
: `<html-render>${content.slice(contentStart)}`;
break;
}
result += `<html-render>${content.slice(contentStart, endIndex)}</html-render>`;
index = endIndex + HTML_RENDER_END_MARKER.length;
}
return result;
}
export function shouldFallbackIncompleteHtmlRender(
content: string,
options: HtmlRenderNormalizeOptions = {},
) {
return Boolean(options.final) && findUnmatchedHtmlRenderStart(content) !== -1;
}
export function normalizeHtmlRenderContent(
content: string,
options: HtmlRenderNormalizeOptions = {},
) {
const normalized = normalizeHtmlRenderMarkers(content, options);
if (!options.final && findUnmatchedHtmlRenderStart(normalized) !== -1) {
return closeStreamingHtmlRender(normalized);
}
if (!shouldFallbackIncompleteHtmlRender(normalized, options)) {
return normalized;
}
const startIndex = findUnmatchedHtmlRenderStart(normalized);
if (startIndex === -1) return normalized;
return `${normalized.slice(0, startIndex)}${escapeHtml(normalized.slice(startIndex))}`;
}
export function getHtmlRenderInnerContent(node: {
content?: unknown;
raw?: unknown;
}) {
const content = String(node.content ?? '');
if (content) return content;
const raw = String(node.raw ?? '');
const openMatch = raw.match(HTML_RENDER_OPEN_RE);
if (!openMatch) return raw;
return raw
.replace(HTML_RENDER_OPEN_RE, '')
.replace(/<\/html-render>\s*$/i, '');
}
+218
View File
@@ -0,0 +1,218 @@
import { invoke, isTauri } from './invoke';
import { Image as TauriImage } from '@tauri-apps/api/image';
const IMAGE_MIME_EXTENSIONS: Record<string, string> = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/jpg': 'jpg',
'image/gif': 'gif',
'image/webp': 'webp',
'image/svg+xml': 'svg',
'image/bmp': 'bmp',
'image/x-icon': 'ico',
'image/vnd.microsoft.icon': 'ico',
'image/avif': 'avif',
};
const IMAGE_SAVE_EXTENSIONS = Array.from(new Set(Object.values(IMAGE_MIME_EXTENSIONS)));
type RemoteImageResponse = {
data: string;
mimeType: string;
};
function isHttpImageSource(src: string) {
return /^https?:\/\//i.test(src.trim());
}
function base64ToBytes(base64: string) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
function getMimeTypeFromDataUrl(src: string) {
const match = src.match(/^data:([^;,]+)[;,]/i);
return match?.[1]?.toLowerCase() ?? null;
}
function getExtensionFromUrl(src: string) {
try {
const url = new URL(src);
const match = url.pathname.match(/\.([a-z0-9]+)$/i);
return match?.[1]?.toLowerCase() ?? null;
} catch {
const match = src.split('?')[0]?.match(/\.([a-z0-9]+)$/i);
return match?.[1]?.toLowerCase() ?? null;
}
}
function getExtensionForImage(src: string, mimeType?: string | null) {
const normalizedMime = mimeType?.toLowerCase();
if (normalizedMime && IMAGE_MIME_EXTENSIONS[normalizedMime]) {
return IMAGE_MIME_EXTENSIONS[normalizedMime];
}
const dataMime = getMimeTypeFromDataUrl(src);
if (dataMime && IMAGE_MIME_EXTENSIONS[dataMime]) {
return IMAGE_MIME_EXTENSIONS[dataMime];
}
const urlExtension = getExtensionFromUrl(src);
return urlExtension && IMAGE_SAVE_EXTENSIONS.includes(urlExtension) ? urlExtension : 'png';
}
function sanitizeFilenamePart(value: string) {
const normalized = value
.trim()
.replace(/[\\/:*?"<>|]+/g, '-')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
return normalized || 'boosapi-image';
}
function ensureImageExtension(filename: string, src: string, mimeType?: string | null) {
const extension = getExtensionForImage(src, mimeType);
return /\.[a-z0-9]+$/i.test(filename) ? filename : `${filename}.${extension}`;
}
function browserDownloadBlob(filename: string, blob: Blob) {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
function loadImageFromBlob(blob: Blob) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const url = URL.createObjectURL(blob);
const image = new Image();
image.onload = () => {
URL.revokeObjectURL(url);
resolve(image);
};
image.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('Failed to decode image.'));
};
image.src = url;
});
}
function canvasToPngBlob(canvas: HTMLCanvasElement) {
return new Promise<Blob>((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
return;
}
reject(new Error('Failed to encode image.'));
}, 'image/png');
});
}
async function convertBlobToPng(blob: Blob) {
if (blob.type === 'image/png') return blob;
const image = await loadImageFromBlob(blob);
const width = image.naturalWidth || image.width;
const height = image.naturalHeight || image.height;
if (!width || !height) {
throw new Error('Image has no drawable size.');
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
if (!context) {
throw new Error('Canvas is not available.');
}
context.drawImage(image, 0, 0, width, height);
return canvasToPngBlob(canvas);
}
async function blobToBytes(blob: Blob) {
return new Uint8Array(await blob.arrayBuffer());
}
export function getDefaultImageFilename(src: string, alt?: string | null) {
const base = sanitizeFilenamePart(alt || 'boosapi-image');
return ensureImageExtension(base, src, getMimeTypeFromDataUrl(src));
}
export async function resolveImageBlob(src: string) {
if (!src) {
throw new Error('Image source is empty.');
}
if (isTauri() && isHttpImageSource(src)) {
const response = await invoke<RemoteImageResponse>('fetch_remote_image', { url: src });
return new Blob([base64ToBytes(response.data)], { type: response.mimeType });
}
const response = await fetch(src);
if (!response.ok) {
throw new Error(`Failed to fetch image: ${response.status}`);
}
const blob = await response.blob();
const mimeType = blob.type || getMimeTypeFromDataUrl(src) || 'application/octet-stream';
if (!mimeType.toLowerCase().startsWith('image/')) {
throw new Error('The image source did not return an image.');
}
return blob.type ? blob : new Blob([await blob.arrayBuffer()], { type: mimeType });
}
export async function copyChatImage(src: string) {
const sourceBlob = await resolveImageBlob(src);
const pngBlob = await convertBlobToPng(sourceBlob);
const pngBytes = await blobToBytes(pngBlob);
if (isTauri()) {
const { writeImage } = await import('@tauri-apps/plugin-clipboard-manager');
const image = await TauriImage.fromBytes(pngBytes);
await writeImage(image);
return;
}
if (navigator.clipboard && typeof navigator.clipboard.write === 'function' && typeof ClipboardItem !== 'undefined') {
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })]);
return;
}
throw new Error('Image clipboard is not supported in this environment.');
}
export async function saveChatImage(src: string, defaultName = getDefaultImageFilename(src)) {
const blob = await resolveImageBlob(src);
const filename = ensureImageExtension(defaultName, src, blob.type);
const bytes = await blobToBytes(blob);
if (isTauri()) {
const [{ save }, { writeFile }] = await Promise.all([
import('@tauri-apps/plugin-dialog'),
import('@tauri-apps/plugin-fs'),
]);
const filePath = await save({
defaultPath: filename,
filters: [{ name: 'Images', extensions: IMAGE_SAVE_EXTENSIONS }],
});
if (!filePath) return false;
await writeFile(filePath, bytes);
return true;
}
browserDownloadBlob(filename, blob);
return true;
}
+299
View File
@@ -0,0 +1,299 @@
import { getMarkdown, parseMarkdownToStructure, type BaseNode } from 'stream-markdown-parser';
import { normalizeHtmlRenderContent } from './chatHtmlRender';
import { normalizeThinkTagsForMarkdown } from './thinkTags';
export type ChatMarkdownNode = BaseNode;
export const CHAT_CUSTOM_HTML_TAGS = ['think', 'web-search-query', 'web-search', 'knowledge-retrieval', 'memory-retrieval', 'tool-call', 'html-render', 'img'] as const;
const BOOSAPI_DISPLAY_TAGS = new Set([
'web-search-query',
'web-search',
'knowledge-retrieval',
'memory-retrieval',
'tool-call',
]);
const LONG_CONTENT_PLAIN_TEXT_THRESHOLD = 100_000;
const UNBALANCED_HTML_TAG_THRESHOLD = 20;
export type StripBoosapiTagsOptions = {
stripThink?: boolean;
};
export type ChatContentPlainTextOptions = {
role: 'user' | 'assistant' | 'system' | 'tool';
isStreaming?: boolean;
};
type SafeParseOptions = {
parse?: (content: string) => ChatMarkdownNode[];
};
function isTagNameChar(ch: string) {
return /[a-zA-Z0-9_-]/.test(ch);
}
function isWhitespace(ch: string) {
return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r' || ch === '\f';
}
function findTagEnd(content: string, startIndex: number) {
let quote: string | null = null;
for (let index = startIndex; index < content.length; index += 1) {
const ch = content[index];
if (quote) {
if (ch === quote) quote = null;
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
continue;
}
if (ch === '>') return index;
}
return -1;
}
function parseHtmlLikeTag(content: string, startIndex: number) {
if (content[startIndex] !== '<') return null;
let index = startIndex + 1;
let closing = false;
while (index < content.length && isWhitespace(content[index])) index += 1;
if (content[index] === '/') {
closing = true;
index += 1;
while (index < content.length && isWhitespace(content[index])) index += 1;
}
const nameStart = index;
while (index < content.length && isTagNameChar(content[index])) index += 1;
if (index === nameStart) return null;
const name = content.slice(nameStart, index).toLowerCase();
const next = content[index] ?? '';
if (next && !isWhitespace(next) && next !== '/' && next !== '>') return null;
const end = findTagEnd(content, index);
if (end === -1) return null;
const raw = content.slice(startIndex, end + 1);
return {
name,
raw,
closing,
selfClosing: /\/\s*>$/.test(raw),
end,
};
}
function hasBoosapiDataAttr(openTag: string) {
return /\bdata-boosapi\s*=\s*(?:"1"|'1'|1)(?=\s|\/|>)/i.test(openTag);
}
function findClosingTagEnd(content: string, tagName: string, fromIndex: number) {
const lower = content.toLowerCase();
const needle = `</${tagName}`;
let index = fromIndex;
for (;;) {
const closeStart = lower.indexOf(needle, index);
if (closeStart === -1) return null;
const next = lower[closeStart + needle.length] ?? '';
if (next && !isWhitespace(next) && next !== '>') {
index = closeStart + needle.length;
continue;
}
const closeEnd = findTagEnd(content, closeStart + needle.length);
if (closeEnd === -1) return null;
return closeEnd;
}
}
function consumeTrailingWhitespace(content: string, fromIndex: number) {
let index = fromIndex;
while (index < content.length && isWhitespace(content[index])) index += 1;
return index;
}
function stripCompleteBoosapiHtmlTags(content: string, options: StripBoosapiTagsOptions) {
const stripThink = options.stripThink ?? true;
let output = '';
let cursor = 0;
while (cursor < content.length) {
const openStart = content.indexOf('<', cursor);
if (openStart === -1) {
output += content.slice(cursor);
break;
}
const tag = parseHtmlLikeTag(content, openStart);
if (!tag || tag.closing || tag.selfClosing) {
output += content.slice(cursor, openStart + 1);
cursor = openStart + 1;
continue;
}
const shouldStrip = tag.name === 'think'
? stripThink
: BOOSAPI_DISPLAY_TAGS.has(tag.name) && hasBoosapiDataAttr(tag.raw);
if (!shouldStrip) {
output += content.slice(cursor, tag.end + 1);
cursor = tag.end + 1;
continue;
}
const closeEnd = findClosingTagEnd(content, tag.name, tag.end + 1);
if (closeEnd === null) {
output += content.slice(cursor, tag.end + 1);
cursor = tag.end + 1;
continue;
}
output += content.slice(cursor, openStart);
cursor = consumeTrailingWhitespace(content, closeEnd + 1);
}
return output;
}
function findCompleteMcpFenceEnd(content: string, fromIndex: number) {
let lineStart = fromIndex;
while (lineStart < content.length) {
const lineEnd = content.indexOf('\n', lineStart);
const end = lineEnd === -1 ? content.length : lineEnd;
if (content.slice(lineStart, end).trim() === ':::') {
return lineEnd === -1 ? end : lineEnd + 1;
}
if (lineEnd === -1) return null;
lineStart = lineEnd + 1;
}
return null;
}
function stripCompleteMcpFences(content: string) {
let output = '';
let cursor = 0;
while (cursor < content.length) {
const start = content.indexOf(':::mcp ', cursor);
if (start === -1) {
output += content.slice(cursor);
break;
}
if (start > 0 && content[start - 1] !== '\n') {
output += content.slice(cursor, start + 1);
cursor = start + 1;
continue;
}
const openLineEnd = content.indexOf('\n', start);
if (openLineEnd === -1) {
output += content.slice(cursor);
break;
}
const fenceEnd = findCompleteMcpFenceEnd(content, openLineEnd + 1);
if (fenceEnd === null) {
output += content.slice(cursor);
break;
}
output += content.slice(cursor, start);
if (output && !output.endsWith('\n') && fenceEnd < content.length && content[fenceEnd] !== '\n') {
output += '\n';
}
cursor = fenceEnd;
}
return output;
}
function countHtmlLikeTagBalance(content: string) {
let openCount = 0;
let closeCount = 0;
let index = 0;
while (index < content.length) {
const start = content.indexOf('<', index);
if (start === -1) break;
const tag = parseHtmlLikeTag(content, start);
if (!tag) {
index = start + 1;
continue;
}
if (tag.closing) closeCount += 1;
else if (!tag.selfClosing) openCount += 1;
index = tag.end + 1;
}
return { openCount, closeCount };
}
function plainTextNodes(content: string): ChatMarkdownNode[] {
return [{
type: 'paragraph',
children: [{
type: 'text',
content,
raw: content,
}],
raw: content,
} as ChatMarkdownNode];
}
/**
* Strip all boosapi-injected custom tags (with `data-boosapi="1"` attribute) and
* MCP tool call fenced blocks (`:::mcp ... :::`) from content.
* Used when copying message text so display-only tags don't pollute the clipboard.
*/
export function stripBoosapiTags(
content: string,
options: StripBoosapiTagsOptions = {},
): string {
return stripCompleteMcpFences(stripCompleteBoosapiHtmlTags(content, options)).trim();
}
export function shouldUsePlainTextChatContent(
content: string,
options: ChatContentPlainTextOptions,
): boolean {
if (options.role !== 'user' || options.isStreaming || content.length < LONG_CONTENT_PLAIN_TEXT_THRESHOLD) {
return false;
}
const { openCount, closeCount } = countHtmlLikeTagBalance(content);
return openCount - closeCount >= UNBALANCED_HTML_TAG_THRESHOLD;
}
const chatMarkdown = getMarkdown('boosapi-chat', {
customHtmlTags: CHAT_CUSTOM_HTML_TAGS,
});
function unwrapStandaloneHtmlRenderNodes(nodes: ChatMarkdownNode[]) {
return nodes.map((node) => {
const children = (node as { children?: ChatMarkdownNode[] }).children;
if (node.type === 'paragraph' && children?.length === 1 && children[0]?.type === 'html-render') {
return children[0];
}
return node;
});
}
export function parseChatMarkdown(content: string): ChatMarkdownNode[] {
const nodes = parseMarkdownToStructure(normalizeHtmlRenderContent(normalizeThinkTagsForMarkdown(content), { final: true }), chatMarkdown, {
customHtmlTags: [...CHAT_CUSTOM_HTML_TAGS],
final: true,
});
return unwrapStandaloneHtmlRenderNodes(nodes);
}
export function safeParseChatMarkdown(
content: string,
options: SafeParseOptions = {},
): ChatMarkdownNode[] {
try {
return (options.parse ?? parseChatMarkdown)(content);
} catch (error) {
console.error('Failed to parse chat markdown:', error);
return plainTextNodes(content);
}
}
+324
View File
@@ -0,0 +1,324 @@
import type { Message } from '@/types';
export function getMessageVersionGroupKey(version: Message): string {
if (version.model_id) {
return `${version.provider_id ?? '__provider__'}:${version.model_id}`;
}
if (version.provider_id) {
return `${version.provider_id}:${version.id}`;
}
return `__message__:${version.id}`;
}
export function getLatestVersionsByModel(versions: Message[]): Message[] {
const modelMap = new Map<string, Message>();
for (const version of versions) {
const key = getMessageVersionGroupKey(version);
const existing = modelMap.get(key);
if (!existing || version.version_index > existing.version_index) {
modelMap.set(key, version);
}
}
return Array.from(modelMap.values());
}
export function selectDisplayVersionsByModel(
versions: Message[],
activeMessageId?: string | null,
displayMessageIdsByModelKey?: ReadonlyMap<string, string> | Record<string, string | undefined> | null,
): Message[] {
const modelMap = new Map<string, { latest: Message; active: Message | null; selected: Message | null }>();
for (const version of versions) {
const key = getMessageVersionGroupKey(version);
const existing = modelMap.get(key);
const selectedMessageId = getDisplayMessageId(displayMessageIdsByModelKey, key);
const isActiveVersion = activeMessageId
? version.id === activeMessageId
: version.is_active;
if (!existing) {
modelMap.set(key, {
latest: version,
active: isActiveVersion ? version : null,
selected: selectedMessageId === version.id ? version : null,
});
continue;
}
if (compareVersionDesc(version, existing.latest) < 0) {
existing.latest = version;
}
if (isActiveVersion) {
existing.active = version;
}
if (selectedMessageId === version.id) {
existing.selected = version;
}
}
return Array.from(modelMap.values()).map((group) => group.selected ?? group.active ?? group.latest);
}
export interface PendingDisplayVersionSelection {
messageId: string;
versionIndex: number;
createdAt: number;
}
export function resolvePendingDisplayVersionSelection(
versions: Message[],
modelKey: string,
selectedMessageId: string | null | undefined,
pending: PendingDisplayVersionSelection | null | undefined,
): string | null {
if (!selectedMessageId) return null;
if (versions.some((version) => version.id === selectedMessageId)) {
return selectedMessageId;
}
if (!pending || pending.messageId !== selectedMessageId) {
return selectedMessageId;
}
const resolved = versions
.filter((version) =>
getMessageVersionGroupKey(version) === modelKey
&& version.version_index >= pending.versionIndex
)
.sort(compareVersionDesc)[0];
return resolved?.id ?? selectedMessageId;
}
export function hasMultipleModelVersions(versions: Message[]): boolean {
return getLatestVersionsByModel(versions).length > 1;
}
export function selectRenderableVersionSet(
storeVersions: Message[],
cachedVersions: Message[] | null | undefined,
): Message[] {
if (storeVersions.length > 0) {
return storeVersions;
}
return cachedVersions ?? storeVersions;
}
function compareVersionDesc(left: Message, right: Message): number {
return right.version_index - left.version_index
|| right.created_at - left.created_at
|| right.id.localeCompare(left.id);
}
function getDisplayMessageId(
selections: ReadonlyMap<string, string> | Record<string, string | undefined> | null | undefined,
key: string,
): string | null {
if (!selections) return null;
if (typeof (selections as ReadonlyMap<string, string>).get === 'function') {
return (selections as ReadonlyMap<string, string>).get(key) ?? null;
}
return (selections as Record<string, string | undefined>)[key] ?? null;
}
export function selectNextAssistantVersion(
versions: Message[],
deletedMessageId: string,
): Message | null {
const deletedVersion = versions.find((version) => version.id === deletedMessageId);
if (!deletedVersion) {
return null;
}
const remainingVersions = versions.filter((version) => version.id !== deletedMessageId);
if (remainingVersions.length === 0) {
return null;
}
const sameModelVersions = deletedVersion.model_id
? remainingVersions.filter((version) => version.model_id === deletedVersion.model_id)
: [];
const candidates = sameModelVersions.length > 0 ? sameModelVersions : remainingVersions;
return [...candidates].sort(compareVersionDesc)[0] ?? null;
}
export function shouldRenderStandaloneAssistantError(
status: Message['status'] | null | undefined,
isNonTabsMultiModel: boolean,
): boolean {
return status === 'error' && !isNonTabsMultiModel;
}
export function insertModelVersionPlaceholder(
messages: Message[],
parentMessageId: string,
placeholder: Message,
): Message[] {
let inserted = false;
const updated: Message[] = [];
for (const message of messages) {
updated.push(message);
if (!inserted && message.parent_message_id === parentMessageId && message.role === 'assistant' && message.is_active) {
updated.push(placeholder);
inserted = true;
}
}
if (!inserted) {
updated.push(placeholder);
}
return updated;
}
function compareMessageAsc(left: Message, right: Message): number {
return left.created_at - right.created_at
|| left.version_index - right.version_index
|| left.id.localeCompare(right.id);
}
export function mergeAssistantVersionGroup(
messages: Message[],
parentMessageId: string,
versions: Message[],
activeMessageId?: string | null,
): Message[] {
if (versions.length === 0) {
return messages;
}
const versionGroup = [...versions]
.sort(compareMessageAsc)
.map((version) => activeMessageId
? { ...version, is_active: version.id === activeMessageId }
: version);
const result: Message[] = [];
let inserted = false;
for (const message of messages) {
const isTargetAssistant = message.parent_message_id === parentMessageId && message.role === 'assistant';
if (isTargetAssistant) {
if (!inserted) {
result.push(...versionGroup);
inserted = true;
}
continue;
}
result.push(message);
}
if (!inserted) {
const parentIndex = result.findIndex((message) => message.id === parentMessageId);
if (parentIndex >= 0) {
result.splice(parentIndex + 1, 0, ...versionGroup);
} else {
result.push(...versionGroup);
}
}
return result;
}
export interface MultiModelStreamErrorInput {
conversationId: string;
parentMessageId: string | null;
streamingMessageId: string | null;
messageId: string;
error: string;
modelId?: string | null;
providerId?: string | null;
}
export function applyMultiModelStreamError(
messages: Message[],
input: MultiModelStreamErrorInput,
): { messages: Message[]; streamingMessageId: string | null } {
const directMatch = messages.some((message) => message.id === input.messageId);
if (directMatch) {
const matchedMessage = messages.find((message) => message.id === input.messageId);
const shouldResolveStreamingId = Boolean(
input.streamingMessageId?.startsWith('temp-')
&& matchedMessage
&& (!input.parentMessageId || matchedMessage.parent_message_id === input.parentMessageId)
&& (!input.modelId || matchedMessage.model_id === input.modelId)
&& (!input.providerId || matchedMessage.provider_id === input.providerId)
);
return {
streamingMessageId: input.streamingMessageId === input.messageId || shouldResolveStreamingId
? input.messageId
: input.streamingMessageId,
messages: messages.map((message) => message.id === input.messageId
? {
...message,
content: input.error,
status: 'error' as const,
model_id: message.model_id ?? input.modelId ?? null,
provider_id: message.provider_id ?? input.providerId ?? null,
parent_message_id: message.parent_message_id ?? input.parentMessageId,
}
: message),
};
}
if (input.streamingMessageId?.startsWith('temp-')) {
const placeholder = messages.find((message) => message.id === input.streamingMessageId);
if (placeholder && (!input.parentMessageId || placeholder.parent_message_id === input.parentMessageId)) {
return {
streamingMessageId: input.messageId,
messages: messages.map((message) => message.id === input.streamingMessageId
? {
...message,
id: input.messageId,
content: input.error,
status: 'error' as const,
model_id: input.modelId ?? message.model_id ?? null,
provider_id: input.providerId ?? message.provider_id ?? null,
parent_message_id: input.parentMessageId ?? message.parent_message_id,
}
: message),
};
}
}
if (!input.parentMessageId) {
return { messages, streamingMessageId: input.streamingMessageId };
}
const newMessage: Message = {
id: input.messageId,
conversation_id: input.conversationId,
role: 'assistant',
content: input.error,
provider_id: input.providerId ?? null,
model_id: input.modelId ?? null,
token_count: null,
prompt_tokens: null,
completion_tokens: null,
attachments: [],
thinking: null,
tool_calls_json: null,
tool_call_id: null,
created_at: Date.now(),
parent_message_id: input.parentMessageId,
version_index: 0,
is_active: false,
status: 'error',
tokens_per_second: null,
first_token_latency_ms: null,
};
return {
messages: insertModelVersionPlaceholder(messages, input.parentMessageId, newMessage),
streamingMessageId: input.streamingMessageId,
};
}
export function mergeAssistantVersionsAfterSwitch(
messages: Message[],
parentMessageId: string,
versions: Message[],
activeMessageId: string,
): Message[] {
return mergeAssistantVersionGroup(messages, parentMessageId, versions, activeMessageId);
}
+13
View File
@@ -0,0 +1,13 @@
export const LANG_OPTIONS = [
{ key: 'zh-CN', label: '简体中文', icon: '🇨🇳' },
{ key: 'zh-TW', label: '繁體中文', icon: '🇭🇰' },
{ key: 'en-US', label: 'English', icon: '🇺🇸' },
{ key: 'ja', label: '日本語', icon: '🇯🇵' },
{ key: 'ko', label: '한국어', icon: '🇰🇷' },
{ key: 'fr', label: 'Français', icon: '🇫🇷' },
{ key: 'de', label: 'Deutsch', icon: '🇩🇪' },
{ key: 'es', label: 'Español', icon: '🇪🇸' },
{ key: 'ru', label: 'Русский', icon: '🇷🇺' },
{ key: 'hi', label: 'हिन्दी', icon: '🇮🇳' },
{ key: 'ar', label: 'العربية', icon: '🇸🇦' },
] as const;
+18
View File
@@ -0,0 +1,18 @@
export type ConvIconType = 'model' | 'emoji' | 'url' | 'file';
export interface ConvIcon {
type: ConvIconType;
value: string;
}
export const CONV_ICON_KEY = (id: string) => `boosapi_conv_icon_${id}`;
export function getConvIcon(conversationId: string): ConvIcon | null {
const stored = localStorage.getItem(CONV_ICON_KEY(conversationId));
if (!stored) return null;
try {
return JSON.parse(stored) as ConvIcon;
} catch {
return null;
}
}
+18
View File
@@ -0,0 +1,18 @@
const AUTO_CONVERSATION_TITLE_LIMIT = 30;
function trimTitleWrappers(value: string): string {
return value
.trim()
.replace(/^["'“”「」《》]+/, '')
.replace(/["'“”「」《》]+$/, '')
.trim();
}
export function normalizeAutoConversationTitle(value: string): string {
const normalized = trimTitleWrappers(value.replace(/\s+/g, ' '));
const chars = Array.from(normalized);
if (chars.length <= AUTO_CONVERSATION_TITLE_LIMIT) {
return normalized;
}
return `${chars.slice(0, AUTO_CONVERSATION_TITLE_LIMIT).join('')}...`;
}
+43
View File
@@ -0,0 +1,43 @@
import { invoke } from '@/lib/invoke';
import { writeStartupDiagnostic } from '@/lib/startupDiagnostics';
import type { DesktopCapability } from '@/types';
const DEVTOOLS_CONTEXT_MENU_CAPABILITY = 'devtools_context_menu';
export function hasDesktopCapability(
capabilities: DesktopCapability[],
key: string,
): boolean {
return capabilities.some((capability) => (
capability.key === key && capability.supported === true
));
}
export async function loadDevtoolsContextMenuEnabled(
isDevBuild = import.meta.env.DEV,
): Promise<boolean> {
if (isDevBuild) return true;
try {
const capabilities = await invoke<DesktopCapability[]>('get_desktop_capabilities');
return hasDesktopCapability(capabilities, DEVTOOLS_CONTEXT_MENU_CAPABILITY);
} catch (error) {
await writeStartupDiagnostic(
'warn',
`failed to load desktop capabilities for devtools context menu: ${String(error)}`,
);
return false;
}
}
export async function openDevtoolsWithDiagnostics(): Promise<void> {
try {
await invoke('open_devtools');
await writeStartupDiagnostic('info', 'open_devtools command completed');
} catch (error) {
await writeStartupDiagnostic(
'error',
`open_devtools command failed: ${String(error)}`,
);
}
}
+149
View File
@@ -0,0 +1,149 @@
import type {
DrawingBackground,
DrawingModelId,
DrawingOutputFormat,
DrawingQuality,
DrawingSettings,
DrawingReferenceImageFormat,
DrawingReferenceImageMode,
ProviderConfig,
} from '@/types';
import {
GPT_IMAGE_MODELS,
GPT_IMAGE_PARAM_CONFIG,
GPT_IMAGE_REFERENCE_IMAGE_MODES,
GPT_IMAGE_SIZE_OPTIONS,
getGptImageBackgroundOptions,
getGptImageOutputFormatOptions,
getGptImageQualityOptions,
getGptImageReferenceImageFormatOptions,
getGptImageReferenceImageModeOptions,
getGptImageSizeOptions,
isGptImageOutputCompressionSupported,
isGptImageTransparentBackgroundSupported,
} from '@/components/drawing/params/gpt-image';
import type { DrawingParamConfig } from '@/components/drawing/params/types';
export const DRAWING_MODELS: Array<{ id: DrawingModelId; name: string }> = [...GPT_IMAGE_MODELS];
export interface DrawingModelOption {
label: string;
value: DrawingModelId;
}
type DrawingTranslate = (key: string, fallback: string) => string;
const DRAWING_PARAM_CONFIGS: DrawingParamConfig[] = [GPT_IMAGE_PARAM_CONFIG];
function isOpenAIImagesCompatible(provider: ProviderConfig): boolean {
return provider.provider_type === 'openai' || provider.provider_type === 'custom';
}
function hasEnabledImageModel(provider: ProviderConfig, modelId: DrawingModelId): boolean {
return provider.models.some((model) =>
model.enabled
&& model.model_type === 'Image'
&& model.model_id === modelId,
);
}
export function getDrawingModelOptions(_providers?: ProviderConfig[]): DrawingModelOption[] {
return DRAWING_MODELS.map((model) => ({ label: model.name, value: model.id }));
}
export function getDrawingParamConfig(modelId: DrawingModelId): DrawingParamConfig {
return DRAWING_PARAM_CONFIGS.find((config) => config.modelIds.includes(modelId))
?? GPT_IMAGE_PARAM_CONFIG;
}
export function getDrawingProvidersForModel(
providers: ProviderConfig[],
modelId: DrawingModelId,
): ProviderConfig[] {
return providers.filter((provider) =>
provider.enabled
&& isOpenAIImagesCompatible(provider)
&& hasEnabledImageModel(provider, modelId),
);
}
export const DRAWING_SIZE_OPTIONS = [...GPT_IMAGE_SIZE_OPTIONS];
export const DRAWING_REFERENCE_IMAGE_MODES: DrawingReferenceImageMode[] = [...GPT_IMAGE_REFERENCE_IMAGE_MODES];
export function getDrawingSizeOptions(t: DrawingTranslate): Array<{ label: string; value: string }> {
return getGptImageSizeOptions(t).map(({ fallbackLabel, value }) => ({
label: fallbackLabel,
value: String(value),
}));
}
export function getDrawingQualityOptions(
t: DrawingTranslate,
): Array<{ label: string; value: DrawingQuality }> {
return getGptImageQualityOptions(t).map(({ fallbackLabel, value }) => ({
label: fallbackLabel,
value: value as DrawingQuality,
}));
}
export function getDrawingOutputFormatOptions(
t: DrawingTranslate,
): Array<{ label: string; value: DrawingOutputFormat }> {
return getGptImageOutputFormatOptions(t).map(({ fallbackLabel, value }) => ({
label: fallbackLabel,
value: value as DrawingOutputFormat,
}));
}
export function isDrawingTransparentBackgroundSupported(modelId?: DrawingModelId): boolean {
return isGptImageTransparentBackgroundSupported(modelId);
}
export function isDrawingOutputCompressionSupported(
modelId: DrawingModelId,
outputFormat: DrawingOutputFormat,
): boolean {
return isGptImageOutputCompressionSupported(modelId, outputFormat);
}
export function getDrawingBackgroundOptions(
t: DrawingTranslate,
modelId?: DrawingModelId,
): Array<{ label: string; value: DrawingBackground }> {
return getGptImageBackgroundOptions(t, modelId).map(({ fallbackLabel, value }) => ({
label: fallbackLabel,
value: value as DrawingBackground,
}));
}
export function getDrawingReferenceImageModeOptions(
t: DrawingTranslate,
): Array<{ label: string; value: DrawingReferenceImageMode }> {
return getGptImageReferenceImageModeOptions(t).map(({ fallbackLabel, value }) => ({
label: fallbackLabel,
value: value as DrawingReferenceImageMode,
}));
}
export function getDrawingReferenceImageFormatOptions(
t: DrawingTranslate,
): Array<{ label: string; value: DrawingReferenceImageFormat }> {
return getGptImageReferenceImageFormatOptions(t).map(({ fallbackLabel, value }) => ({
label: fallbackLabel,
value: value as DrawingReferenceImageFormat,
}));
}
export function normalizeDrawingSettingsByConfig(settings: DrawingSettings): DrawingSettings {
const config = getDrawingParamConfig(settings.modelId);
return config.normalizeSettings ? config.normalizeSettings(settings) : settings;
}
export function describeDrawingSize(size: string) {
if (size === 'auto') return 'auto';
const [w, h] = size.split('x').map(Number);
if (!w || !h) return size;
const ratio = w === h ? '1:1' : w > h ? '16:9' : '9:16';
const label = Math.max(w, h) >= 2048 ? '2K' : '1K';
return `${ratio} | ${label}`;
}
+38
View File
@@ -0,0 +1,38 @@
export function getErrorMessage(error: unknown): string {
if (typeof error === 'string') {
return error;
}
if (error instanceof Error && error.message) {
return error.message;
}
if (error && typeof error === 'object') {
const record = error as Record<string, unknown>;
for (const key of ['message', 'error', 'detail', 'reason']) {
const value = record[key];
if (typeof value === 'string' && value.trim()) {
return value;
}
}
try {
const serialized = JSON.stringify(error);
if (serialized && serialized !== '{}') {
return serialized;
}
} catch {
// Fall through to the generic conversion.
}
}
return String(error);
}
export function isFormValidationError(error: unknown): boolean {
return !!(
error &&
typeof error === 'object' &&
Array.isArray((error as { errorFields?: unknown }).errorFields)
);
}
+148
View File
@@ -0,0 +1,148 @@
import { isTauri } from '@/lib/invoke'
import { stripBoosapiTags } from '@/lib/chatMarkdown'
import type { Message } from '@/types'
function browserDownload(filename: string, content: string, mimeType: string) {
const blob = new Blob([content], { type: mimeType })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
async function saveFile(
defaultName: string,
content: string | Uint8Array,
filters: { name: string; extensions: string[] }[],
) {
if (isTauri()) {
const { save } = await import('@tauri-apps/plugin-dialog')
const { writeTextFile, writeFile } = await import('@tauri-apps/plugin-fs')
const filePath = await save({ defaultPath: defaultName, filters })
if (!filePath) return false
try {
if (typeof content === 'string') {
await writeTextFile(filePath, content)
} else {
await writeFile(filePath, content)
}
} catch (e) {
console.error('Failed to write file:', filePath, e)
throw e
}
return true
}
// Browser fallback
const mimeType = filters[0]?.extensions[0] === 'png' ? 'image/png' : 'text/plain'
if (typeof content === 'string') {
browserDownload(defaultName, content, mimeType)
} else {
const blob = new Blob([content], { type: mimeType })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = defaultName
a.click()
URL.revokeObjectURL(url)
}
return true
}
async function writeToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text)
} catch {
const { writeText } = await import('@tauri-apps/plugin-clipboard-manager')
await writeText(text)
}
}
export interface TranscriptExportOptions {
includeThinking?: boolean;
}
function getExportMessageContent(message: Message, options?: TranscriptExportOptions) {
if (options?.includeThinking === false) {
return stripBoosapiTags(message.content, { stripThink: message.role !== 'user' })
}
return message.content
}
export function buildMarkdownTranscript(messages: Message[], title: string, options?: TranscriptExportOptions) {
const lines: string[] = [`# ${title}`, '']
for (const m of messages) {
const role = m.role === 'user' ? 'User' : m.role === 'system' ? 'System' : 'Assistant'
lines.push(`## ${role}`, '', getExportMessageContent(m, options), '', '---', '')
}
return lines.join('\n')
}
export function buildTextTranscript(messages: Message[], title: string, options?: TranscriptExportOptions) {
const lines: string[] = [title, '='.repeat(title.length), '']
for (const m of messages) {
const role = m.role === 'user' ? 'User' : m.role === 'system' ? 'System' : 'Assistant'
lines.push(`[${role}]`, '', getExportMessageContent(m, options), '', '---', '')
}
return lines.join('\n')
}
export async function exportAsPNG(element: HTMLElement | null, title: string) {
if (!element) return false
const { default: html2canvas } = await import('html2canvas')
const canvas = await html2canvas(element, { useCORS: true, scale: 2, backgroundColor: '#fff' })
if (isTauri()) {
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/png'))
if (!blob) return false
const buffer = new Uint8Array(await blob.arrayBuffer())
return saveFile(`${title}.png`, buffer, [{ name: 'PNG Image', extensions: ['png'] }])
}
// Browser fallback
const link = document.createElement('a')
link.download = `${title}.png`
link.href = canvas.toDataURL('image/png')
link.click()
return true
}
export function buildJsonTranscript(messages: Message[], title: string, options?: TranscriptExportOptions) {
const data = {
title,
exported_at: new Date().toISOString(),
messages: messages.map((m) => ({
role: m.role,
content: getExportMessageContent(m, options),
...(options?.includeThinking === false ? {} : { thinking: m.thinking }),
created_at: m.created_at,
})),
}
return JSON.stringify(data, null, 2)
}
export async function copyTranscript(
messages: Message[],
title: string,
format: 'markdown' | 'text',
options?: TranscriptExportOptions,
) {
const content = format === 'markdown'
? buildMarkdownTranscript(messages, title, options)
: buildTextTranscript(messages, title, options)
await writeToClipboard(content)
return true
}
export async function exportAsMarkdown(messages: Message[], title: string, options?: TranscriptExportOptions) {
return saveFile(`${title}.md`, buildMarkdownTranscript(messages, title, options), [{ name: 'Markdown', extensions: ['md'] }])
}
export async function exportAsText(messages: Message[], title: string, options?: TranscriptExportOptions) {
return saveFile(`${title}.txt`, buildTextTranscript(messages, title, options), [{ name: 'Text', extensions: ['txt'] }])
}
export async function exportAsJSON(messages: Message[], title: string, options?: TranscriptExportOptions) {
return saveFile(`${title}.json`, buildJsonTranscript(messages, title, options), [{ name: 'JSON', extensions: ['json'] }])
}
+27
View File
@@ -0,0 +1,27 @@
import { invoke as tauriInvoke } from '@tauri-apps/api/core';
import { listen as tauriListen } from '@tauri-apps/api/event';
import { handleCommand } from './browserMock';
export type UnlistenFn = () => void;
export function isTauri(): boolean {
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
}
export async function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
if (isTauri()) {
return tauriInvoke<T>(cmd, args);
}
return handleCommand<T>(cmd, args);
}
export async function listen<T>(
event: string,
handler: (event: { payload: T }) => void,
): Promise<UnlistenFn> {
if (isTauri()) {
return tauriListen<T>(event, handler);
}
// Browser mode: no-op listener
return () => {};
}
+9
View File
@@ -0,0 +1,9 @@
import { invoke } from '@tauri-apps/api/core';
export async function ipc<T>(command: string, args?: Record<string, unknown>): Promise<T> {
return invoke<T>(command, args);
}
export async function togglePinConversation(id: string) {
return invoke('toggle_pin_conversation', { id });
}
+102
View File
@@ -0,0 +1,102 @@
export interface MemoryRetrievedItem {
content: string;
score: number;
rerankScore?: number;
document_id: string;
/** Chunk ID within the vector store */
id: string;
/** Human-readable document name (knowledge items only) */
document_name?: string;
}
export interface MemorySourceResult {
source_type: 'knowledge' | 'memory';
container_id: string;
items: MemoryRetrievedItem[];
}
export interface RagSourceError {
source_type: 'knowledge' | 'memory';
container_id: string;
message: string;
}
export interface RagSourceEmptyResult {
source_type: 'knowledge' | 'memory';
container_id: string;
reason: 'no_candidates' | 'threshold_filtered' | string;
}
export interface RagContextRetrievedEvent {
conversation_id: string;
message_id?: string | null;
stream_id?: string | null;
sources: MemorySourceResult[];
errors?: RagSourceError[];
empty_results?: RagSourceEmptyResult[];
emptyResults?: RagSourceEmptyResult[];
}
function escapeTagText(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function escapeTagAttr(value: string): string {
return escapeTagText(value).replace(/"/g, '&quot;');
}
export function formatRagFailureMessage(message?: string): string {
const reason = message?.trim() ?? '';
if (!reason) return '检索失败';
if (reason.startsWith('检索失败')) return reason;
return `检索失败:${reason}`;
}
/**
* Build a `<knowledge-retrieval>` custom tag for markstream-react rendering.
*/
export function buildKnowledgeTag(
status: 'searching' | 'done' | 'error' | 'empty',
sources?: MemorySourceResult[] | string,
): string {
if (status === 'searching') {
return '<knowledge-retrieval status="searching" data-boosapi="1"></knowledge-retrieval>';
}
if (status === 'empty') {
const reason = typeof sources === 'string' ? sources : '';
const reasonAttr = reason ? ` reason="${escapeTagAttr(reason)}"` : '';
return `<knowledge-retrieval status="empty" data-boosapi="1"${reasonAttr}></knowledge-retrieval>`;
}
if (status === 'error') {
const message = formatRagFailureMessage(typeof sources === 'string' ? sources : '');
return `<knowledge-retrieval status="error" data-boosapi="1">${escapeTagText(message)}</knowledge-retrieval>`;
}
const json = JSON.stringify(sources ?? []);
return `<knowledge-retrieval status="done" data-boosapi="1">\n${json}\n</knowledge-retrieval>\n\n`;
}
/**
* Build a `<memory-retrieval>` custom tag for markstream-react rendering.
*/
export function buildMemoryTag(
status: 'searching' | 'done' | 'error' | 'empty',
sources?: MemorySourceResult[] | string,
): string {
if (status === 'searching') {
return '<memory-retrieval status="searching" data-boosapi="1"></memory-retrieval>';
}
if (status === 'empty') {
const reason = typeof sources === 'string' ? sources : '';
const reasonAttr = reason ? ` reason="${escapeTagAttr(reason)}"` : '';
return `<memory-retrieval status="empty" data-boosapi="1"${reasonAttr}></memory-retrieval>`;
}
if (status === 'error') {
const message = formatRagFailureMessage(typeof sources === 'string' ? sources : '');
return `<memory-retrieval status="error" data-boosapi="1">${escapeTagText(message)}</memory-retrieval>`;
}
const json = JSON.stringify(sources ?? []);
return `<memory-retrieval status="done" data-boosapi="1">\n${json}\n</memory-retrieval>\n\n`;
}
+46
View File
@@ -0,0 +1,46 @@
import type { Model, ModelCapability, ModelType, ProviderConfig } from '@/types';
const CHAT_MODEL_CAPABILITIES: ModelCapability[] = [
'Vision',
'FunctionCalling',
'Reasoning',
];
export function getEditableCapabilities(modelType: ModelType | null | undefined): ModelCapability[] {
return modelType === 'Chat' || !modelType ? CHAT_MODEL_CAPABILITIES : [];
}
export function sanitizeModelCapabilities(
modelType: ModelType | null | undefined,
capabilities: ModelCapability[],
): ModelCapability[] {
const allowed = new Set(getEditableCapabilities(modelType));
return capabilities.filter((capability) => allowed.has(capability));
}
export function getVisibleModelCapabilities(
model: Pick<Model, 'model_type' | 'capabilities'>,
): ModelCapability[] {
return sanitizeModelCapabilities(model.model_type, model.capabilities);
}
export function modelHasCapability(
model: Pick<Model, 'capabilities'> | null | undefined,
capability: ModelCapability,
): boolean {
return model?.capabilities.includes(capability) ?? false;
}
export function supportsReasoning(model: Pick<Model, 'capabilities'> | null | undefined): boolean {
return modelHasCapability(model, 'Reasoning');
}
export function findModelByIds(
providers: ProviderConfig[],
providerId: string | null | undefined,
modelId: string | null | undefined,
): Model | null {
if (!providerId || !modelId) return null;
const provider = providers.find((item) => item.id === providerId);
return provider?.models.find((item) => item.model_id === modelId) ?? null;
}
+29
View File
@@ -0,0 +1,29 @@
import type { AppSettings, Model } from '@/types';
type ModelForDefaults = Pick<Model, 'param_overrides'> | null | undefined;
type SettingsForDefaults = Pick<
AppSettings,
| 'default_temperature'
| 'default_top_p'
| 'default_max_tokens'
| 'default_frequency_penalty'
> | null | undefined;
export interface ResolvedModelParamDefaults {
temperature: number;
topP: number;
maxTokens: number;
frequencyPenalty: number;
}
export function resolveModelParamDefaults(
model: ModelForDefaults,
settings: SettingsForDefaults,
): ResolvedModelParamDefaults {
return {
temperature: model?.param_overrides?.temperature ?? settings?.default_temperature ?? 0.7,
topP: model?.param_overrides?.top_p ?? settings?.default_top_p ?? 1,
maxTokens: settings?.default_max_tokens ?? 4096,
frequencyPenalty: model?.param_overrides?.frequency_penalty ?? settings?.default_frequency_penalty ?? 0,
};
}
+21
View File
@@ -0,0 +1,21 @@
import { preloadExtendedLanguageIcons } from 'markstream-react';
let preloadPromise: Promise<void> | null = null;
export function preloadChatRenderers(): Promise<void> {
if (preloadPromise) return preloadPromise;
preloadPromise = (async () => {
try {
const streamMonacoModule = await import('stream-monaco');
void preloadExtendedLanguageIcons();
void streamMonacoModule;
} catch (e) {
console.warn('Failed to preload chat renderers:', e);
}
})();
return preloadPromise;
}
+64
View File
@@ -0,0 +1,64 @@
import type { ProviderType } from '@/types';
export interface ProviderDeepLinkPayload {
name: string;
baseurl: string;
apikey: string;
type: ProviderType;
}
const PROVIDER_TYPES: ProviderType[] = [
'openai',
'openai_responses',
'deepseek',
'xai',
'glm',
'siliconflow',
'anthropic',
'gemini',
'jina',
'cohere',
'voyage',
'custom',
];
function readRequiredParam(params: URLSearchParams, key: string): string | null {
const value = params.get(key)?.trim();
return value ? value : null;
}
function isProviderType(value: string): value is ProviderType {
return PROVIDER_TYPES.includes(value as ProviderType);
}
function getDeepLinkTarget(url: URL): string {
if (url.hostname) return url.hostname;
return url.pathname.replace(/^\/+/, '').split('/')[0] ?? '';
}
export function parseProviderDeepLink(rawUrl: string): ProviderDeepLinkPayload | null {
let url: URL;
try {
url = new URL(rawUrl);
} catch {
return null;
}
if (url.protocol !== 'boosapi:') return null;
if (getDeepLinkTarget(url) !== 'providers') return null;
const name = readRequiredParam(url.searchParams, 'name');
const baseurl = readRequiredParam(url.searchParams, 'baseurl');
const apikey = readRequiredParam(url.searchParams, 'apikey');
const type = readRequiredParam(url.searchParams, 'type');
if (!name || !baseurl || !apikey || !type || !isProviderType(type)) {
return null;
}
return { name, baseurl, apikey, type };
}
export function getProviderDeepLinkKeyPrefix(apikey: string): string {
return apikey.length >= 8 ? `${apikey.slice(0, 8)}...` : apikey;
}
+131
View File
@@ -0,0 +1,131 @@
import { memo } from 'react';
import type { ProviderConfig } from '@/types';
import { ProviderIcon, ModelIcon, providerMappings, modelMappings } from '@lobehub/icons';
import { DynamicLobeIcon } from '@/components/shared/DynamicLobeIcon';
const TYPE_TO_PROVIDER: Record<string, string> = {
openai: 'openai',
openai_responses: 'openai',
deepseek: 'deepseek',
xai: 'xai',
glm: 'zhipu',
siliconflow: 'siliconcloud',
anthropic: 'anthropic',
gemini: 'google',
jina: 'jina',
cohere: 'cohere',
voyage: 'voyage',
custom: 'openai',
};
/**
* Check if a name matches any providerMappings keyword (exact, lowercased).
*/
function findProviderKey(name: string): string | null {
const lower = name.toLowerCase().replace(/\s+/g, '');
for (const mapping of providerMappings) {
if (mapping.keywords.some((kw: string) => lower.includes(kw.toLowerCase()))) {
return mapping.keywords[0];
}
}
return null;
}
/**
* Check if a name matches any modelMappings keyword using regex (same as ModelIcon internals).
*/
function findModelKey(name: string): string | null {
const lower = name.toLowerCase().replace(/\s+/g, '');
for (const mapping of modelMappings) {
if (mapping.keywords.some((kw: string) => {
try {
return new RegExp(kw, 'i').test(lower);
} catch {
return lower.includes(kw.toLowerCase());
}
})) {
return mapping.keywords[0];
}
}
return null;
}
export type IconResult = {
type: 'provider';
key: string;
} | {
type: 'model';
key: string;
};
// Explicit name-to-provider fallback for names that don't match
// either providerMappings or modelMappings keywords.
const NAME_TO_PROVIDER: Record<string, string> = {
glm: 'zhipu',
};
/**
* Resolve a ProviderConfig to the best icon match.
* 1) providerMappings keyword match → ProviderIcon
* 2) modelMappings keyword match → ModelIcon
* 3) NAME_TO_PROVIDER explicit mapping → ProviderIcon
* 4) TYPE_TO_PROVIDER fallback → ProviderIcon
*/
export function resolveProviderIcon(provider: ProviderConfig): IconResult {
const providerKey = findProviderKey(provider.name);
if (providerKey) return { type: 'provider', key: providerKey };
const modelKey = findModelKey(provider.name);
if (modelKey) return { type: 'model', key: modelKey };
const nameLower = provider.name.toLowerCase().replace(/\s+/g, '');
for (const [keyword, icon] of Object.entries(NAME_TO_PROVIDER)) {
if (nameLower.includes(keyword)) return { type: 'provider', key: icon };
}
return { type: 'provider', key: TYPE_TO_PROVIDER[provider.provider_type] || 'openai' };
}
/**
* Legacy helper — returns a ProviderIcon-compatible string key.
* Prefer resolveProviderIcon + SmartProviderIcon for correct two-tier rendering.
*/
export function getProviderIconKey(provider: ProviderConfig): string {
const result = resolveProviderIcon(provider);
return result.key;
}
/**
* Two-tier icon component: tries ProviderIcon first, then ModelIcon, then fallback.
*/
export const SmartProviderIcon = memo(function SmartProviderIcon({
provider,
size = 22,
type = 'color',
shape,
}: {
provider: ProviderConfig;
size?: number;
type?: 'avatar' | 'color' | 'mono';
shape?: 'circle' | 'square';
}) {
if (provider.icon) {
const [, key] = provider.icon.includes(':')
? (provider.icon.split(':', 2) as [string, string])
: ['model' as const, provider.icon];
// key is a toc `id` (e.g., "Ai302", "OpenAI") — use DynamicLobeIcon for reliable rendering
return <DynamicLobeIcon iconId={key} size={size} type={type} />;
}
const result = resolveProviderIcon(provider);
if (result.type === 'model') {
return <ModelIcon model={result.key} size={size} type={type} />;
}
return <ProviderIcon provider={result.key} size={size} type={type} shape={shape} />;
}, (prev, next) =>
prev.provider.icon === next.provider.icon
&& prev.provider.name === next.provider.name
&& prev.provider.provider_type === next.provider.provider_type
&& prev.size === next.size
&& prev.type === next.type
&& prev.shape === next.shape
);
+373
View File
@@ -0,0 +1,373 @@
import type { Model, ProviderType } from '@/types';
export type ReasoningOptionKey =
| 'default'
| 'off'
| 'none'
| 'minimal'
| 'low'
| 'medium'
| 'high'
| 'xhigh'
| 'max';
export type ReasoningApiStyle =
| 'none'
| 'openai_reasoning_effort'
| 'openai_responses_reasoning'
| 'glm_thinking'
| 'gemini_thinking_level'
| 'gemini_thinking_budget'
| 'anthropic_adaptive'
| 'anthropic_budget_tokens'
| 'siliconflow_enable_thinking';
export interface ReasoningOption {
key: ReasoningOptionKey;
labelKey: string;
fallbackLabel: string;
icon: 'default' | 'off' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
reasoningEffort?: string;
thinkingLevel?: string;
budgetTokens?: number;
enableThinking?: boolean;
}
export interface ReasoningProfile {
apiStyle: ReasoningApiStyle;
options: ReasoningOption[];
defaultOptionKey: ReasoningOptionKey;
}
export interface ResolvedReasoningRequest {
level: ReasoningOptionKey;
apiStyle: ReasoningApiStyle;
reasoningEffort?: string;
thinkingLevel?: string;
budgetTokens?: number;
enableThinking?: boolean;
suppressSamplingParams: boolean;
}
const OPTION_DEFS: Record<ReasoningOptionKey, ReasoningOption> = {
default: {
key: 'default',
labelKey: 'chat.thinking.default',
fallbackLabel: '默认',
icon: 'default',
},
off: {
key: 'off',
labelKey: 'chat.thinking.off',
fallbackLabel: '关闭',
icon: 'off',
reasoningEffort: 'off',
enableThinking: false,
},
none: {
key: 'none',
labelKey: 'chat.thinking.none',
fallbackLabel: '禁止思考',
icon: 'off',
reasoningEffort: 'none',
enableThinking: false,
budgetTokens: 0,
},
minimal: {
key: 'minimal',
labelKey: 'chat.thinking.minimal',
fallbackLabel: 'Minimal',
icon: 'low',
thinkingLevel: 'minimal',
},
low: {
key: 'low',
labelKey: 'chat.thinking.low',
fallbackLabel: 'Low',
icon: 'low',
reasoningEffort: 'low',
thinkingLevel: 'low',
budgetTokens: 1024,
enableThinking: true,
},
medium: {
key: 'medium',
labelKey: 'chat.thinking.medium',
fallbackLabel: 'Medium',
icon: 'medium',
reasoningEffort: 'medium',
thinkingLevel: 'medium',
budgetTokens: 4096,
enableThinking: true,
},
high: {
key: 'high',
labelKey: 'chat.thinking.high',
fallbackLabel: 'High',
icon: 'high',
reasoningEffort: 'high',
thinkingLevel: 'high',
budgetTokens: 8192,
enableThinking: true,
},
xhigh: {
key: 'xhigh',
labelKey: 'chat.thinking.xhigh',
fallbackLabel: 'XHigh',
icon: 'xhigh',
reasoningEffort: 'xhigh',
thinkingLevel: 'xhigh',
budgetTokens: 16384,
enableThinking: true,
},
max: {
key: 'max',
labelKey: 'chat.thinking.max',
fallbackLabel: 'Max',
icon: 'max',
reasoningEffort: 'max',
thinkingLevel: 'max',
budgetTokens: 32768,
enableThinking: true,
},
};
function options(keys: ReasoningOptionKey[]): ReasoningOption[] {
return keys.map((key) => ({ ...OPTION_DEFS[key] }));
}
function normalizedModelId(model: Pick<Model, 'model_id'> | null | undefined): string {
return model?.model_id.toLowerCase().replace(/[_\s]+/g, '-') ?? '';
}
function overrideProfile(model: Pick<Model, 'param_overrides'> | null | undefined): ReasoningApiStyle | null {
const profile = model?.param_overrides?.reasoning_profile;
if (!profile) return null;
return [
'none',
'openai_reasoning_effort',
'openai_responses_reasoning',
'glm_thinking',
'gemini_thinking_level',
'gemini_thinking_budget',
'anthropic_adaptive',
'anthropic_budget_tokens',
'siliconflow_enable_thinking',
].includes(profile) ? (profile as ReasoningApiStyle) : null;
}
function openAiProfile(providerType: ProviderType, modelId: string): ReasoningProfile {
const apiStyle = providerType === 'openai_responses'
? 'openai_responses_reasoning'
: 'openai_reasoning_effort';
const supportsXHigh = modelId.startsWith('gpt-5') || modelId.startsWith('o5');
return {
apiStyle,
defaultOptionKey: 'default',
options: options(supportsXHigh
? ['default', 'none', 'low', 'medium', 'high', 'xhigh']
: ['default', 'none', 'low', 'medium', 'high']),
};
}
function geminiProfile(modelId: string): ReasoningProfile {
const isGemini3 = modelId.includes('3.') || modelId.includes('gemini-3') || modelId.includes('-3-');
if (isGemini3) {
return {
apiStyle: 'gemini_thinking_level',
defaultOptionKey: 'default',
options: options(modelId.includes('pro')
? ['default', 'low', 'medium', 'high']
: ['default', 'minimal', 'low', 'medium', 'high']),
};
}
return {
apiStyle: 'gemini_thinking_budget',
defaultOptionKey: 'default',
options: options(['default', 'none', 'low', 'medium', 'high']),
};
}
function anthropicProfile(modelId: string): ReasoningProfile {
if (modelId.includes('4.7') || modelId.includes('4-7')) {
return {
apiStyle: 'anthropic_adaptive',
defaultOptionKey: 'default',
options: options(['default', 'off', 'low', 'medium', 'high', 'xhigh', 'max']),
};
}
if (modelId.includes('4.6') || modelId.includes('4-6')) {
return {
apiStyle: 'anthropic_adaptive',
defaultOptionKey: 'default',
options: options(['default', 'off', 'low', 'medium', 'high', 'max']),
};
}
return {
apiStyle: 'anthropic_budget_tokens',
defaultOptionKey: 'default',
options: options(['default', 'none', 'low', 'medium', 'high']),
};
}
function deepSeekProfile(): ReasoningProfile {
return {
apiStyle: 'openai_reasoning_effort',
defaultOptionKey: 'default',
options: options(['default', 'none', 'high', 'max']),
};
}
function xaiProfile(modelId: string): ReasoningProfile {
if (modelId.startsWith('grok-4.3')) {
return {
apiStyle: 'openai_reasoning_effort',
defaultOptionKey: 'default',
options: options(['default', 'none', 'low', 'medium', 'high']),
};
}
return { apiStyle: 'none', defaultOptionKey: 'default', options: options(['default']) };
}
function glmProfile(): ReasoningProfile {
return {
apiStyle: 'glm_thinking',
defaultOptionKey: 'default',
options: options(['default', 'none', 'high']),
};
}
function siliconFlowProfile(): ReasoningProfile {
return {
apiStyle: 'siliconflow_enable_thinking',
defaultOptionKey: 'default',
options: options(['default', 'none', 'low', 'medium', 'high']),
};
}
function overriddenProfile(apiStyle: ReasoningApiStyle, modelId: string): ReasoningProfile {
if (apiStyle === 'glm_thinking') return glmProfile();
if (apiStyle === 'anthropic_budget_tokens') {
return {
apiStyle,
defaultOptionKey: 'default',
options: options(modelId.includes('4.7') || modelId.includes('4-7')
? ['default', 'off', 'low', 'medium', 'high', 'xhigh', 'max']
: ['default', 'off', 'low', 'medium', 'high', 'max']),
};
}
if (apiStyle === 'siliconflow_enable_thinking') {
return {
apiStyle,
defaultOptionKey: 'default',
options: options(['default', 'none', 'low', 'medium', 'high']),
};
}
if (apiStyle === 'none') {
return { apiStyle, defaultOptionKey: 'default', options: options(['default']) };
}
if (apiStyle === 'gemini_thinking_level') return geminiProfile('gemini-3.1-flash');
if (apiStyle === 'gemini_thinking_budget') return geminiProfile('gemini-2.5-pro');
if (apiStyle === 'anthropic_adaptive') return anthropicProfile(modelId);
return openAiProfile(apiStyle === 'openai_responses_reasoning' ? 'openai_responses' : 'openai', modelId);
}
export function resolveReasoningProfile(
providerType: ProviderType | undefined,
model: Model | null | undefined,
): ReasoningProfile {
const modelId = normalizedModelId(model);
const explicitProfile = overrideProfile(model);
if (explicitProfile) return overriddenProfile(explicitProfile, modelId);
if (providerType === 'gemini') return geminiProfile(modelId);
if (providerType === 'anthropic') return anthropicProfile(modelId);
if (providerType === 'deepseek') return deepSeekProfile();
if (providerType === 'xai') return xaiProfile(modelId);
if (providerType === 'glm') return glmProfile();
if (providerType === 'siliconflow') return siliconFlowProfile();
if (providerType === 'openai' || providerType === 'openai_responses') return openAiProfile(providerType, modelId);
if (modelId.includes('claude')) return anthropicProfile(modelId);
if (modelId.includes('gemini')) return geminiProfile(modelId);
if (modelId.startsWith('gpt-') || modelId.startsWith('o')) return openAiProfile('openai', modelId);
return { apiStyle: 'none', defaultOptionKey: 'default', options: options(['default']) };
}
export function coerceReasoningOptionKey(
profile: ReasoningProfile,
key: string | null | undefined,
): ReasoningOptionKey {
if (!key) return profile.defaultOptionKey;
return profile.options.some((option) => option.key === key)
? (key as ReasoningOptionKey)
: profile.defaultOptionKey;
}
export function legacyThinkingBudgetToOptionKey(
profile: ReasoningProfile,
budget: number | null | undefined,
): ReasoningOptionKey | null {
if (budget === null || budget === undefined) return null;
if (budget === 0) return coerceReasoningOptionKey(profile, profile.apiStyle.includes('anthropic') ? 'off' : 'none');
if (budget <= 2048) return coerceReasoningOptionKey(profile, 'low');
if (budget <= 6144) return coerceReasoningOptionKey(profile, 'medium');
if (budget <= 12288) return coerceReasoningOptionKey(profile, 'high');
return coerceReasoningOptionKey(profile, 'xhigh');
}
export function resolveReasoningRequest(
profile: ReasoningProfile,
key: string | null | undefined,
): ResolvedReasoningRequest | undefined {
const optionKey = coerceReasoningOptionKey(profile, key);
if (optionKey === 'default' || profile.apiStyle === 'none') return undefined;
const option = profile.options.find((item) => item.key === optionKey) ?? OPTION_DEFS[optionKey];
const suppressSamplingParams = optionKey !== 'off' && optionKey !== 'none';
if (profile.apiStyle === 'gemini_thinking_level') {
return {
level: optionKey,
apiStyle: profile.apiStyle,
thinkingLevel: option.thinkingLevel,
suppressSamplingParams: false,
};
}
if (profile.apiStyle === 'glm_thinking') {
return {
level: optionKey,
apiStyle: profile.apiStyle,
suppressSamplingParams,
};
}
if (profile.apiStyle === 'gemini_thinking_budget' || profile.apiStyle === 'anthropic_budget_tokens') {
return {
level: optionKey,
apiStyle: profile.apiStyle,
budgetTokens: optionKey === 'off' || optionKey === 'none' ? 0 : option.budgetTokens,
suppressSamplingParams,
};
}
if (profile.apiStyle === 'siliconflow_enable_thinking') {
return {
level: optionKey,
apiStyle: profile.apiStyle,
enableThinking: option.enableThinking,
budgetTokens: option.budgetTokens,
suppressSamplingParams,
};
}
return {
level: optionKey,
apiStyle: profile.apiStyle,
reasoningEffort: option.reasoningEffort,
suppressSamplingParams,
};
}
+328
View File
@@ -0,0 +1,328 @@
import type { Message, SearchResultItem } from '@/types';
const SEARCH_MARKER_START = '<!-- search:';
const SEARCH_MARKER_END = ' -->';
const SEARCH_SEPARATOR = '\n---\n\n';
const SEARCH_CONTEXT_MESSAGE_LIMIT = 6;
const SEARCH_HISTORY_MESSAGE_CHAR_LIMIT = 240;
const SEARCH_TOTAL_CONTEXT_CHAR_LIMIT = 1200;
const SEARCH_CURRENT_CONTENT_CHAR_LIMIT = 500;
export interface SearchSourceTag {
title: string;
url: string;
}
interface SearchContentMetadata {
sources: SearchSourceTag[];
status?: SearchTagStatus;
error?: string;
query?: string;
queryStatus?: SearchQueryStatus;
queryError?: string;
}
interface FormatSearchContentOptions {
query?: string;
queryStatus?: SearchQueryStatus;
queryError?: string;
status?: SearchTagStatus;
error?: string;
}
/**
* Format search results + user content into a single enriched message.
* The LLM sees natural-language context; the UI can parse the hidden marker.
*/
export function formatSearchContent(
results: SearchResultItem[],
userContent: string,
options?: FormatSearchContentOptions,
): string {
const sourceTags: SearchSourceTag[] = results.map((r) => ({
title: r.title,
url: r.url,
}));
const metadataPayload: SearchContentMetadata = { sources: sourceTags };
const query = options?.query?.trim();
if (query) {
metadataPayload.query = query;
}
if (options?.queryStatus) {
metadataPayload.queryStatus = options.queryStatus;
}
const queryError = options?.queryError?.trim();
if (queryError) {
metadataPayload.queryError = queryError;
}
if (options?.status) {
metadataPayload.status = options.status;
}
const error = options?.error?.trim();
if (error) {
metadataPayload.error = error;
}
const metadata = JSON.stringify(metadataPayload);
let block = `${SEARCH_MARKER_START}${metadata}${SEARCH_MARKER_END}\n`;
if (options?.status === 'error') {
block += `联网搜索失败:${error || '搜索失败'}\n\n`;
} else if (results.length === 0) {
block += '联网搜索未找到相关结果。\n\n';
} else {
block += '以下是与问题相关的网络搜索结果,请参考回答:\n\n';
results.forEach((r, i) => {
block += `${i + 1}. **${r.title}** - ${r.url}\n ${r.content}\n\n`;
});
}
return `${block}${SEARCH_SEPARATOR}${userContent}`;
}
/**
* Build custom tags for markstream-react rendering.
*/
export type SearchTagStatus = 'summarizing' | 'searching' | 'done' | 'error';
export type SearchQueryStatus = 'summarizing' | 'done' | 'error';
interface BuildSearchTagOptions {
query?: string;
queryStatus?: SearchQueryStatus;
queryError?: string;
}
function escapeHtmlAttr(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function escapeHtmlText(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function buildSearchTagAttrs(status: SearchTagStatus, options?: BuildSearchTagOptions): string {
const query = options?.query?.trim();
const queryAttr = query ? ` query="${escapeHtmlAttr(query)}"` : '';
const queryStatusAttr = options?.queryStatus
? ` query-status="${escapeHtmlAttr(options.queryStatus)}"`
: '';
const queryError = options?.queryError?.trim();
const queryErrorAttr = queryError ? ` query-error="${escapeHtmlAttr(queryError)}"` : '';
return `status="${status}" data-boosapi="1"${queryAttr}${queryStatusAttr}${queryErrorAttr}`;
}
export function buildSearchTag(
status: SearchTagStatus,
results?: SearchResultItem[],
message?: string,
options?: BuildSearchTagOptions,
): string {
if (status === 'summarizing' || status === 'searching') {
return `<web-search ${buildSearchTagAttrs(status, options)}></web-search>`;
}
if (status === 'error') {
return `<web-search ${buildSearchTagAttrs(status, options)}>${message ?? ''}</web-search>`;
}
const json = JSON.stringify(
(results ?? []).map((r) => ({ title: r.title, url: r.url, content: r.content })),
);
return `<web-search ${buildSearchTagAttrs(status, options)}>\n${json}\n</web-search>\n\n`;
}
export function buildSearchQueryTag(
status: SearchQueryStatus,
query?: string,
message?: string,
): string {
const queryAttr = query?.trim() ? ` query="${escapeHtmlAttr(query.trim())}"` : '';
return `<web-search-query status="${status}" data-boosapi="1"${queryAttr}>${message ? escapeHtmlText(message) : ''}</web-search-query>\n\n`;
}
function truncateChars(text: string, limit: number): string {
const chars = Array.from(text);
if (chars.length <= limit) return text;
return chars.slice(0, limit).join('');
}
function stripSearchEnrichment(content: string): string {
const trimmedStart = content.trimStart();
if (!trimmedStart.startsWith(SEARCH_MARKER_START)) {
return content;
}
const markerEndIdx = trimmedStart.indexOf(SEARCH_MARKER_END);
if (markerEndIdx === -1) {
return content;
}
const afterMarker = trimmedStart.substring(markerEndIdx + SEARCH_MARKER_END.length);
const separatorIdx = afterMarker.indexOf(SEARCH_SEPARATOR);
if (separatorIdx === -1) {
return content;
}
return afterMarker.substring(separatorIdx + SEARCH_SEPARATOR.length);
}
function stripBoosapiDisplayTags(content: string): string {
return ['web-search-query', 'web-search', 'knowledge-retrieval', 'memory-retrieval'].reduce((next, tagName) => {
const tagPattern = new RegExp(
`<${tagName}\\b[^>]*data-boosapi=["']?1["']?[^>]*>[\\s\\S]*?<\\/${tagName}>`,
'gi',
);
return next.replace(tagPattern, '\n');
}, content);
}
function stripMcpBlocks(content: string): string {
return content.replace(/(^|\n):::mcp[^\n]*\n[\s\S]*?\n:::\s*(?=\n|$)/gi, '\n');
}
function normalizeSearchText(content: string): string {
return stripMcpBlocks(stripBoosapiDisplayTags(stripSearchEnrichment(content)))
.replace(/<think\b[^>]*>[\s\S]*?<\/think>/gi, '\n')
.replace(/\s+/g, ' ')
.trim();
}
function isSearchPermissionMetaInstruction(content: string): boolean {
return /权限/.test(content)
&& /(你可以|可以).*(搜索|打开|访问).*(网页|网站|页面)/.test(content);
}
/**
* Build a concise search query for follow-up questions by adding recent chat context.
* The returned query is only used for the search provider; stored user content stays unchanged.
*/
export function buildContextualSearchQuery(
messages: Message[],
currentContent: string,
): string {
const currentQuestion = truncateChars(
normalizeSearchText(currentContent),
SEARCH_CURRENT_CONTENT_CHAR_LIMIT,
);
const historyCandidates = messages
.filter((message) => (
(message.role === 'user' || message.role === 'assistant')
&& message.status === 'complete'
))
.slice(-SEARCH_CONTEXT_MESSAGE_LIMIT);
const perMessageLimit = historyCandidates.length > 0
? Math.floor(SEARCH_TOTAL_CONTEXT_CHAR_LIMIT / historyCandidates.length)
: SEARCH_HISTORY_MESSAGE_CHAR_LIMIT;
const historyParts = historyCandidates
.map((message) => {
const roleLabel = message.role === 'user' ? '用户' : '助手';
const contentLimit = Math.max(
1,
Math.min(
SEARCH_HISTORY_MESSAGE_CHAR_LIMIT,
perMessageLimit - roleLabel.length - 1,
),
);
const content = truncateChars(normalizeSearchText(message.content), contentLimit);
return content ? `${message.role === 'user' ? '用户' : '助手'}:${content}` : '';
})
.filter(Boolean);
if (historyParts.length === 0) {
return currentQuestion;
}
if (isSearchPermissionMetaInstruction(currentQuestion)) {
const lastUser = [...historyCandidates]
.reverse()
.find((message) => message.role === 'user');
const lastUserContent = lastUser
? truncateChars(normalizeSearchText(lastUser.content), SEARCH_CURRENT_CONTENT_CHAR_LIMIT)
: '';
if (lastUserContent) {
return lastUserContent;
}
}
const context = truncateChars(historyParts.join('\n'), SEARCH_TOTAL_CONTEXT_CHAR_LIMIT);
return `对话上下文:\n${context}\n\n当前问题:${currentQuestion}`;
}
export function parseSearchContent(content: string): {
hasSearch: boolean;
sources: SearchSourceTag[];
status: SearchTagStatus | null;
error: string | null;
query: string | null;
queryStatus: SearchQueryStatus | null;
queryError: string | null;
userContent: string;
} {
if (!content.startsWith(SEARCH_MARKER_START)) {
return {
hasSearch: false,
sources: [],
status: null,
error: null,
query: null,
queryStatus: null,
queryError: null,
userContent: content,
};
}
const markerEndIdx = content.indexOf(SEARCH_MARKER_END);
if (markerEndIdx === -1) {
return {
hasSearch: false,
sources: [],
status: null,
error: null,
query: null,
queryStatus: null,
queryError: null,
userContent: content,
};
}
const jsonStr = content.substring(SEARCH_MARKER_START.length, markerEndIdx);
let sources: SearchSourceTag[] = [];
let status: SearchTagStatus | null = null;
let error: string | null = null;
let query: string | null = null;
let queryStatus: SearchQueryStatus | null = null;
let queryError: string | null = null;
try {
const data = JSON.parse(jsonStr);
sources = data.sources ?? [];
status = ['searching', 'done', 'error', 'summarizing'].includes(data.status)
? data.status
: null;
error = typeof data.error === 'string' && data.error.trim()
? data.error.trim()
: null;
query = typeof data.query === 'string' && data.query.trim()
? data.query.trim()
: null;
queryStatus = ['summarizing', 'done', 'error'].includes(data.queryStatus)
? data.queryStatus
: null;
queryError = typeof data.queryError === 'string' && data.queryError.trim()
? data.queryError.trim()
: null;
} catch {
// corrupted marker – treat as no search
}
const separatorIdx = content.indexOf(SEARCH_SEPARATOR);
const userContent =
separatorIdx !== -1
? content.substring(separatorIdx + SEARCH_SEPARATOR.length)
: content.substring(markerEndIdx + SEARCH_MARKER_END.length);
return { hasSearch: true, sources, status, error, query, queryStatus, queryError, userContent };
}
+127
View File
@@ -0,0 +1,127 @@
import { getCurrentWindow, getAllWindows } from '@tauri-apps/api/window';
import { message } from 'antd';
import { isTauri } from '@/lib/invoke';
import { useUIStore } from '@/stores/uiStore';
import { useSettingsStore } from '@/stores/settingsStore';
import { invoke } from '@/lib/invoke';
import type { GatewayStatus } from '@/types';
import { SHORTCUT_ACTION_LABEL_KEYS, type ShortcutAction } from '@/lib/shortcuts';
import i18n from '@/i18n';
function notifyShortcutTriggered(action: ShortcutAction) {
const settings = useSettingsStore.getState().settings;
if (!settings.shortcut_trigger_toast_enabled) return;
const actionLabel = i18n.t(SHORTCUT_ACTION_LABEL_KEYS[action]);
const text = i18n.t('settings.shortcutTriggeredMessage', { action: actionLabel });
message.info(text);
}
function dispatchWindowEvent(name: string) {
window.dispatchEvent(new CustomEvent(name));
}
function dispatchChatScopedEvent(name: string) {
const uiState = useUIStore.getState();
const shouldDelayDispatch = uiState.activePage !== 'chat';
uiState.setActivePage('chat');
window.setTimeout(() => {
dispatchWindowEvent(name);
}, shouldDelayDispatch ? 80 : 0);
}
async function toggleCurrentWindow() {
if (!isTauri()) return;
const win = getCurrentWindow();
const visible = await win.isVisible();
if (visible) {
await win.hide();
return;
}
await win.show();
await win.setFocus();
}
async function toggleAllWindows() {
if (!isTauri()) return;
const windows = await getAllWindows();
if (windows.length === 0) return;
const visibility = await Promise.all(windows.map((win) => win.isVisible()));
const shouldHide = visibility.some(Boolean);
if (shouldHide) {
await Promise.all(windows.map((win) => win.hide()));
return;
}
await Promise.all(windows.map((win) => win.show()));
await windows[0].setFocus();
}
async function closeCurrentWindow() {
if (!isTauri()) return;
await getCurrentWindow().close();
}
async function toggleGatewayPage() {
const status = await invoke<GatewayStatus>('get_gateway_status');
if (status.is_running) {
await invoke('stop_gateway');
} else {
await invoke('start_gateway');
}
}
export async function executeShortcutAction(action: ShortcutAction): Promise<void> {
switch (action) {
case 'toggleCurrentWindow':
notifyShortcutTriggered(action);
await toggleCurrentWindow();
return;
case 'toggleAllWindows':
notifyShortcutTriggered(action);
await toggleAllWindows();
return;
case 'closeWindow':
notifyShortcutTriggered(action);
await closeCurrentWindow();
return;
case 'newConversation':
notifyShortcutTriggered(action);
dispatchChatScopedEvent('boosapi:new-conversation');
return;
case 'openSettings':
notifyShortcutTriggered(action);
if (useUIStore.getState().activePage === 'settings') {
useUIStore.getState().exitSettings();
} else {
useUIStore.getState().enterSettings();
}
return;
case 'toggleModelSelector':
notifyShortcutTriggered(action);
dispatchChatScopedEvent('boosapi:toggle-model-selector');
return;
case 'toggleChatSidebar':
notifyShortcutTriggered(action);
dispatchChatScopedEvent('boosapi:toggle-chat-sidebar');
return;
case 'fillLastMessage':
notifyShortcutTriggered(action);
dispatchChatScopedEvent('boosapi:fill-last-message');
return;
case 'clearContext':
notifyShortcutTriggered(action);
dispatchChatScopedEvent('boosapi:clear-context');
return;
case 'clearConversationMessages':
notifyShortcutTriggered(action);
dispatchChatScopedEvent('boosapi:clear-conversation-messages');
return;
case 'toggleGateway':
notifyShortcutTriggered(action);
await toggleGatewayPage();
return;
case 'toggleMode':
notifyShortcutTriggered(action);
dispatchChatScopedEvent('boosapi:toggle-mode');
return;
}
}
+324
View File
@@ -0,0 +1,324 @@
import type { AppSettings } from '@/types';
export type ShortcutAction =
| 'toggleCurrentWindow'
| 'toggleAllWindows'
| 'closeWindow'
| 'newConversation'
| 'sendMessage'
| 'openSettings'
| 'toggleModelSelector'
| 'toggleChatSidebar'
| 'fillLastMessage'
| 'clearContext'
| 'clearConversationMessages'
| 'toggleGateway'
| 'toggleMode';
export interface ShortcutDescriptor {
action: ShortcutAction;
labelKey: string;
supportsGlobal: boolean;
}
export const SHORTCUT_ACTIONS: ShortcutAction[] = [
'toggleCurrentWindow',
'toggleAllWindows',
'closeWindow',
'newConversation',
'openSettings',
'toggleModelSelector',
'toggleChatSidebar',
'fillLastMessage',
'clearContext',
'clearConversationMessages',
'toggleGateway',
'toggleMode',
];
export const SHORTCUT_SETTING_ACTIONS: ShortcutAction[] = [
...SHORTCUT_ACTIONS,
'sendMessage',
];
export const SHORTCUT_DESCRIPTORS: ShortcutDescriptor[] = [
{ action: 'toggleCurrentWindow', labelKey: 'settings.shortcutAction.toggleCurrentWindow', supportsGlobal: true },
{ action: 'toggleAllWindows', labelKey: 'settings.shortcutAction.toggleAllWindows', supportsGlobal: true },
{ action: 'closeWindow', labelKey: 'settings.shortcutAction.closeWindow', supportsGlobal: true },
{ action: 'newConversation', labelKey: 'settings.shortcutAction.newConversation', supportsGlobal: false },
{ action: 'sendMessage', labelKey: 'settings.shortcutAction.sendMessage', supportsGlobal: false },
{ action: 'openSettings', labelKey: 'settings.shortcutAction.openSettings', supportsGlobal: false },
{ action: 'toggleModelSelector', labelKey: 'settings.shortcutAction.toggleModelSelector', supportsGlobal: false },
{ action: 'toggleChatSidebar', labelKey: 'settings.shortcutAction.toggleChatSidebar', supportsGlobal: false },
{ action: 'fillLastMessage', labelKey: 'settings.shortcutAction.fillLastMessage', supportsGlobal: false },
{ action: 'clearContext', labelKey: 'settings.shortcutAction.clearContext', supportsGlobal: false },
{ action: 'clearConversationMessages', labelKey: 'settings.shortcutAction.clearConversationMessages', supportsGlobal: false },
{ action: 'toggleGateway', labelKey: 'settings.shortcutAction.toggleGateway', supportsGlobal: false },
{ action: 'toggleMode', labelKey: 'settings.shortcutAction.toggleMode', supportsGlobal: false },
];
export const SHORTCUT_ACTION_LABEL_KEYS: Record<ShortcutAction, string> = {
toggleCurrentWindow: 'settings.shortcutAction.toggleCurrentWindow',
toggleAllWindows: 'settings.shortcutAction.toggleAllWindows',
closeWindow: 'settings.shortcutAction.closeWindow',
newConversation: 'settings.shortcutAction.newConversation',
sendMessage: 'settings.shortcutAction.sendMessage',
openSettings: 'settings.shortcutAction.openSettings',
toggleModelSelector: 'settings.shortcutAction.toggleModelSelector',
toggleChatSidebar: 'settings.shortcutAction.toggleChatSidebar',
fillLastMessage: 'settings.shortcutAction.fillLastMessage',
clearContext: 'settings.shortcutAction.clearContext',
clearConversationMessages: 'settings.shortcutAction.clearConversationMessages',
toggleGateway: 'settings.shortcutAction.toggleGateway',
toggleMode: 'settings.shortcutAction.toggleMode',
};
export const GLOBAL_SHORTCUT_ACTIONS: ShortcutAction[] = [
'toggleCurrentWindow',
'toggleAllWindows',
'closeWindow',
];
export function isGlobalShortcutAction(action: ShortcutAction): boolean {
return GLOBAL_SHORTCUT_ACTIONS.includes(action);
}
export type ShortcutConflictMap = Partial<Record<ShortcutAction, ShortcutAction[]>>;
export const SHORTCUT_SETTING_KEYS = {
toggleCurrentWindow: 'shortcut_toggle_current_window',
toggleAllWindows: 'shortcut_toggle_all_windows',
closeWindow: 'shortcut_close_window',
newConversation: 'shortcut_new_conversation',
sendMessage: 'shortcut_send_message',
openSettings: 'shortcut_open_settings',
toggleModelSelector: 'shortcut_toggle_model_selector',
toggleChatSidebar: 'shortcut_toggle_chat_sidebar',
fillLastMessage: 'shortcut_fill_last_message',
clearContext: 'shortcut_clear_context',
clearConversationMessages: 'shortcut_clear_conversation_messages',
toggleGateway: 'shortcut_toggle_gateway',
toggleMode: 'shortcut_toggle_mode',
} as const satisfies Record<ShortcutAction, keyof AppSettings>;
export type ShortcutSettingKey = (typeof SHORTCUT_SETTING_KEYS)[ShortcutAction];
export const DEFAULT_SHORTCUT_BINDINGS: Record<ShortcutAction, string> = {
toggleCurrentWindow: 'CmdOrCtrl+Shift+A',
toggleAllWindows: 'CmdOrCtrl+Shift+Alt+A',
closeWindow: 'CmdOrCtrl+Shift+W',
newConversation: 'CmdOrCtrl+N',
sendMessage: 'Enter',
openSettings: 'CmdOrCtrl+,',
toggleModelSelector: 'CmdOrCtrl+Shift+M',
toggleChatSidebar: 'CmdOrCtrl+L',
fillLastMessage: 'CmdOrCtrl+Shift+ArrowUp',
clearContext: 'CmdOrCtrl+Shift+K',
clearConversationMessages: 'CmdOrCtrl+Shift+Backspace',
toggleGateway: 'CmdOrCtrl+Shift+G',
toggleMode: 'Shift+Tab',
};
const DISPLAY_MAP: Record<string, string> = {
Shift: '⇧',
Alt: '⌥',
Control: '⌃',
Enter: '↩',
Backspace: '⌫',
Tab: '⇥',
Escape: 'Esc',
ArrowUp: '↑',
ArrowDown: '↓',
ArrowLeft: '←',
ArrowRight: '→',
Space: '␣',
};
function isMacPlatform(): boolean {
if (typeof navigator === 'undefined') return true;
const platform = navigator.platform || '';
if (/Mac|iPhone|iPad|iPod/i.test(platform)) return true;
const userAgent = navigator.userAgent || '';
return /Mac OS/i.test(userAgent) && !/Windows|Linux|Android/i.test(userAgent);
}
function getDisplayToken(token: string): string {
if (token === 'CmdOrCtrl' || token === 'CommandOrControl') {
return isMacPlatform() ? '⌘' : 'Ctrl';
}
return DISPLAY_MAP[token] ?? token;
}
function normalizeModifierToken(token: string): string {
switch (token) {
case 'CommandOrControl':
case 'CmdOrCtrl':
return 'CmdOrCtrl';
case 'Control':
case 'Shift':
case 'Alt':
return token;
default:
return token;
}
}
function normalizeKeyToken(token: string): string {
if (token.length === 1) {
return token.toUpperCase();
}
if (token === 'Comma') return ',';
if (token === 'Period') return '.';
if (token === 'Slash') return '/';
if (token === 'Semicolon') return ';';
if (token === ' ') return 'Space';
if (token === 'Esc') return 'Escape';
return token;
}
function tokenize(binding: string): string[] {
return binding
.split('+')
.map((part) => part.trim())
.filter(Boolean)
.map((token) => normalizeKeyToken(normalizeModifierToken(token)));
}
export function getShortcutBinding(settings: AppSettings, action: ShortcutAction): string {
const key = SHORTCUT_SETTING_KEYS[action];
const raw = String(settings[key] ?? '').trim();
if (raw) return raw;
return DEFAULT_SHORTCUT_BINDINGS[action];
}
export function getShortcutBindingByKey(settings: AppSettings, key: ShortcutSettingKey): string {
const action = SHORTCUT_SETTING_ACTIONS.find((item) => SHORTCUT_SETTING_KEYS[item] === key);
if (!action) {
throw new Error(`Unknown shortcut setting key: ${key}`);
}
return getShortcutBinding(settings, action);
}
export function formatShortcutForDisplay(binding: string): string {
return tokenize(binding).map(getDisplayToken).join(' + ');
}
function normalizeEventKey(key: string): string {
if (key.length === 1) return key.toUpperCase();
if (key === ' ') return 'Space';
if (key === 'Esc') return 'Escape';
return key;
}
export function normalizeShortcutFromKeyboardEvent(
event: Pick<KeyboardEvent, 'metaKey' | 'ctrlKey' | 'shiftKey' | 'altKey' | 'key'>,
): string | null {
const parts: string[] = [];
if (event.metaKey || event.ctrlKey) parts.push('CmdOrCtrl');
if (event.shiftKey) parts.push('Shift');
if (event.altKey) parts.push('Alt');
const key = normalizeEventKey(event.key);
if (['Control', 'Shift', 'Alt', 'Meta', 'Command'].includes(key)) return null;
parts.push(key);
return parts.join('+');
}
export function toTauriAccelerator(binding: string): string {
return tokenize(binding)
.map((part) => {
if (part === 'CmdOrCtrl') return 'CommandOrControl';
if (part === ',') return 'Comma';
if (part === '.') return 'Period';
if (part === '/') return 'Slash';
if (part === ';') return 'Semicolon';
return part;
})
.join('+');
}
export function matchesShortcutEvent(event: KeyboardEvent, binding: string): boolean {
const tokens = tokenize(binding);
if (tokens.length === 0) return false;
const wantsCmdOrCtrl = tokens.includes('CmdOrCtrl');
const wantsShift = tokens.includes('Shift');
const wantsAlt = tokens.includes('Alt');
const wantsControl = tokens.includes('Control');
const hasCmdOrCtrl = event.metaKey || event.ctrlKey;
if (wantsCmdOrCtrl) {
if (!hasCmdOrCtrl) return false;
} else if (wantsControl) {
if (!event.ctrlKey) return false;
} else if (hasCmdOrCtrl) {
return false;
}
if (wantsShift !== event.shiftKey) return false;
if (wantsAlt !== event.altKey) return false;
const keyToken = tokens.find((token) => !['CmdOrCtrl', 'Shift', 'Alt', 'Control'].includes(token));
if (!keyToken) return false;
return normalizeEventKey(event.key) === normalizeKeyToken(keyToken);
}
export function detectShortcutConflicts(bindings: Partial<Record<ShortcutAction, string>>): ShortcutConflictMap {
const grouped = new Map<string, ShortcutAction[]>();
for (const action of SHORTCUT_SETTING_ACTIONS) {
const raw = String(bindings[action] ?? '').trim();
if (!raw) continue;
const canonical = toTauriAccelerator(raw).toLowerCase();
if (!canonical) continue;
const actions = grouped.get(canonical) ?? [];
actions.push(action);
grouped.set(canonical, actions);
}
const conflicts: ShortcutConflictMap = {};
for (const actions of grouped.values()) {
if (actions.length < 2) continue;
for (const action of actions) {
conflicts[action] = actions.filter((item) => item !== action);
}
}
return conflicts;
}
/**
* Known external app shortcuts that commonly conflict with BoosApi.
* Each entry maps a canonical accelerator (lowercase) to a list of
* app names that use the same shortcut.
*/
const KNOWN_EXTERNAL_CONFLICTS: Array<{
accelerators: string[];
apps: string;
}> = [
{
accelerators: ['commandorcontrol+shift+a', 'control+shift+a', 'command+shift+a'],
apps: '飞书/微信/企业微信/钉钉',
},
{
accelerators: ['control+alt+a', 'command+alt+a'],
apps: 'QQ/微信',
},
{
accelerators: ['alt+a'],
apps: '微信 (Windows)',
},
];
/**
* Check whether a Tauri accelerator string matches a known external-app shortcut.
* Returns the conflicting app description or `undefined` if no conflict is found.
*/
export function findExternalConflict(accelerator: string): string | undefined {
const lower = accelerator.toLowerCase();
for (const entry of KNOWN_EXTERNAL_CONFLICTS) {
if (entry.accelerators.includes(lower)) {
return entry.apps;
}
}
return undefined;
}
+115
View File
@@ -0,0 +1,115 @@
export type StartupLogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error';
export type StartupDiagnosticWriter = (
level: StartupLogLevel,
message: string,
) => void | Promise<void>;
const MAX_DIAGNOSTIC_TEXT_LENGTH = 8 * 1024;
export function formatStartupError(reason: unknown): string {
if (reason instanceof Error) {
return truncateDiagnosticText(reason.stack || reason.message || reason.name);
}
if (typeof reason === 'string') {
return truncateDiagnosticText(reason);
}
try {
return truncateDiagnosticText(JSON.stringify(reason) ?? String(reason));
} catch {
return String(reason);
}
}
export async function writeStartupDiagnostic(
level: StartupLogLevel,
message: string,
): Promise<void> {
if (!isTauriRuntime()) return;
try {
const { invoke } = await import('@tauri-apps/api/core');
await invoke('write_diagnostic_log', {
level,
message: truncateDiagnosticText(message),
});
} catch {
// Logging must never break startup.
}
}
export function installStartupDiagnostics(
writeLog: StartupDiagnosticWriter = writeStartupDiagnostic,
): () => void {
const handleError = (event: ErrorEvent) => {
void writeLog(
'error',
[
'frontend window error',
`message=${event.message || '<empty>'}`,
`filename=${event.filename || '<unknown>'}`,
`line=${event.lineno || 0}`,
`column=${event.colno || 0}`,
event.error ? `error=${formatStartupError(event.error)}` : null,
].filter(Boolean).join(' '),
);
};
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
void writeLog(
'error',
`frontend unhandled rejection: ${formatStartupError(event.reason)}`,
);
};
window.addEventListener('error', handleError);
window.addEventListener('unhandledrejection', handleUnhandledRejection);
return () => {
window.removeEventListener('error', handleError);
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
};
}
export function renderStartupError(root: HTMLElement, reason: unknown): void {
const panel = document.createElement('div');
panel.style.minHeight = '100vh';
panel.style.boxSizing = 'border-box';
panel.style.padding = '32px';
panel.style.fontFamily = 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
panel.style.background = '#111827';
panel.style.color = '#f9fafb';
const title = document.createElement('h1');
title.textContent = 'BoosAPI Desktop startup failed';
title.style.margin = '0 0 12px';
title.style.fontSize = '20px';
title.style.fontWeight = '600';
const description = document.createElement('p');
description.textContent = 'Please restart BoosAPI Desktop with BOOSAPI_LOG_FILE and RUST_LOG=debug, then attach the generated log file.';
description.style.margin = '0 0 16px';
description.style.color = '#d1d5db';
description.style.lineHeight = '1.5';
const pre = document.createElement('pre');
pre.textContent = formatStartupError(reason);
pre.style.whiteSpace = 'pre-wrap';
pre.style.wordBreak = 'break-word';
pre.style.padding = '16px';
pre.style.borderRadius = '8px';
pre.style.background = '#020617';
pre.style.color = '#fca5a5';
pre.style.overflow = 'auto';
pre.style.maxHeight = '60vh';
panel.append(title, description, pre);
root.replaceChildren(panel);
}
function isTauriRuntime(): boolean {
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
}
function truncateDiagnosticText(text: string): string {
if (text.length <= MAX_DIAGNOSTIC_TEXT_LENGTH) return text;
return `${text.slice(0, MAX_DIAGNOSTIC_TEXT_LENGTH)}...<truncated>`;
}
+97
View File
@@ -0,0 +1,97 @@
export type StreamActivityPhase = 'waiting_first_packet' | 'streaming' | 'waiting_next_chunk';
export interface StreamActivity {
startedAt: number;
firstChunkAt: number | null;
lastChunkAt: number | null;
providerId?: string | null;
modelId?: string | null;
phase: StreamActivityPhase;
error?: string | null;
errorKind?: string | null;
timeoutSecs?: number | null;
}
export interface StreamingStatusPresentation {
labelKey: string;
phase: StreamActivityPhase;
tone: 'active' | 'warning';
}
export const STREAM_WAITING_PROVIDER_THRESHOLD_MS = 10_000;
export const STREAM_WAITING_NEXT_CHUNK_THRESHOLD_MS = 15_000;
export const STREAM_ERROR_CONTENT_MARKER = '<!-- boosapi-stream-error -->';
export function getStreamingStatusPresentation(input: {
isStreaming: boolean;
activity?: StreamActivity | null;
now: number;
hasModelText: boolean;
}): StreamingStatusPresentation | null {
if (!input.isStreaming) {
return null;
}
const activity = input.activity;
const startedAt = activity?.startedAt ?? input.now;
const firstChunkAt = activity?.firstChunkAt ?? null;
const lastChunkAt = activity?.lastChunkAt ?? firstChunkAt;
if (!firstChunkAt && !input.hasModelText) {
const elapsed = Math.max(0, input.now - startedAt);
if (elapsed >= STREAM_WAITING_PROVIDER_THRESHOLD_MS) {
return {
labelKey: 'chat.streamingStatus.waitingProvider',
phase: 'waiting_first_packet',
tone: 'warning',
};
}
return {
labelKey: 'chat.streamingStatus.waitingFirstPacket',
phase: 'waiting_first_packet',
tone: 'active',
};
}
const idleFor = lastChunkAt ? Math.max(0, input.now - lastChunkAt) : 0;
if (idleFor >= STREAM_WAITING_NEXT_CHUNK_THRESHOLD_MS) {
return {
labelKey: 'chat.streamingStatus.waitingNextChunk',
phase: 'waiting_next_chunk',
tone: 'warning',
};
}
return {
labelKey: 'chat.streamingStatus.generating',
phase: 'streaming',
tone: 'active',
};
}
export function appendStreamErrorToContent(content: string, error: string): string {
const trimmedError = error.trim();
if (!content.trim()) {
return trimmedError;
}
if (content.includes(STREAM_ERROR_CONTENT_MARKER)) {
const [prefix] = content.split(STREAM_ERROR_CONTENT_MARKER);
return `${prefix.trimEnd()}\n\n${STREAM_ERROR_CONTENT_MARKER}\n${trimmedError}`;
}
return `${content.trimEnd()}\n\n${STREAM_ERROR_CONTENT_MARKER}\n${trimmedError}`;
}
export function splitStreamErrorContent(content: string): { prefix: string; error: string } | null {
const markerIndex = content.indexOf(STREAM_ERROR_CONTENT_MARKER);
if (markerIndex < 0) {
return null;
}
return {
prefix: content.slice(0, markerIndex).trimEnd(),
error: content.slice(markerIndex + STREAM_ERROR_CONTENT_MARKER.length).trim(),
};
}
+18
View File
@@ -0,0 +1,18 @@
const FENCED_CODE_BLOCK_RE = /(```[\s\S]*?```|~~~[\s\S]*?~~~)/g;
function normalizeThinkTagSegment(content: string): string {
return content
.replace(/(<think\b[^>]*>)(?!\n)/gi, '$1\n')
.replace(/([^\n])(<\/think\s*>)/gi, '$1\n$2');
}
export function normalizeThinkTagsForMarkdown(content: string): string {
return content
.split(FENCED_CODE_BLOCK_RE)
.map((segment) => (
segment.startsWith('```') || segment.startsWith('~~~')
? segment
: normalizeThinkTagSegment(segment)
))
.join('');
}
+42
View File
@@ -0,0 +1,42 @@
/**
* Lightweight token estimation mirroring the Rust token_counter.rs.
* Uses character-based heuristics (~15% accuracy for mixed-language text).
*/
function isCjk(code: number): boolean {
return (
(code >= 0x4e00 && code <= 0x9fff) ||
(code >= 0x3400 && code <= 0x4dbf) ||
(code >= 0xf900 && code <= 0xfaff) ||
(code >= 0x3000 && code <= 0x303f) ||
(code >= 0xff00 && code <= 0xffef) ||
(code >= 0xac00 && code <= 0xd7af) ||
(code >= 0x3040 && code <= 0x309f) ||
(code >= 0x30a0 && code <= 0x30ff)
);
}
export function estimateTokens(text: string): number {
if (!text) return 0;
let asciiChars = 0;
let cjkChars = 0;
for (let i = 0; i < text.length; i++) {
if (isCjk(text.charCodeAt(i))) {
cjkChars++;
} else {
asciiChars++;
}
}
const asciiTokens = Math.ceil(asciiChars / 4);
const cjkTokens = Math.ceil((cjkChars * 2) / 3);
return asciiTokens + cjkTokens;
}
const PER_MESSAGE_OVERHEAD = 4;
export function estimateMessageTokens(role: string, content: string): number {
return estimateTokens(role) + estimateTokens(content) + PER_MESSAGE_OVERHEAD;
}