diff --git a/src/crates/services/services-integrations/src/speech/realtime.rs b/src/crates/services/services-integrations/src/speech/realtime.rs index 853bc118df..970fb61bef 100644 --- a/src/crates/services/services-integrations/src/speech/realtime.rs +++ b/src/crates/services/services-integrations/src/speech/realtime.rs @@ -37,7 +37,7 @@ const BITFUN_VOICE_INSTRUCTIONS: &str = r#"You are BitFun's client-level realtim Use get_bitfun_client_context whenever the user asks about the current client, open workspaces/projects, visible sessions, running tasks, or names a workspace whose exact id is not already known from a fresh context result. Never guess a workspace id. Use switch_bitfun_workspace for navigation-only requests. When the user asks you to inspect, create, change, run, debug, research, or otherwise complete work, call run_bitfun_task with a complete standalone task description and the intended workspace_id. Omit workspace_id only when the user clearly means the active workspace. Set activate_workspace to true when the user asks to enter, switch to, or visibly work in that workspace; use false only for an explicit background request. -If the user asks to stop, cancel, abort, or interrupt the BitFun task currently running through this client voice assistant, call stop_bitfun_task immediately. A stop request is a control operation, not a new task: never pass it to run_bitfun_task and never claim the task stopped before the stop_bitfun_task result confirms it. Do not claim that work is complete before the tool result arrives. BitFun will speak brief public progress summaries while the Agent task is running; do not expose private reasoning, raw logs, or tool payloads. After the tool result arrives, summarize the outcome clearly and mention any user action still required. Never invent client state or task results."#; +If the user asks to stop, cancel, abort, or interrupt the BitFun task currently running through this client voice assistant, call stop_bitfun_task immediately. A stop request is a control operation, not a new task: never pass it to run_bitfun_task and never claim the task stopped before the stop_bitfun_task result confirms it. Do not claim that work is complete before the tool result arrives. BitFun will speak brief public progress summaries while the Agent task is running; do not expose private reasoning, raw logs, or tool payloads. BitFun also speaks a concise final outcome itself. When a task tool result contains outcome_spoken=true, do not repeat that outcome; wait for the user's next request. If outcome_spoken is false or absent, summarize the outcome clearly and mention any user action still required. Never invent client state or task results."#; #[derive(Debug, Clone)] pub struct VolcengineRealtimeSpeechConfig { @@ -916,6 +916,7 @@ mod tests { .and_then(Value::as_str) .unwrap(); assert!(instructions.contains("never claim the task stopped")); + assert!(instructions.contains("outcome_spoken=true")); assert!(instructions.contains("workspace-1")); assert_eq!( payload.pointer("/extension/extra/enable_proactive_speak"), diff --git a/src/web-ui/src/app/App.tsx b/src/web-ui/src/app/App.tsx index c19177cd33..8fc4a9f178 100644 --- a/src/web-ui/src/app/App.tsx +++ b/src/web-ui/src/app/App.tsx @@ -28,6 +28,10 @@ import { hideStartupOverlay, isStartupOverlayPresent, } from './startup/startupOverlay'; +import { + clearStartupModuleReloadAttempt, + retryStartupAfterModuleLoadFailure, +} from './startup/startupModuleRecovery'; import { ToolbarModeProvider } from '../flow_chat/components/toolbar-mode/ToolbarModeProvider'; import { RealtimeVoiceCallProvider } from '../flow_chat/components/voice/RealtimeVoiceCallContext'; import type { AgentCompanionPetCommand } from './services/agentCompanionPetCommands'; @@ -49,6 +53,7 @@ const LazyAppLayout = lazy(async () => { startupTrace.markPhase('app_layout_import_start'); try { const module = await import('./layout/AppLayout'); + clearStartupModuleReloadAttempt(); startupTrace.markPhase('app_layout_import_end'); return { default: function AppLayoutStartupGate({ onReady }: AppLayoutStartupGateProps) { @@ -62,6 +67,13 @@ const LazyAppLayout = lazy(async () => { }; } catch (error) { startupTrace.markPhase('app_layout_import_failed'); + if (retryStartupAfterModuleLoadFailure(error)) { + startupTrace.markPhase('app_layout_import_reload_requested'); + return await new Promise(() => undefined); + } + // The static overlay otherwise hides AppErrorBoundary and makes a real + // startup failure look like an endless loading state. + void hideStartupOverlay(); throw error; } }); diff --git a/src/web-ui/src/app/startup/startupModuleRecovery.test.ts b/src/web-ui/src/app/startup/startupModuleRecovery.test.ts new file mode 100644 index 0000000000..745e3add48 --- /dev/null +++ b/src/web-ui/src/app/startup/startupModuleRecovery.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + clearStartupModuleReloadAttempt, + isRecoverableStartupModuleLoadError, + retryStartupAfterModuleLoadFailure, +} from './startupModuleRecovery'; + +function createStorage() { + const values = new Map(); + return { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + }; +} + +describe('startup module recovery', () => { + it('recognizes WebKit and Chromium dynamic module load failures', () => { + expect(isRecoverableStartupModuleLoadError( + new TypeError('Importing a module script failed.'), + )).toBe(true); + expect(isRecoverableStartupModuleLoadError( + new TypeError('Failed to fetch dynamically imported module'), + )).toBe(true); + expect(isRecoverableStartupModuleLoadError(new Error('render failed'))).toBe(false); + }); + + it('reloads at most once until a module import succeeds', () => { + const storage = createStorage(); + const reload = vi.fn(); + const runtime = { isDevelopment: true, storage, reload }; + const error = new TypeError('Importing a module script failed.'); + + expect(retryStartupAfterModuleLoadFailure(error, runtime)).toBe(true); + expect(reload).toHaveBeenCalledOnce(); + expect(retryStartupAfterModuleLoadFailure(error, runtime)).toBe(false); + expect(reload).toHaveBeenCalledOnce(); + + clearStartupModuleReloadAttempt(storage); + expect(retryStartupAfterModuleLoadFailure(error, runtime)).toBe(true); + expect(reload).toHaveBeenCalledTimes(2); + }); + + it('does not reload production or non-module failures', () => { + const storage = createStorage(); + const reload = vi.fn(); + + expect(retryStartupAfterModuleLoadFailure( + new TypeError('Importing a module script failed.'), + { isDevelopment: false, storage, reload }, + )).toBe(false); + expect(retryStartupAfterModuleLoadFailure( + new Error('application initialization failed'), + { isDevelopment: true, storage, reload }, + )).toBe(false); + expect(reload).not.toHaveBeenCalled(); + }); + + it('falls through to the error boundary if reload itself fails', () => { + const storage = createStorage(); + const reload = vi.fn(() => { + throw new Error('reload unavailable'); + }); + + expect(retryStartupAfterModuleLoadFailure( + new TypeError('Importing a module script failed.'), + { isDevelopment: true, storage, reload }, + )).toBe(false); + + expect(retryStartupAfterModuleLoadFailure( + new TypeError('Importing a module script failed.'), + { isDevelopment: true, storage, reload: vi.fn() }, + )).toBe(true); + }); +}); diff --git a/src/web-ui/src/app/startup/startupModuleRecovery.ts b/src/web-ui/src/app/startup/startupModuleRecovery.ts new file mode 100644 index 0000000000..1d906bb79a --- /dev/null +++ b/src/web-ui/src/app/startup/startupModuleRecovery.ts @@ -0,0 +1,72 @@ +const STARTUP_MODULE_RELOAD_KEY = 'bitfun:startup-module-reload-attempted'; + +const DYNAMIC_MODULE_LOAD_ERROR = + /(?:importing a module script failed|failed to fetch dynamically imported module|error loading dynamically imported module|load failed)/i; + +type StartupModuleRecoveryStorage = Pick; + +interface StartupModuleRecoveryRuntime { + isDevelopment: boolean; + storage: StartupModuleRecoveryStorage; + reload: () => void; +} + +function browserRuntime(): StartupModuleRecoveryRuntime | null { + try { + return { + isDevelopment: import.meta.env.DEV, + storage: window.sessionStorage, + reload: () => window.location.reload(), + }; + } catch { + return null; + } +} + +export function isRecoverableStartupModuleLoadError(error: unknown): boolean { + if (!(error instanceof Error) || error.name !== 'TypeError') { + return false; + } + return DYNAMIC_MODULE_LOAD_ERROR.test(error.message); +} + +/** + * Reload once when a development WebView rejects a lazy module request. + * A single retry recovers stale/transient Vite module responses without + * turning a persistent code or server error into a reload loop. + */ +export function retryStartupAfterModuleLoadFailure( + error: unknown, + runtime: StartupModuleRecoveryRuntime | null = browserRuntime(), +): boolean { + if (!runtime?.isDevelopment || !isRecoverableStartupModuleLoadError(error)) { + return false; + } + + try { + if (runtime.storage.getItem(STARTUP_MODULE_RELOAD_KEY) === '1') { + return false; + } + runtime.storage.setItem(STARTUP_MODULE_RELOAD_KEY, '1'); + runtime.reload(); + return true; + } catch { + try { + runtime.storage.removeItem(STARTUP_MODULE_RELOAD_KEY); + } catch { + // Storage may be unavailable in a restricted WebView. The caller will + // reveal the normal application error boundary instead. + } + return false; + } +} + +export function clearStartupModuleReloadAttempt( + storage?: StartupModuleRecoveryStorage, +): void { + try { + (storage ?? window.sessionStorage).removeItem(STARTUP_MODULE_RELOAD_KEY); + } catch { + // A successful module import must not fail because storage is unavailable. + } +} diff --git a/src/web-ui/src/app/startup/startupPerformanceContract.test.ts b/src/web-ui/src/app/startup/startupPerformanceContract.test.ts index 4c5f7a4f8a..9fe769c43a 100644 --- a/src/web-ui/src/app/startup/startupPerformanceContract.test.ts +++ b/src/web-ui/src/app/startup/startupPerformanceContract.test.ts @@ -292,6 +292,14 @@ describe('startup performance contract', () => { expect(source).toContain('!appLayoutReady'); }); + it('recovers development module load failures without trapping the error boundary behind the startup overlay', () => { + const source = readSource('../App.tsx'); + + expect(source).toContain('retryStartupAfterModuleLoadFailure(error)'); + expect(source).toContain("startupTrace.markPhase('app_layout_import_reload_requested')"); + expect(source).toMatch(/void hideStartupOverlay\(\);\s+throw error;/); + }); + it('keeps non-default shell surfaces out of the startup import path', () => { const appSource = readSource('../App.tsx'); const appLayoutSource = readSource('../layout/AppLayout.tsx'); diff --git a/src/web-ui/src/flow_chat/components/voice/useRealtimeVoiceCall.ts b/src/web-ui/src/flow_chat/components/voice/useRealtimeVoiceCall.ts index 32f271fc18..9719440038 100644 --- a/src/web-ui/src/flow_chat/components/voice/useRealtimeVoiceCall.ts +++ b/src/web-ui/src/flow_chat/components/voice/useRealtimeVoiceCall.ts @@ -22,6 +22,7 @@ import { RealtimePcmPlayer } from './realtimeVoiceAudio'; import { applyRealtimeAsrSnapshot } from './realtimeVoiceTranscript'; import { runBitFunVoiceTask, + summarizeVoiceTaskConclusion, VoiceTaskCancelledError, type VoiceTaskProgress, type VoiceTaskProgressPhase, @@ -245,7 +246,7 @@ export function useRealtimeVoiceCallController(disabled = false): RealtimeVoiceC return t(`voiceCall.call.taskPhases.${progress.phase}`); }, [t]); - const enqueueSpokenProgress = useCallback((sessionId: string, text: string): Promise => { + const enqueueSpokenTaskText = useCallback((sessionId: string, text: string): Promise => { const spokenText = text.trim(); if (!spokenText) return Promise.resolve(); const epoch = spokenProgressEpochRef.current; @@ -259,7 +260,7 @@ export function useRealtimeVoiceCallController(disabled = false): RealtimeVoiceC try { await speechAPI.speakRealtimeText(sessionId, spokenText); } catch (firstError) { - log.warn('Failed to enqueue BitFun progress speech; retrying once', { + log.warn('Failed to enqueue BitFun task speech; retrying once', { sessionId, firstError, }); @@ -279,7 +280,7 @@ export function useRealtimeVoiceCallController(disabled = false): RealtimeVoiceC .catch(() => undefined) .then(send); spokenProgressQueueRef.current = queued.catch(error => { - log.warn('Failed to speak BitFun task progress after retry', { sessionId, error }); + log.warn('Failed to speak BitFun task update after retry', { sessionId, error }); setStatus(t('voiceCall.call.status.audioPlaybackFailed')); }); return queued; @@ -290,15 +291,33 @@ export function useRealtimeVoiceCallController(disabled = false): RealtimeVoiceC setTaskPhase(progress.phase); setTaskProgressText(text); setStatus(text); - void enqueueSpokenProgress(sessionId, text); - }, [enqueueSpokenProgress, progressText]); + void enqueueSpokenTaskText(sessionId, text); + }, [enqueueSpokenTaskText, progressText]); const speakTextProgress = useCallback((sessionId: string, text: string) => { setTaskPhase(previous => previous === 'stopping' ? previous : 'working'); setTaskProgressText(text); setStatus(text); - void enqueueSpokenProgress(sessionId, text); - }, [enqueueSpokenProgress]); + void enqueueSpokenTaskText(sessionId, text); + }, [enqueueSpokenTaskText]); + + const speakTaskOutcome = useCallback(async ( + sessionId: string, + text: string, + ): Promise => { + setTaskPhase(null); + setTaskProgressText(''); + setAssistantTranscript(text); + setStatus(text); + try { + // Share the progress queue so the closing brief cannot overtake an + // already accepted in-flight update. + await enqueueSpokenTaskText(sessionId, text); + return true; + } catch { + return false; + } + }, [enqueueSpokenTaskText]); const clearAssistantSpeechFallbackTimer = useCallback(() => { if (assistantSpeechFallbackTimerRef.current === null) return; @@ -516,14 +535,19 @@ export function useRealtimeVoiceCallController(disabled = false): RealtimeVoiceC }); settleActiveTask(activeTask, { status: 'completed', result }); setTaskSessionId(result.sessionId); - setTaskPhase(null); - setTaskProgressText(''); - setStatus(t('voiceCall.call.taskComplete')); - await spokenProgressQueueRef.current.catch(() => undefined); + const outcomeText = result.conclusion + ? t('voiceCall.call.taskOutcome.completed', { conclusion: result.conclusion }) + : t('voiceCall.call.taskOutcome.completedWithoutConclusion'); + const outcomeSpoken = await speakTaskOutcome(callSessionId, outcomeText); await speechAPI.sendRealtimeToolResult( callSessionId, call.callId, - JSON.stringify({ ok: true, session_id: result.sessionId, summary: result.summary }), + JSON.stringify({ + ok: true, + session_id: result.sessionId, + summary: result.summary, + outcome_spoken: outcomeSpoken, + }), ); } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -533,13 +557,19 @@ export function useRealtimeVoiceCallController(disabled = false): RealtimeVoiceC if (activeTask && error instanceof VoiceTaskCancelledError) { settleActiveTask(activeTask, { status: 'cancelled', sessionId: error.sessionId }); setTaskSessionId(error.sessionId); - setTaskPhase(null); - setTaskProgressText(''); - setStatus(t('voiceCall.call.taskStopped')); + const outcomeSpoken = await speakTaskOutcome( + callSessionId, + t('voiceCall.call.taskOutcome.cancelled'), + ); await speechAPI.sendRealtimeToolResult( callSessionId, call.callId, - JSON.stringify({ ok: true, cancelled: true, session_id: error.sessionId }), + JSON.stringify({ + ok: true, + cancelled: true, + session_id: error.sessionId, + outcome_spoken: outcomeSpoken, + }), ).catch(sendError => { log.warn('Failed to return BitFun task cancellation to realtime voice session', { sendError, @@ -550,18 +580,28 @@ export function useRealtimeVoiceCallController(disabled = false): RealtimeVoiceC if (activeTask) { settleActiveTask(activeTask, { status: 'failed', error: message }); } - setTaskPhase(null); - setTaskProgressText(''); - setStatus( - command?.kind === 'run_task' || command?.kind === 'stop_task' - ? t('voiceCall.call.taskFailed') - : t('voiceCall.call.status.error'), - ); + const isTaskCommand = command?.kind === 'run_task' || command?.kind === 'stop_task'; + let outcomeSpoken: boolean | undefined; + if (isTaskCommand) { + const reason = summarizeVoiceTaskConclusion(message); + const outcomeText = reason + ? t('voiceCall.call.taskOutcome.failed', { reason }) + : t('voiceCall.call.taskOutcome.failedWithoutReason'); + outcomeSpoken = await speakTaskOutcome(callSessionId, outcomeText); + } else { + setTaskPhase(null); + setTaskProgressText(''); + setStatus(t('voiceCall.call.status.error')); + } log.error('BitFun client voice tool failed', { callId: call.callId, tool: call.name, error }); await speechAPI.sendRealtimeToolResult( callSessionId, call.callId, - JSON.stringify({ ok: false, error: message }), + JSON.stringify({ + ok: false, + error: message, + ...(outcomeSpoken === undefined ? {} : { outcome_spoken: outcomeSpoken }), + }), ).catch(sendError => { log.warn('Failed to return BitFun task error to realtime voice session', { sendError }); }); @@ -570,7 +610,7 @@ export function useRealtimeVoiceCallController(disabled = false): RealtimeVoiceC activeTaskRef.current = null; } } - }, [speakProgress, speakTextProgress, t]); + }, [speakProgress, speakTaskOutcome, speakTextProgress, t]); const handleRealtimeEvent = useCallback((event: SpeechRealtimeEvent) => { const session = sessionRef.current; diff --git a/src/web-ui/src/flow_chat/components/voice/voiceTaskBridge.test.ts b/src/web-ui/src/flow_chat/components/voice/voiceTaskBridge.test.ts index 6f07f9839e..8d988fd7c6 100644 --- a/src/web-ui/src/flow_chat/components/voice/voiceTaskBridge.test.ts +++ b/src/web-ui/src/flow_chat/components/voice/voiceTaskBridge.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from 'vitest'; import type { Session } from '@/flow_chat/types/flow-chat'; import { + extractVoiceTaskConclusion, extractVoiceTaskProgressTexts, extractVoiceTaskSummary, + summarizeVoiceTaskConclusion, summarizeVoiceTaskProgress, } from './voiceTaskBridge'; @@ -87,8 +89,8 @@ describe('extractVoiceTaskSummary', () => { ], 'processing')); expect(updates).toEqual([{ - id: 'round-1:progress:Progress: Finished reading the config. Now checking audio output.', - text: 'Progress: Finished reading the config. Now checking audio output.', + id: 'round-1:progress:Finished reading the config. Now checking audio output.', + text: 'Finished reading the config. Now checking audio output.', }]); }); @@ -110,8 +112,8 @@ describe('extractVoiceTaskSummary', () => { status: 'completed', isStreaming: false, }], 'finishing'))).toEqual([{ - id: 'round-1:verification:Progress: Tests are complete. Preparing the final result.', - text: 'Progress: Tests are complete. Preparing the final result.', + id: 'round-1:verification:Tests are complete. Preparing the final result.', + text: 'Tests are complete. Preparing the final result.', }]); }); @@ -120,9 +122,85 @@ describe('extractVoiceTaskSummary', () => { const spoken = summarizeVoiceTaskProgress(original); - expect(spoken).toBe('进展:配置文件读取和依赖检查已完成,确认主流程没有问题。下一步继续检查音频输出链路,并运行相关测试验证结果。'); + expect(spoken).toBe('配置文件读取和依赖检查已完成,确认主流程没有问题。下一步继续检查音频输出链路,并运行相关测试验证结果。'); + expect(spoken).not.toMatch(/^(?:进展|Progress)[::]/i); expect(spoken).not.toBe(original); expect(spoken.match(/[。!?!?]/g)?.length).toBeLessThanOrEqual(2); expect(spoken.length).toBeLessThanOrEqual(90); }); + + it('removes a progress label already present in Agent text', () => { + expect(summarizeVoiceTaskProgress('进展:已经完成了配置检查。接下来会运行测试。')) + .toBe('配置检查已完成。下一步运行测试。'); + expect(summarizeVoiceTaskProgress('Progress: Finished the config check. Next, I will run tests.')) + .toBe('Finished the config check. Next, run tests.'); + }); +}); + +describe('voice task conclusion', () => { + it('uses the final completed public text instead of an earlier progress update', () => { + const session = sessionWithItems([ + { + id: 'progress', + type: 'text', + content: '正在检查语音任务链路。', + status: 'completed', + isStreaming: false, + }, + { + id: 'final', + type: 'text', + content: [ + '## 已完成', + '', + '用户提出的两个语音问题都已处理。', + '- 电话弹窗现在会显示收尾简报', + '- 收尾会保留最终回答的关键结论', + '- 进展前缀仍保持移除', + '- 聚焦测试已通过', + '- 第六条内部实现细节不应进入简报', + ].join('\n'), + status: 'completed', + isStreaming: false, + }, + ]); + + expect(extractVoiceTaskConclusion(session)) + .toBe('用户提出的两个语音问题都已处理。电话弹窗现在会显示收尾简报。收尾会保留最终回答的关键结论。进展前缀仍保持移除。聚焦测试已通过。'); + }); + + it('keeps enough of the final answer to respond to the user while remaining a brief', () => { + const conclusion = summarizeVoiceTaskConclusion( + 'Final result: Yes, the requested behavior is now supported. The closing text is visible in the call popup. The spoken brief retains the answer and key result. Focused tests pass. Restart the current call before testing. Internal implementation details should not be announced.', + ); + + expect(conclusion) + .toBe('Yes, the requested behavior is now supported. The closing text is visible in the call popup. The spoken brief retains the answer and key result. Focused tests pass. Restart the current call before testing.'); + expect(conclusion.match(/[.!?]/g)?.length).toBeLessThanOrEqual(5); + expect(conclusion.length).toBeLessThanOrEqual(320); + }); + + it('starts with the answer instead of source-reading preamble, headings, or parentheses', () => { + const conclusion = summarizeVoiceTaskConclusion([ + '我已经通读了项目的 README.md 和 README.zh-CN.md(项目自述是最权威的定位来源),下面是整理好的介绍。', + '', + '## BitFun 项目介绍', + '', + 'BitFun 是一个桌面 AI Agent,能把任务变成可打开的应用界面。', + '它支持编码、办公和桌面执行(包括浏览器、终端与文件系统)。', + 'BitFun 是一个桌面 AI Agent,能把任务变成可打开的应用界面。', + ].join('\n')); + + expect(conclusion).toBe( + 'BitFun 是一个桌面 AI Agent,能把任务变成可打开的应用界面。' + + '它支持编码、办公和桌面执行包括浏览器、终端与文件系统。', + ); + expect(conclusion).not.toMatch(/[()()]/); + expect(conclusion).not.toContain('README.md'); + expect(conclusion.match(/BitFun 是一个桌面 AI Agent/g)).toHaveLength(1); + }); + + it('returns an empty conclusion when no final public text exists', () => { + expect(extractVoiceTaskConclusion(sessionWithItems([]))).toBe(''); + }); }); diff --git a/src/web-ui/src/flow_chat/components/voice/voiceTaskBridge.ts b/src/web-ui/src/flow_chat/components/voice/voiceTaskBridge.ts index f5ecb8bde4..0ebf18ab9c 100644 --- a/src/web-ui/src/flow_chat/components/voice/voiceTaskBridge.ts +++ b/src/web-ui/src/flow_chat/components/voice/voiceTaskBridge.ts @@ -15,6 +15,19 @@ const HEARTBEAT_INTERVAL_MS = 20_000; const TEXT_PROGRESS_INTERVAL_MS = 8_000; const MAX_RESULT_CHARS = 6_000; const MAX_PROGRESS_CHARS = 90; +const MAX_CONCLUSION_CHARS = 320; +const MAX_CONCLUSION_SENTENCES = 5; + +type SentenceSegmenter = { + segment: (input: string) => Iterable<{ segment: string }>; +}; + +type SentenceSegmenterConstructor = new ( + locales?: string | string[], + options?: { granularity: 'sentence' }, +) => SentenceSegmenter; + +let cachedSentenceSegmenter: SentenceSegmenter | null | undefined; export type VoiceTaskProgressPhase = | 'starting' @@ -33,6 +46,7 @@ export interface VoiceTaskProgress { export interface VoiceTaskResult { sessionId: string; summary: string; + conclusion: string; } interface RunVoiceTaskOptions { @@ -72,32 +86,104 @@ function normalizeAssistantText(text: string): string { .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') .replace(/^\s{0,3}#{1,6}\s+/gm, '') .replace(/^\s{0,3}>\s?/gm, '') + .replace(/^\s*(?:[-+\u2022]|\d+[.)\u3001])\s+/gm, '') .replace(/[*_~]{1,3}/g, '') .replace(/\s+/g, ' ') .trim(); } +function normalizeConclusionText(text: string): string { + const lines = text + .replace(/```[\s\S]*?```/g, ' ') + .replace(/`([^`]*)`/g, '$1') + .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') + .split(/\r?\n+/) + .map(line => { + const heading = /^\s{0,3}#{1,6}\s+/.test(line); + const value = line + .replace(/^\s{0,3}#{1,6}\s+/, '') + .replace(/^\s{0,3}>\s?/, '') + .replace(/^\s*(?:[-+\u2022]|\d+[.)\u3001])\s+/, '') + .replace(/[*_~]{1,3}/g, '') + .replace(/[()\uFF08\uFF09]/g, '') + .trim(); + return { heading, value }; + }) + .filter(line => line.value && !/^[-=]{3,}$/.test(line.value)); + + const contentLines = lines.some(line => !line.heading) + ? lines.filter(line => !line.heading) + : lines; + + return contentLines.reduce((result, line) => { + if (!result) return line.value; + if (/[:\uFF1A]$/.test(result)) return `${result} ${line.value}`; + if (/[\u3002\uFF01\uFF1F.!?\uFF1B;]$/.test(result)) return `${result} ${line.value}`; + return /[\u3400-\u9fff]/.test(`${result}${line.value}`) + ? `${result}\u3002${line.value}` + : `${result}. ${line.value}`; + }, '').replace(/\s+/g, ' ').trim(); +} + function latestTurn(session: Session): DialogTurn | undefined { return session.dialogTurns[session.dialogTurns.length - 1]; } -function truncateProgressText(text: string): string { - if (text.length <= MAX_PROGRESS_CHARS) return text; - const candidate = text.slice(0, MAX_PROGRESS_CHARS); +function truncateBriefText(text: string, maxChars: number): string { + if (text.length <= maxChars) return text; + const candidate = text.slice(0, maxChars); const boundaries = ['。', '!', '?', '. ', '! ', '? ', ';', '; '] .map(mark => candidate.lastIndexOf(mark)) - .filter(index => index >= Math.floor(MAX_PROGRESS_CHARS * 0.45)); + .filter(index => index >= Math.floor(maxChars * 0.45)); const boundary = boundaries.length ? Math.max(...boundaries) + 1 : -1; - return `${candidate.slice(0, boundary > 0 ? boundary : MAX_PROGRESS_CHARS - 1).trim()}…`; + return `${candidate.slice(0, boundary > 0 ? boundary : maxChars - 1).trim()}…`; } function progressSentences(text: string): string[] { + if (cachedSentenceSegmenter === undefined) { + const Segmenter = (Intl as typeof Intl & { + Segmenter?: SentenceSegmenterConstructor; + }).Segmenter; + cachedSentenceSegmenter = Segmenter + ? new Segmenter(undefined, { granularity: 'sentence' }) + : null; + } + if (cachedSentenceSegmenter) { + return Array.from(cachedSentenceSegmenter.segment(text), part => part.segment.trim()) + .filter(Boolean); + } return text - .match(/[^\u3002.\uFF01\uFF1F!?\uFF1B;]+[\u3002.\uFF01\uFF1F!?\uFF1B;]?/g) + .match(/[^\u3002\uFF01\uFF1F!?\uFF1B;]+(?:[\u3002\uFF01\uFF1F!?\uFF1B;]|\.(?=\s|$))?/g) ?.map(sentence => sentence.trim()) .filter(Boolean) ?? []; } +function cleanConclusionSentence(sentence: string): string { + const value = sentence + .replace(/[()\uFF08\uFF09]/g, '') + .replace( + /^(?:\u4E0B\u9762|\u4EE5\u4E0B|\u8FD9\u91CC)(?:\u662F|\u4E3A|\u7ED9\u51FA|\u6574\u7406|\u63D0\u4F9B)(?:\u6574\u7406\u597D\u7684|\u7B80\u8981\u7684|\u7B80\u8981)?(?:\u4ECB\u7ECD|\u7ED3\u8BBA|\u603B\u7ED3|\u56DE\u7B54|\u7ED3\u679C)?\s*[:\uFF1A,\uFF0C]?\s*/, + '', + ) + .replace( + /^(?:below|here|the following)\s+(?:is|are)\s+(?:the\s+)?(?:brief\s+|concise\s+)?(?:introduction|conclusion|summary|answer|result)\s*[:,-]?\s*/i, + '', + ) + .trim(); + if (!value) return ''; + + const withoutPunctuation = value.replace(/[\u3002\uFF01\uFF1F.!?\uFF1B;:\uFF1A]+$/g, '').trim(); + if ( + /^(?:(?:\u6211|\u6211\u4EEC)(?:\u5DF2\u7ECF|\u5DF2)?|(?:\u5DF2\u7ECF|\u5DF2))(?:\u901A\u8BFB|\u9605\u8BFB|\u67E5\u9605|\u6D4F\u89C8|\u67E5\u770B|\u770B\u8FC7|\u7814\u7A76)/.test(withoutPunctuation) + || /^(?:I|we)(?:'ve| have)?\s+(?:read|reviewed|consulted|looked through|studied)\b/i.test(withoutPunctuation) + || /^(?:[\w.-]+\s*)?(?:\u9879\u76EE)?(?:\u4ECB\u7ECD|\u6982\u89C8|\u603B\u7ED3|\u7ED3\u8BBA|\u6700\u7EC8\u7ED3\u679C|\u56DE\u7B54)$/i.test(withoutPunctuation) + ) { + return ''; + } + return value; +} + function rewriteProgressSentence(sentence: string): string { const punctuation = sentence.match(/[\u3002.\uFF01\uFF1F!?\uFF1B;]$/)?.[0] ?? ''; let value = punctuation ? sentence.slice(0, -1).trim() : sentence.trim(); @@ -150,8 +236,40 @@ export function summarizeVoiceTaskProgress(text: string): string { selected.push(nextStep ?? candidates[1]); } const separator = /[\u3400-\u9fff]/.test(normalized) ? '' : ' '; - const prefix = /[\u3400-\u9fff]/.test(normalized) ? '\u8FDB\u5C55\uFF1A' : 'Progress: '; - return truncateProgressText(`${prefix}${selected.join(separator)}`); + return truncateBriefText(selected.join(separator), MAX_PROGRESS_CHARS); +} + +export function summarizeVoiceTaskConclusion(text: string): string { + const normalized = normalizeConclusionText(text) + .replace( + /^(?:(?:\u6700\u7EC8)?(?:\u7ED3\u8BBA|\u7ED3\u679C|\u603B\u7ED3)|(?:final\s+)?(?:conclusion|result|summary))\s*[:\uFF1A-]?\s*/i, + '', + ) + .replace(/^(?:\u4EFB\u52A1)?(?:\u5DF2\u5B8C\u6210|\u5B8C\u6210)[\u3002\uFF01!:\uFF1A-]*\s*/, '') + .replace(/^(?:(?:the\s+)?task\s+)?(?:is\s+|was\s+)?completed?[.!:\s-]*/i, '') + .replace(/^(?:done|finished)[.!:\s-]*/i, '') + .trim(); + if (!normalized) return ''; + + const candidates = progressSentences(normalized); + if (!candidates.length) return ''; + const separator = /[\u3400-\u9fff]/.test(normalized) ? '' : ' '; + const seen = new Set(); + const selected: string[] = []; + for (const candidate of candidates) { + const cleaned = cleanConclusionSentence(candidate); + if (!cleaned) continue; + const key = cleaned.toLocaleLowerCase().replace(/[\s\u3002\uFF01\uFF1F.!?\uFF1B;:\uFF1A]/g, ''); + if (!key || seen.has(key)) continue; + seen.add(key); + selected.push(cleaned); + if (selected.length >= MAX_CONCLUSION_SENTENCES) break; + } + if (!selected.length) return ''; + return truncateBriefText( + selected.join(separator), + MAX_CONCLUSION_CHARS, + ); } export function extractVoiceTaskProgressTexts(session: Session): Array<{ id: string; text: string }> { @@ -192,6 +310,22 @@ export function extractVoiceTaskSummary(session: Session): string { return `${summary.slice(0, MAX_RESULT_CHARS - 1)}…`; } +export function extractVoiceTaskConclusion(session: Session): string { + const turn = latestTurn(session); + if (!turn) return ''; + + let finalText = ''; + turn.modelRounds.forEach(round => { + round.items.forEach(item => { + if (item.type !== 'text') return; + const textItem = item as FlowTextItem; + if (textItem.isStreaming || textItem.status !== 'completed') return; + if (textItem.content.trim()) finalText = textItem.content; + }); + }); + return summarizeVoiceTaskConclusion(finalText); +} + async function waitForSettledSession(sessionId: string): Promise { const isSettled = () => { const state = stateMachineManager.getCurrentState(sessionId); @@ -347,7 +481,11 @@ export async function runBitFunVoiceTask( if (turn.status === 'cancelled') { throw new VoiceTaskCancelledError(sessionId); } - return { sessionId, summary: extractVoiceTaskSummary(session) }; + return { + sessionId, + summary: extractVoiceTaskSummary(session), + conclusion: extractVoiceTaskConclusion(session), + }; } finally { options.signal?.removeEventListener('abort', handleAbort); window.clearInterval(heartbeatId); diff --git a/src/web-ui/src/infrastructure/design-system/DesignSystemViteIntegration.test.ts b/src/web-ui/src/infrastructure/design-system/DesignSystemViteIntegration.test.ts index 0789f075f3..48f373b50d 100644 --- a/src/web-ui/src/infrastructure/design-system/DesignSystemViteIntegration.test.ts +++ b/src/web-ui/src/infrastructure/design-system/DesignSystemViteIntegration.test.ts @@ -1,7 +1,10 @@ import { readFileSync } from 'node:fs'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; -import { createDesignSystemSourceAliases } from '../../../vite.config'; +import { + createDesignSystemSourceAliases, + createDevServerResponseHeaders, +} from '../../../vite.config'; describe('design-system Vite integration', () => { it('resolves UI package entry points to source only while serving for HMR', () => { @@ -19,6 +22,12 @@ describe('design-system Vite integration', () => { expect(createDesignSystemSourceAliases('build')).toEqual([]); }); + it('prevents persistent module caching in desktop development webviews', () => { + expect(createDevServerResponseHeaders()).toEqual({ + 'Cache-Control': 'no-store', + }); + }); + it('registers the layer contract before product modules can load component CSS', () => { const mainSource = readFileSync( path.resolve(__dirname, '../../main.tsx'), diff --git a/src/web-ui/src/locales/en-US/settings/voice-input.json b/src/web-ui/src/locales/en-US/settings/voice-input.json index 8760068ac5..ce9b10ef68 100644 --- a/src/web-ui/src/locales/en-US/settings/voice-input.json +++ b/src/web-ui/src/locales/en-US/settings/voice-input.json @@ -89,6 +89,13 @@ "taskStopped": "The current BitFun task has stopped.", "taskAlreadyComplete": "The task already finished, so there is nothing to stop.", "noActiveTask": "There is no BitFun task running right now.", + "taskOutcome": { + "completed": "The task is complete. {{conclusion}}", + "completedWithoutConclusion": "The task is complete, but it returned no written conclusion.", + "failed": "The task is incomplete. Reason: {{reason}}", + "failedWithoutReason": "The task is incomplete, and no specific reason was returned.", + "cancelled": "The task is incomplete because it was stopped as requested." + }, "status": { "connecting": "Connecting to Volcengine…", "connected": "Connected, creating the conversation…", diff --git a/src/web-ui/src/locales/zh-CN/settings/voice-input.json b/src/web-ui/src/locales/zh-CN/settings/voice-input.json index da7717b71d..3582509246 100644 --- a/src/web-ui/src/locales/zh-CN/settings/voice-input.json +++ b/src/web-ui/src/locales/zh-CN/settings/voice-input.json @@ -89,6 +89,13 @@ "taskStopped": "当前 BitFun 任务已停止。", "taskAlreadyComplete": "任务已经完成,无需停止。", "noActiveTask": "当前没有正在执行的 BitFun 任务。", + "taskOutcome": { + "completed": "任务已完成。{{conclusion}}", + "completedWithoutConclusion": "任务已完成,但没有返回文字结论。", + "failed": "任务未完成。原因:{{reason}}", + "failedWithoutReason": "任务未完成,未返回具体原因。", + "cancelled": "任务未完成,已按要求停止。" + }, "status": { "connecting": "正在连接火山引擎…", "connected": "连接成功,正在建立会话…", diff --git a/src/web-ui/src/locales/zh-TW/settings/voice-input.json b/src/web-ui/src/locales/zh-TW/settings/voice-input.json index dd802ef491..646691235b 100644 --- a/src/web-ui/src/locales/zh-TW/settings/voice-input.json +++ b/src/web-ui/src/locales/zh-TW/settings/voice-input.json @@ -89,6 +89,13 @@ "taskStopped": "目前的 BitFun 任務已停止。", "taskAlreadyComplete": "任務已經完成,無需停止。", "noActiveTask": "目前沒有正在執行的 BitFun 任務。", + "taskOutcome": { + "completed": "任務已完成。{{conclusion}}", + "completedWithoutConclusion": "任務已完成,但沒有傳回文字結論。", + "failed": "任務未完成。原因:{{reason}}", + "failedWithoutReason": "任務未完成,未傳回具體原因。", + "cancelled": "任務未完成,已依要求停止。" + }, "status": { "connecting": "正在連線火山引擎…", "connected": "連線成功,正在建立會話…", diff --git a/src/web-ui/vite.config.ts b/src/web-ui/vite.config.ts index 3c8e1eb43e..6a7de00753 100644 --- a/src/web-ui/vite.config.ts +++ b/src/web-ui/vite.config.ts @@ -36,6 +36,15 @@ export function createDesignSystemSourceAliases(command: 'serve' | 'build') { ]; } +export function createDevServerResponseHeaders() { + return { + // Vite normally marks optimized dependencies as immutable for one year. + // WKWebView can retain those responses across desktop dev launches and + // then reject a lazy module graph after the optimizer has refreshed it. + 'Cache-Control': 'no-store', + }; +} + /** * Native fs events do not work reliably on UNC network shares (\\server\..., * including \\wsl$ / \\wsl.localhost) or on WSL drvfs mounts (/mnt/). @@ -109,6 +118,7 @@ export default defineConfig(({ mode, command }) => { // If Vite silently falls back to another port, the desktop webview stays blank. strictPort: true, host: host || "localhost", + headers: createDevServerResponseHeaders(), hmr: { protocol: "ws", host: host || "localhost",