diff --git a/AGENTS.md b/AGENTS.md index 961cc02b..2f9a3468 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,29 +1,35 @@ # NCurator-Desktop(AI 项目规则) ## 1. 文档引用 + ### 1.1 AI相关 + - `doc/ai-design/architecture.md`:涉及 AI 整体架构、模块关系或主流程时阅读。 - `doc/ai-design/design-detail.md`:涉及 AI 设计边界、职责划分、核心数据、关键 contract 或关键流程时阅读。 - `doc/ai-design/human-skill-design.md`:涉及 human skill 体系、能力边界或工具使用范围时阅读。 - `doc/ai-design/prompt-design.md`:涉及 prompt 设计、AI-facing 文案或工具描述方式时阅读。 ### 1.2 docx相关 + - `doc/docx-design/architecture.md`:涉及 docx 整体架构、模块关系或主流程时阅读。 - `doc/docx-design/design-detail.md`:涉及 docx 设计边界、核心对象、关键流程、三视图关系或数据结构时阅读。 ### 1.3 项目整体相关 + - `doc/architecture.md`:涉及项目整体架构或技术栈时阅读。 - `doc/database.md`:涉及数据库结构、具名工作空间/网页索引状态存储或持久化关系时阅读。 - `doc/project-structure.md`:涉及工程结构或目录归位时阅读。 - `doc/ui-style.md`:涉及 UI 风格或通用交互规范时阅读。 ### 1.4 测试相关 + - `doc/test-plan/ai-test.md`:涉及 AI 测试范围、正式 case 设计或执行摘要维护时阅读。 - `doc/test-plan/settings-test.md`:涉及 Settings 测试范围或测试边界时阅读。 -- `doc/test.md`:涉及测试体系、runner、日志或正式测试流程时阅读。 +- `doc/test.md`:涉及测试体系、runner、renderer 辅助测试命令或正式测试流程时阅读。 ### 1.5 prd产品文档相关 -- `doc/prd/workbench.md`:涉及 Workbench 产品需求、交互或验收标准时阅读。 + +- `doc/prd/workbench.md`:涉及 Workbench 产品需求、消息流交互或 composer 偏好验收标准时阅读。 - `doc/prd/settings.md`:涉及 Settings 产品需求、具名工作空间资料/索引交互或验收标准时阅读。 ## 2. 必须遵守的规则 @@ -60,5 +66,6 @@ - 涉及工作区语义的测试时,统一使用 `tests/fixtures/workspace` 作为共享输入目录,不再把共享 workspace 放在 `tests/fixtures/ai/**` 下维护 ### 你的回复相关 + - 当对应测试用例文档明确要求引用 PRD 编号时,写正式测试或验收结论应按该文档要求执行 - 最终回复涉及功能实现时,应说明已验证和未验证项;只有在对应测试用例文档明确要求或 `doc/prd/**` 发生变更时,才需要说明 PRD 对齐情况 diff --git a/doc/prd/workbench.md b/doc/prd/workbench.md index 7d03d8f0..3b988976 100644 --- a/doc/prd/workbench.md +++ b/doc/prd/workbench.md @@ -178,10 +178,11 @@ #### WB-COMPOSER-004 搜索模式发送路径 -- 系统行为:搜索模式发送时,不启动 AI 运行;主区插入用户消息和结果摘要消息,右侧进入搜索结果态;输入区模式选项中该模式展示为“没头脑”,但内部模式语义仍是搜索模式。 +- 系统行为:搜索模式发送时,不启动 AI 运行;主区先插入用户消息和一条搜索中的 assistant 占位消息,搜索完成后再将该条消息收口为结果摘要,右侧进入搜索结果态;输入区模式选项中该模式展示为“没头脑”,但内部模式语义仍是搜索模式。 - 验收: - `WB-COMPOSER-004-AC1` 搜索模式发送后,不启动 AI 运行流程。 - - `WB-COMPOSER-004-AC2` 搜索成功后,右侧内容面板展示命中列表和命中详情。 + - `WB-COMPOSER-004-AC2` 搜索进行中时,assistant 占位消息展示“俺正在搜索中”并带有流动高光反馈。 + - `WB-COMPOSER-004-AC3` 搜索成功后,同一条 assistant 消息更新为正常结果摘要,右侧内容面板展示命中列表和命中详情。 #### WB-COMPOSER-005 发送前必须先绑定工作区 @@ -199,12 +200,13 @@ #### WB-COMPOSER-007 模型与工作区选择识别 -- 系统行为:输入区底部展示当前模式、模型和工作区选择;模型与工作区入口都有图标提示;模型下拉支持搜索,选择模型后会保存为当前模型配置的默认模型,下次进入 workbench 时优先使用该默认模型。 +- 系统行为:输入区底部展示当前模式、模型和工作区选择;模型与工作区入口都有图标提示;模型下拉支持搜索;模式、模型和工作区作为当前设备上的 workbench 偏好保存在浏览器侧缓存中,不进入数据库、不要求跨设备同步;模型选择仍会同步保存为当前模型配置的默认模型,供当前配置后续兜底恢复。 - 验收: - `WB-COMPOSER-007-AC1` 模型列表展开后可按模型名称搜索候选。 - - `WB-COMPOSER-007-AC2` 选择模型后,后续进入 workbench 或刷新模型配置时优先恢复该模型配置的默认模型。 - - `WB-COMPOSER-007-AC3` 模型选择和工作区选择入口都有可识别图标,而不是只展示纯文本。 - - `WB-COMPOSER-007-AC4` 输入区模式入口展示“大聪明 / 没头脑”,该展示文案不改变智能模式与搜索模式的内部行为。 + - `WB-COMPOSER-007-AC2` 用户切换模式、模型或工作区后,下次重新进入 workbench 时优先恢复本机浏览器侧缓存中的上次偏好。 + - `WB-COMPOSER-007-AC3` 当缓存中的工作区或模型已失效时,系统会自动回退到当前可用候选,而不是保留无效选择。 + - `WB-COMPOSER-007-AC4` 模型选择和工作区选择入口都有可识别图标,而不是只展示纯文本。 + - `WB-COMPOSER-007-AC5` 输入区模式入口展示“大聪明 / 没头脑”,该展示文案不改变智能模式与搜索模式的内部行为。 #### WB-COMPOSER-008 上下文用量提示 @@ -213,6 +215,15 @@ - `WB-COMPOSER-008-AC1` 智能模式下输入区可见上下文用量圆圈,hover 或辅助文本可读到已用 token 与最大 token。 - `WB-COMPOSER-008-AC2` human run 完成后,用量圆圈会基于最新会话消息与 session memory 刷新。 +#### WB-COMPOSER-009 消息流布局 + +- 系统行为:workbench 消息区移除用户与 assistant 头像;assistant 回复以左对齐内容流展示,过程流与最终 mouth 输出共用消息 rail,最终 markdown 输出不再使用独立背景卡片;用户消息保持右对齐,并使用更紧凑的消息泡样式。 +- 验收: + - `WB-COMPOSER-009-AC1` 用户与 assistant 消息都不再展示头像或头像占位。 + - `WB-COMPOSER-009-AC2` assistant 过程流和最终输出左边缘与底部 composer 内容 rail 对齐。 + - `WB-COMPOSER-009-AC3` assistant 最终 markdown 输出默认无独立背景卡片,保持在过程流下方。 + - `WB-COMPOSER-009-AC4` 用户消息以右对齐紧凑气泡展示,不再使用过宽过高的大块白底样式。 + ### 8.4 模式切换 #### WB-MODE-001 模式切换不切页面 diff --git a/doc/test.md b/doc/test.md index fc6c6d7f..1899dbff 100644 --- a/doc/test.md +++ b/doc/test.md @@ -175,7 +175,7 @@ runner 不负责: - 写失败原因分析。 - 写优化建议。 -当前常用命令按域收口如下。`workbench` 不再维护 formal test-plan,对应端到端体验验证改走 hand-test,不再提供 `test:workbench*` runner;Workbench renderer UI 的长期辅助契约可放入 `tests/renderer/**`,一次性视觉或交互排查放入 `tests/temp/**`,不得直接新增未登记的 formal workbench case。 +当前常用命令按域收口如下。`workbench` 不再维护 formal test-plan,对应端到端体验验证改走 hand-test,不再提供 `test:workbench*` runner;Workbench renderer UI 的长期辅助契约可放入 `tests/renderer/**`,通过 `pnpm test:renderer` 执行;一次性视觉或交互排查放入 `tests/temp/**`,不得直接新增未登记的 formal workbench case。 - AI - `pnpm test:ai` @@ -183,6 +183,8 @@ runner 不负责: - `pnpm test:ai:tool` - `pnpm test:ai:human:mock-llm` - `pnpm test:ai:human:live` +- Renderer + - `pnpm test:renderer` - Unit - `pnpm test:unit` - `pnpm test:unit:ai` diff --git a/package.json b/package.json index 5225f56a..53c84ddb 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "test:ai:tool": "node scripts/test-workflow.mjs --domain ai --suite tool --type backend", "test:ai:human:mock-llm": "node scripts/test-workflow.mjs --domain ai --suite human --group mock-llm --type backend", "test:ai:human:live": "node scripts/test-workflow.mjs --domain ai --suite human --group live --type backend", + "test:renderer": "vitest run --config vitest.renderer.config.mts", "test:unit": "node scripts/test-workflow.mjs --domain unit --type backend", "test:unit:ai": "node scripts/test-workflow.mjs --domain unit --suite ai --type backend", "test:settings": "node scripts/test-workflow.mjs --domain settings", diff --git a/src/renderer/src/components/workbench/AssistantTurnCard.tsx b/src/renderer/src/components/workbench/AssistantTurnCard.tsx index d726caff..88c8f356 100644 --- a/src/renderer/src/components/workbench/AssistantTurnCard.tsx +++ b/src/renderer/src/components/workbench/AssistantTurnCard.tsx @@ -96,10 +96,10 @@ const AssistantTurnCard: React.FC = ({ return (
-
+
{processItems.length > 0 ? (
= ({ {hasFinalAnswer ? (
diff --git a/src/renderer/src/components/workbench/constants.ts b/src/renderer/src/components/workbench/constants.ts index 2c3dd2d6..d2130259 100644 --- a/src/renderer/src/components/workbench/constants.ts +++ b/src/renderer/src/components/workbench/constants.ts @@ -3,12 +3,16 @@ import type { DocxApplyStatus } from '../../../../shared/types' export const WORKBENCH_SIDEBAR_STORAGE_KEY = 'workbench.sidebar.collapsed' export const WORKBENCH_CONTENT_PANEL_COLLAPSED_STORAGE_KEY = 'workbench.content-panel.collapsed' +export const WORKBENCH_PREFERENCE_MODE_STORAGE_KEY = 'workbench.preference.mode' +export const WORKBENCH_PREFERENCE_MODEL_STORAGE_KEY = 'workbench.preference.model' +export const WORKBENCH_PREFERENCE_WORKSPACE_STORAGE_KEY = 'workbench.preference.workspace-id' -export const DOCX_APPLY_STATUS_META: Record = { - applied: { label: '已修改', className: 'border-[#F4E5DF] bg-[#FBF5F2] text-[#D97757]' }, - reverted: { label: '已回退', className: 'border-[#DCEBD7] bg-[#F3FAF0] text-[#4F7A46]' }, - failed: { label: '失败', className: 'border-[#F7D5D9] bg-[#FFF2F4] text-[#B5455D]' } -} +export const DOCX_APPLY_STATUS_META: Record = + { + applied: { label: '已修改', className: 'border-[#F4E5DF] bg-[#FBF5F2] text-[#D97757]' }, + reverted: { label: '已回退', className: 'border-[#DCEBD7] bg-[#F3FAF0] text-[#4F7A46]' }, + failed: { label: '失败', className: 'border-[#F7D5D9] bg-[#FFF2F4] text-[#B5455D]' } + } export const FILE_ORIGIN_LABELS: Record = { search_hit: '搜索命中', diff --git a/src/renderer/src/components/workbench/types.ts b/src/renderer/src/components/workbench/types.ts index afce7943..228dce49 100644 --- a/src/renderer/src/components/workbench/types.ts +++ b/src/renderer/src/components/workbench/types.ts @@ -2,6 +2,13 @@ import type { AiRunEvent, DocxApplyStatus, SearchResult } from '../../../../shar export type WorkbenchMode = 'ai' | 'search' +export type WorkbenchPreferenceStorage = Pick + +export type WorkbenchModelPreference = { + configId: string + modelName: string +} + export type WorkbenchContextUsage = { contextWindowTokens: number usedContextTokens: number diff --git a/src/renderer/src/components/workbench/utils.ts b/src/renderer/src/components/workbench/utils.ts index bb07e92f..9edcb4eb 100644 --- a/src/renderer/src/components/workbench/utils.ts +++ b/src/renderer/src/components/workbench/utils.ts @@ -3,9 +3,22 @@ import type { IpcJsonArray, IpcJsonObject, IpcJsonValue, - SearchResult + LlmRemoteModel, + SearchResult, + WorkspaceSource } from '../../../../shared/types' -import type { AssistantProcessItem, WorkbenchFileRef } from './types' +import { + WORKBENCH_PREFERENCE_MODE_STORAGE_KEY, + WORKBENCH_PREFERENCE_MODEL_STORAGE_KEY, + WORKBENCH_PREFERENCE_WORKSPACE_STORAGE_KEY +} from './constants' +import type { + AssistantProcessItem, + WorkbenchFileRef, + WorkbenchMode, + WorkbenchModelPreference, + WorkbenchPreferenceStorage +} from './types' const getPageNumberFromMetadata = (metadata: SearchResult['metadata']): number | undefined => { if (!metadata) return undefined @@ -24,6 +37,9 @@ const isIpcJsonObject = (value: IpcJsonValue): value is IpcJsonObject => { return typeof value === 'object' && value !== null && !Array.isArray(value) } +const isWorkbenchMode = (value: string | null): value is WorkbenchMode => + value === 'ai' || value === 'search' + const readStringField = (value: IpcJsonObject, key: string): string | undefined => { const record = value as Record const candidate = record[key] @@ -111,14 +127,20 @@ export const buildAssistantProcessText = (event: AiRunEvent): string | null => { return null } -export const createAssistantThinkingItem = (messageId: string): AssistantProcessItem => ({ +export const createAssistantPlaceholderItem = ( + messageId: string, + text = '让俺想想' +): AssistantProcessItem => ({ id: `${messageId}:thinking`, - text: '让俺想想', + text, status: 'running', createdAt: Date.now(), sourceEventType: 'thinking' }) +export const createAssistantThinkingItem = (messageId: string): AssistantProcessItem => + createAssistantPlaceholderItem(messageId) + export const createAssistantProcessItem = ( event: AiRunEvent, options?: { id?: string; text?: string } @@ -249,3 +271,96 @@ export const formatSessionDate = (timestamp: number): string => { const date = new Date(timestamp) return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' }) } + +export const readWorkbenchModePreference = (storage: WorkbenchPreferenceStorage): WorkbenchMode => { + const stored = storage.getItem(WORKBENCH_PREFERENCE_MODE_STORAGE_KEY) + return isWorkbenchMode(stored) ? stored : 'ai' +} + +export const writeWorkbenchModePreference = ( + storage: WorkbenchPreferenceStorage, + mode: WorkbenchMode +): void => { + storage.setItem(WORKBENCH_PREFERENCE_MODE_STORAGE_KEY, mode) +} + +export const readWorkbenchWorkspacePreference = ( + storage: WorkbenchPreferenceStorage +): string | null => { + const stored = storage.getItem(WORKBENCH_PREFERENCE_WORKSPACE_STORAGE_KEY) + return stored && stored.trim() ? stored : null +} + +export const writeWorkbenchWorkspacePreference = ( + storage: WorkbenchPreferenceStorage, + workspaceId: string | null +): void => { + if (!workspaceId) { + storage.removeItem(WORKBENCH_PREFERENCE_WORKSPACE_STORAGE_KEY) + return + } + storage.setItem(WORKBENCH_PREFERENCE_WORKSPACE_STORAGE_KEY, workspaceId) +} + +export const resolveWorkbenchWorkspaceSelection = ( + workspaces: WorkspaceSource[], + preferredWorkspaceId: string | null +): string | null => { + if ( + preferredWorkspaceId && + workspaces.some((workspace) => workspace.id === preferredWorkspaceId) + ) { + return preferredWorkspaceId + } + return workspaces[0]?.id || null +} + +export const readWorkbenchModelPreference = ( + storage: WorkbenchPreferenceStorage +): WorkbenchModelPreference | null => { + const raw = storage.getItem(WORKBENCH_PREFERENCE_MODEL_STORAGE_KEY) + if (!raw) return null + try { + const parsed = JSON.parse(raw) as { configId?: string; modelName?: string } + if (!parsed.configId || !parsed.modelName) return null + return { + configId: parsed.configId, + modelName: parsed.modelName + } + } catch { + return null + } +} + +export const writeWorkbenchModelPreference = ( + storage: WorkbenchPreferenceStorage, + preference: WorkbenchModelPreference | null +): void => { + if (!preference) { + storage.removeItem(WORKBENCH_PREFERENCE_MODEL_STORAGE_KEY) + return + } + storage.setItem(WORKBENCH_PREFERENCE_MODEL_STORAGE_KEY, JSON.stringify(preference)) +} + +export const resolveWorkbenchModelSelection = (input: { + configId: string + models: LlmRemoteModel[] + defaultModelName?: string | null + preference: WorkbenchModelPreference | null +}): string | null => { + const { configId, defaultModelName, models, preference } = input + const preferredModelName = + preference?.configId === configId ? preference.modelName : defaultModelName || null + if (preferredModelName && models.some((model) => model.id === preferredModelName)) { + return preferredModelName + } + if ( + defaultModelName && + preferredModelName !== defaultModelName && + models.some((model) => model.id === defaultModelName) + ) { + return defaultModelName + } + return models[0]?.id || null +} diff --git a/src/renderer/src/pages/ChatPage.tsx b/src/renderer/src/pages/ChatPage.tsx index 06c66493..171e6e4f 100644 --- a/src/renderer/src/pages/ChatPage.tsx +++ b/src/renderer/src/pages/ChatPage.tsx @@ -32,10 +32,11 @@ import AssistantTurnCard from '../components/workbench/AssistantTurnCard' import WorkbenchComposer from '../components/workbench/WorkbenchComposer' import WorkbenchContentPanel from '../components/workbench/WorkbenchContentPanel' import { + DOCX_APPLY_STATUS_META, + FILE_ORIGIN_LABELS, WORKBENCH_CONTENT_PANEL_COLLAPSED_STORAGE_KEY, WORKBENCH_SIDEBAR_STORAGE_KEY } from '../components/workbench/constants' -import { DOCX_APPLY_STATUS_META, FILE_ORIGIN_LABELS } from '../components/workbench/constants' import type { AssistantTurnViewModel, WorkbenchContextUsage, @@ -46,6 +47,7 @@ import type { WorkbenchMode } from '../components/workbench/types' import { + createAssistantPlaceholderItem, createAssistantProcessItem, createAssistantThinkingItem, dedupeFileRefs, @@ -53,7 +55,15 @@ import { extractSearchResultsFromToolOutput, formatSessionDate, getSearchResultFileRef, - mergeAssistantProcessItem + mergeAssistantProcessItem, + readWorkbenchModePreference, + readWorkbenchModelPreference, + readWorkbenchWorkspacePreference, + resolveWorkbenchModelSelection, + resolveWorkbenchWorkspaceSelection, + writeWorkbenchModePreference, + writeWorkbenchModelPreference, + writeWorkbenchWorkspacePreference } from '../components/workbench/utils' import { groupApplyTargetsByRunId, @@ -92,6 +102,16 @@ const createAssistantTurn = (messageId: string): AssistantTurnViewModel => ({ blockers: [] }) +const createSearchAssistantTurn = (messageId: string): AssistantTurnViewModel => ({ + messageId, + status: 'running', + processItems: [createAssistantPlaceholderItem(messageId, '俺正在搜索中')], + finalAnswerMarkdown: '', + artifacts: [], + applyTargets: [], + blockers: [] +}) + const artifactToApplyTarget = ( artifact: AiArtifactRef ): AssistantTurnViewModel['applyTargets'][number] | null => { @@ -124,7 +144,9 @@ const buildAssistantTurnFromMessage = (message: ChatMessage): AssistantTurnViewM })), applyTargets: artifacts .map(artifactToApplyTarget) - .filter((target): target is AssistantTurnViewModel['applyTargets'][number] => Boolean(target)), + .filter((target): target is AssistantTurnViewModel['applyTargets'][number] => + Boolean(target) + ), blockers: [] } } @@ -136,7 +158,10 @@ const appendArtifactForMessage = ( ): void => { const currentArtifacts = store[messageId] || [] const exists = currentArtifacts.some( - (item) => item.path === artifact.path && item.applyId === artifact.applyId && item.summary === artifact.summary + (item) => + item.path === artifact.path && + item.applyId === artifact.applyId && + item.summary === artifact.summary ) if (!exists) { store[messageId] = [...currentArtifacts, artifact] @@ -179,13 +204,17 @@ const ChatPage: React.FC = () => { const [sessions, setSessions] = useState([]) const [currentSessionId, setCurrentSessionId] = useState(null) const [currentMessages, setCurrentMessages] = useState([]) - const [composerMode, setComposerMode] = useState('ai') + const [composerMode, setComposerMode] = useState(() => + readWorkbenchModePreference(window.localStorage) + ) const [input, setInput] = useState('') const [loading, setLoading] = useState(false) const [aiRunId, setAiRunId] = useState(null) const [selectedReferences, setSelectedReferences] = useState([]) const [workspaces, setWorkspaces] = useState([]) - const [selectedWorkspaceId, setSelectedWorkspaceId] = useState(null) + const [selectedWorkspaceId, setSelectedWorkspaceId] = useState(() => + readWorkbenchWorkspacePreference(window.localStorage) + ) const [workspaceDocumentIds, setWorkspaceDocumentIds] = useState([]) const [activeModelConfig, setActiveModelConfig] = useState(null) const [remoteModels, setRemoteModels] = useState([]) @@ -229,15 +258,12 @@ const ChatPage: React.FC = () => { const lastSubmittedReferencesRef = useRef([]) const scrollRef = useRef(null) - const kbScopeDocumentIds = useMemo( - () => { - const explicitIds = collectReferenceDocumentIds(selectedReferences) - if (explicitIds.length > 0) return explicitIds - if (selectedWorkspaceId) return workspaceDocumentIds - return [] - }, - [selectedReferences, selectedWorkspaceId, workspaceDocumentIds] - ) + const kbScopeDocumentIds = useMemo(() => { + const explicitIds = collectReferenceDocumentIds(selectedReferences) + if (explicitIds.length > 0) return explicitIds + if (selectedWorkspaceId) return workspaceDocumentIds + return [] + }, [selectedReferences, selectedWorkspaceId, workspaceDocumentIds]) const selectedWorkspace = useMemo( () => workspaces.find((workspace) => workspace.id === selectedWorkspaceId) || null, @@ -445,10 +471,12 @@ const ChatPage: React.FC = () => { try { const response = await window.api.workspaceList() setWorkspaces(response.items) - setSelectedWorkspaceId((current) => { - if (current && response.items.some((workspace) => workspace.id === current)) return current - return response.items[0]?.id || null - }) + const nextWorkspaceId = resolveWorkbenchWorkspaceSelection( + response.items, + readWorkbenchWorkspacePreference(window.localStorage) + ) + setSelectedWorkspaceId(nextWorkspaceId) + writeWorkbenchWorkspacePreference(window.localStorage, nextWorkspaceId) } catch (error) { console.error('Failed to load workspaces', error) } @@ -467,16 +495,22 @@ const ChatPage: React.FC = () => { const response = await window.api.llmConfigModelList(activeConfig.id) const items = response.success ? response.items : [] setRemoteModels(items) - setSelectedModelName((current) => { - if (current && items.some((model) => model.id === current)) return current - if ( - activeConfig.default_model && - items.some((model) => model.id === activeConfig.default_model) - ) { - return activeConfig.default_model - } - return items[0]?.id || null + const nextModelName = resolveWorkbenchModelSelection({ + configId: activeConfig.id, + models: items, + defaultModelName: activeConfig.default_model, + preference: readWorkbenchModelPreference(window.localStorage) }) + setSelectedModelName(nextModelName) + writeWorkbenchModelPreference( + window.localStorage, + nextModelName + ? { + configId: activeConfig.id, + modelName: nextModelName + } + : null + ) } catch (error) { console.error('Failed to load llm configs', error) setActiveModelConfig(null) @@ -488,6 +522,15 @@ const ChatPage: React.FC = () => { const handleModelNameChange = useCallback( (modelName: string): void => { setSelectedModelName(modelName) + writeWorkbenchModelPreference( + window.localStorage, + activeModelConfig + ? { + configId: activeModelConfig.id, + modelName + } + : null + ) if (!activeModelConfig || activeModelConfig.default_model === modelName) return const nextConfig: LLMConfig = { @@ -506,6 +549,16 @@ const ChatPage: React.FC = () => { [activeModelConfig, message] ) + const handleModeChange = useCallback((mode: WorkbenchMode): void => { + setComposerMode(mode) + writeWorkbenchModePreference(window.localStorage, mode) + }, []) + + const handleWorkspaceChange = useCallback((workspaceId: string | null): void => { + setSelectedWorkspaceId(workspaceId) + writeWorkbenchWorkspacePreference(window.localStorage, workspaceId) + }, []) + const createNewSession = async (): Promise => { try { const timestamp = Date.now() @@ -600,10 +653,9 @@ const ChatPage: React.FC = () => { return nextTurns }) - const preferredRecord = - options?.preferredApplyId - ? allRecords.find((record) => record.id === options.preferredApplyId) || null - : null + const preferredRecord = options?.preferredApplyId + ? allRecords.find((record) => record.id === options.preferredApplyId) || null + : null const workspaceRecords = preferredRecord ? allRecords.filter((record) => record.run_id === preferredRecord.run_id) : activeRecords @@ -1288,8 +1340,28 @@ const ChatPage: React.FC = () => { setCurrentMessages((previous) => [...previous, userMessage]) void window.api.chatMessageSave(userMessage).catch(console.error) + let assistantMessageId: string | null = null + let assistantMessage: ChatMessage | null = null + try { await updateSessionTitleIfNeeded(userQuery) + const nextAssistantMessageId = crypto.randomUUID() + assistantMessageId = nextAssistantMessageId + setAssistantTurns((previous) => ({ + ...previous, + [nextAssistantMessageId]: createSearchAssistantTurn(nextAssistantMessageId) + })) + + const nextAssistantMessage: ChatMessage = { + id: nextAssistantMessageId, + session_id: currentSessionId, + role: 'assistant', + content: '', + timestamp: Date.now() + } + assistantMessage = nextAssistantMessage + setCurrentMessages((previous) => [...previous, nextAssistantMessage]) + const response = await window.api.search( userQuery, 'all', @@ -1300,15 +1372,30 @@ const ChatPage: React.FC = () => { results.length > 0 ? `已在当前资料范围中找到 ${results.length} 条结果,右侧可以直接查看命中详情。` : '未在当前资料范围中找到匹配结果。' - const assistantMessage: ChatMessage = { - id: crypto.randomUUID(), - session_id: currentSessionId, - role: 'assistant', - content: assistantContent, - timestamp: Date.now() - } - setCurrentMessages((previous) => [...previous, assistantMessage]) - void window.api.chatMessageSave(assistantMessage).catch(console.error) + const completedAssistantMessageId = assistantMessageId + const completedAssistantMessage = assistantMessage + completedAssistantMessage.content = assistantContent + setCurrentMessages((previous) => + previous.map((item) => + item.id === completedAssistantMessageId ? completedAssistantMessage : item + ) + ) + void window.api.chatMessageSave(completedAssistantMessage).catch(console.error) + setAssistantTurns((previous) => ({ + ...previous, + [completedAssistantMessageId]: { + ...(previous[completedAssistantMessageId] || + createSearchAssistantTurn(completedAssistantMessageId)), + status: 'completed', + processItems: [ + { + ...createAssistantPlaceholderItem(completedAssistantMessageId, '俺正在搜索中'), + status: 'completed' + } + ], + finalAnswerMarkdown: assistantContent + } + })) setModalPreviewFile(null) showContentPanel({ @@ -1320,6 +1407,32 @@ const ChatPage: React.FC = () => { }) } catch (error) { console.error(error) + if (assistantMessageId && assistantMessage) { + const failedAssistantMessageId = assistantMessageId + const failedAssistantMessage = assistantMessage + failedAssistantMessage.content = '搜索失败了,请稍后再试。' + setCurrentMessages((previous) => + previous.map((item) => + item.id === failedAssistantMessageId ? failedAssistantMessage : item + ) + ) + setAssistantTurns((previous) => ({ + ...previous, + [failedAssistantMessageId]: { + ...(previous[failedAssistantMessageId] || + createSearchAssistantTurn(failedAssistantMessageId)), + status: 'failed', + processItems: [ + { + ...createAssistantPlaceholderItem(failedAssistantMessageId, '俺正在搜索中'), + status: 'failed' + } + ], + finalAnswerMarkdown: '搜索失败了,请稍后再试。', + error: '搜索失败了,请稍后再试。' + } + })) + } message.error('搜索失败') } finally { setLoading(false) @@ -1547,8 +1660,8 @@ const ChatPage: React.FC = () => { return (
-
-
+
+
{messageItem.content}
{sources.length > 0 ? ( @@ -1579,7 +1692,7 @@ const ChatPage: React.FC = () => { if (!turn) { return (
-
+
@@ -1820,30 +1933,29 @@ const ChatPage: React.FC = () => {
void handleSend()} onCancel={() => void handleCancel()} loading={loading} canSubmit={Boolean( input.trim() && - selectedWorkspace && - (composerMode === 'search' || selectedModelName) + selectedWorkspace && + (composerMode === 'search' || selectedModelName) )} showCancel={loading && composerMode === 'ai' && Boolean(aiRunId)} resetKey={composerResetKey} contextUsage={contextUsage} /> - {composerMode === 'ai' && - (pendingApprovalBatch || pendingWorkspaceRequest) ? ( + {composerMode === 'ai' && (pendingApprovalBatch || pendingWorkspaceRequest) ? (
{
- 审批 {approvalCurrentIndex + 1} / {pendingApprovalBatch.items.length} + 审批 {approvalCurrentIndex + 1} /{' '} + {pendingApprovalBatch.items.length}
) : null} {composerMode === 'search' && selectedReferences.some( - (reference) => reference.kind === 'directory' && reference.kbAvailability === 'partial' + (reference) => + reference.kind === 'directory' && reference.kbAvailability === 'partial' ) ? (
{ expect(screen.queryByTestId('workbench-assistant-final-answer-assistant-1')).toBeNull() }) + it('renders search placeholder with the same streaming surface before final text', () => { + render( + + ) + + expect(screen.getByText('俺正在搜索中')).toBeTruthy() + expect(screen.queryByTestId('workbench-assistant-final-answer-assistant-search-1')).toBeNull() + }) + it('renders markdown final answer below process stream', () => { render( { ).toBeTruthy() expect(screen.getByTestId('mock-markdown-renderer').textContent).toContain('## 最终结果') expect(screen.getByTestId('mock-markdown-renderer').textContent).toContain('- 已完成') + expect(finalAnswer.className).not.toContain('bg-white') + expect(finalAnswer.className).not.toContain('rounded') + expect(process.className).not.toContain('bg-') }) }) diff --git a/tests/renderer/workbench/workbench-preferences.test.ts b/tests/renderer/workbench/workbench-preferences.test.ts new file mode 100644 index 00000000..b823af8f --- /dev/null +++ b/tests/renderer/workbench/workbench-preferences.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import type { LlmRemoteModel, WorkspaceSource } from '../../../src/shared/types' +import { + readWorkbenchModePreference, + readWorkbenchModelPreference, + readWorkbenchWorkspacePreference, + resolveWorkbenchModelSelection, + resolveWorkbenchWorkspaceSelection, + writeWorkbenchModePreference, + writeWorkbenchModelPreference, + writeWorkbenchWorkspacePreference +} from '../../../src/renderer/src/components/workbench/utils' + +const workspaceFixtures: WorkspaceSource[] = [ + { + id: 'workspace-1', + name: '资料库 A', + created_at: 1, + updated_at: 1 + }, + { + id: 'workspace-2', + name: '资料库 B', + created_at: 2, + updated_at: 2 + } +] + +const modelFixtures: LlmRemoteModel[] = [ + { id: 'model-a', label: 'Model A' }, + { id: 'model-b', label: 'Model B' } +] + +describe('workbench preferences', () => { + beforeEach(() => { + window.localStorage.clear() + }) + + it('persists mode as ai/search and falls back to ai for invalid values', () => { + expect(readWorkbenchModePreference(window.localStorage)).toBe('ai') + + writeWorkbenchModePreference(window.localStorage, 'search') + expect(readWorkbenchModePreference(window.localStorage)).toBe('search') + + window.localStorage.setItem('workbench.preference.mode', 'invalid') + expect(readWorkbenchModePreference(window.localStorage)).toBe('ai') + }) + + it('restores workspace preference and falls back to the first available workspace', () => { + writeWorkbenchWorkspacePreference(window.localStorage, 'workspace-2') + expect(readWorkbenchWorkspacePreference(window.localStorage)).toBe('workspace-2') + expect(resolveWorkbenchWorkspaceSelection(workspaceFixtures, 'workspace-2')).toBe('workspace-2') + + expect(resolveWorkbenchWorkspaceSelection(workspaceFixtures, 'workspace-missing')).toBe( + 'workspace-1' + ) + + writeWorkbenchWorkspacePreference(window.localStorage, null) + expect(readWorkbenchWorkspacePreference(window.localStorage)).toBeNull() + }) + + it('restores workbench model preference before default_model and falls back when invalid', () => { + writeWorkbenchModelPreference(window.localStorage, { + configId: 'config-1', + modelName: 'model-b' + }) + + expect(readWorkbenchModelPreference(window.localStorage)).toEqual({ + configId: 'config-1', + modelName: 'model-b' + }) + + expect( + resolveWorkbenchModelSelection({ + configId: 'config-1', + models: modelFixtures, + defaultModelName: 'model-a', + preference: readWorkbenchModelPreference(window.localStorage) + }) + ).toBe('model-b') + + expect( + resolveWorkbenchModelSelection({ + configId: 'config-2', + models: modelFixtures, + defaultModelName: 'model-a', + preference: readWorkbenchModelPreference(window.localStorage) + }) + ).toBe('model-a') + + expect( + resolveWorkbenchModelSelection({ + configId: 'config-1', + models: [{ id: 'model-a', label: 'Model A' }], + defaultModelName: 'model-a', + preference: readWorkbenchModelPreference(window.localStorage) + }) + ).toBe('model-a') + }) +})