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:
@@ -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 "Windows" <download>"');
|
||||
});
|
||||
|
||||
it('renders query summary failure details', () => {
|
||||
const tag = buildSearchQueryTag('error', undefined, '模型返回 <empty>');
|
||||
|
||||
expect(tag).toContain('status="error"');
|
||||
expect(tag).toContain('模型返回 <empty>');
|
||||
});
|
||||
});
|
||||
|
||||
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
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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, '');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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('')}...`;
|
||||
}
|
||||
@@ -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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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'] }])
|
||||
}
|
||||
@@ -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 () => {};
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function escapeTagAttr(value: string): string {
|
||||
return escapeTagText(value).replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function escapeHtmlText(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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('');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user