feat: enhance workbench preferences and search functionality

- Updated AGENTS.md to clarify documentation references for AI and docx-related components.
- Modified workbench.md to refine system behavior descriptions for search mode and workspace binding.
- Enhanced test.md to include renderer testing commands and clarify testing processes.
- Added renderer test command to package.json for improved testing workflow.
- Adjusted AssistantTurnCard component styles for better UI consistency.
- Introduced new constants for workbench preferences in constants.ts.
- Expanded types.ts to include new preference types for workbench settings.
- Implemented utility functions in utils.ts for managing workbench preferences.
- Updated ChatPage.tsx to integrate preference management for mode, model, and workspace.
- Added tests for workbench preferences to ensure correct functionality and persistence.
This commit is contained in:
hy committed 2026-06-05 10:53:45 +08:00
1 parent a012a68839
commit 7ddf7c5fa3
11 files changed
+483 -81

No files matched your search

+9 -2
View File
@@ -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 对齐情况
+17 -6
View File
@@ -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 模式切换不切页面
+3 -1
View File
@@ -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`
+1
View File
@@ -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",
@@ -96,10 +96,10 @@ const AssistantTurnCard: React.FC<AssistantTurnCardProps> = ({
return (
<div className="w-full" data-testid={`workbench-assistant-turn-${messageId}`}>
<div className="space-y-3">
<div className="space-y-4">
{processItems.length > 0 ? (
<div
className={`rounded-[18px] border border-[#ECE7DF] bg-[#FCFBF8] px-4 py-3 transition-opacity duration-200 ${
className={`border-l border-[#E6DDD5] pl-4 transition-opacity duration-200 ${
turn.status === 'completed' && hasFinalAnswer ? 'opacity-75' : ''
}`}
data-testid={`${workbenchSelectors.assistantProcessPrefix}-${messageId}`}
@@ -129,7 +129,7 @@ const AssistantTurnCard: React.FC<AssistantTurnCardProps> = ({
{hasFinalAnswer ? (
<div
className="rounded-[20px] border border-[#EAE2DD] bg-white px-5 py-4 shadow-[0_1px_2px_rgba(0,0,0,0.04)]"
className="min-w-0 text-[15px] leading-7 text-[#1F1F1F]"
data-testid={`${workbenchSelectors.assistantFinalAnswerPrefix}-${messageId}`}
>
<MarkdownRenderer content={finalAnswerMarkdown} />
@@ -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<DocxApplyStatus, { label: string; className: string }> = {
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<DocxApplyStatus, { label: string; className: string }> =
{
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<WorkbenchFileOrigin, string> = {
search_hit: '搜索命中',
@@ -2,6 +2,13 @@ import type { AiRunEvent, DocxApplyStatus, SearchResult } from '../../../../shar
export type WorkbenchMode = 'ai' | 'search'
export type WorkbenchPreferenceStorage = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>
export type WorkbenchModelPreference = {
configId: string
modelName: string
}
export type WorkbenchContextUsage = {
contextWindowTokens: number
usedContextTokens: number
+119 -4
View File
@@ -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<string, IpcJsonValue>
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
}
+186 -60
View File
@@ -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<ChatSession[]>([])
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null)
const [currentMessages, setCurrentMessages] = useState<ChatMessage[]>([])
const [composerMode, setComposerMode] = useState<WorkbenchMode>('ai')
const [composerMode, setComposerMode] = useState<WorkbenchMode>(() =>
readWorkbenchModePreference(window.localStorage)
)
const [input, setInput] = useState('')
const [loading, setLoading] = useState(false)
const [aiRunId, setAiRunId] = useState<string | null>(null)
const [selectedReferences, setSelectedReferences] = useState<SelectedReference[]>([])
const [workspaces, setWorkspaces] = useState<WorkspaceSource[]>([])
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string | null>(null)
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string | null>(() =>
readWorkbenchWorkspacePreference(window.localStorage)
)
const [workspaceDocumentIds, setWorkspaceDocumentIds] = useState<string[]>([])
const [activeModelConfig, setActiveModelConfig] = useState<LLMConfig | null>(null)
const [remoteModels, setRemoteModels] = useState<LlmRemoteModel[]>([])
@@ -229,15 +258,12 @@ const ChatPage: React.FC = () => {
const lastSubmittedReferencesRef = useRef<SelectedReference[]>([])
const scrollRef = useRef<HTMLDivElement>(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<void> => {
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 (
<div key={messageItem.id} className="flex justify-end">
<div className="max-w-[78%]">
<div className="rounded-[20px] border border-[#E5E5E4] bg-white px-4 py-3.5 text-[14px] leading-7 text-[#1F1F1F] shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<div className="max-w-[66%]">
<div className="rounded-[18px] bg-[#EDE5DF] px-3.5 py-2.5 text-[14px] leading-6 text-[#2F2925]">
{messageItem.content}
</div>
{sources.length > 0 ? (
@@ -1579,7 +1692,7 @@ const ChatPage: React.FC = () => {
if (!turn) {
return (
<div key={messageItem.id} className="w-full">
<div className="rounded-[20px] border border-[#EAE2DD] bg-white px-5 py-4 shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<div className="min-w-0 text-[15px] leading-7 text-[#1F1F1F]">
<MarkdownRenderer content={messageItem.content} />
</div>
</div>
@@ -1820,30 +1933,29 @@ const ChatPage: React.FC = () => {
<div className="mx-auto max-w-[920px]">
<WorkbenchComposer
mode={composerMode}
onModeChange={setComposerMode}
onModeChange={handleModeChange}
activeModelConfig={activeModelConfig}
remoteModels={remoteModels}
selectedModelName={selectedModelName}
onModelNameChange={handleModelNameChange}
workspaces={workspaces}
selectedWorkspaceId={selectedWorkspaceId}
onWorkspaceChange={setSelectedWorkspaceId}
onWorkspaceChange={handleWorkspaceChange}
onDraftChange={handleComposerDraftChange}
onSubmit={() => 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) ? (
<div
className="mt-3 flex items-start gap-2 rounded-[14px] border border-[#FDECC8] bg-[#FFF9ED] px-3 py-3 text-sm text-[#8A6510]"
data-testid={
@@ -1858,7 +1970,8 @@ const ChatPage: React.FC = () => {
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-medium text-[#8A6510]">
审批 {approvalCurrentIndex + 1} / {pendingApprovalBatch.items.length}
审批 {approvalCurrentIndex + 1} /{' '}
{pendingApprovalBatch.items.length}
</div>
<div className="flex items-center gap-1">
<Button
@@ -1876,8 +1989,8 @@ const ChatPage: React.FC = () => {
size="small"
icon={<HiChevronRight className="h-4 w-4" />}
disabled={
approvalCurrentIndex >= pendingApprovalBatch.items.length - 1 ||
approvalSubmitting
approvalCurrentIndex >=
pendingApprovalBatch.items.length - 1 || approvalSubmitting
}
onClick={() =>
setApprovalCurrentIndex((current) =>
@@ -1895,17 +2008,22 @@ const ChatPage: React.FC = () => {
<div className="flex gap-2">
<Button
type="primary"
onClick={() => setApprovalAction(currentApprovalItem.itemId, 'approve')}
onClick={() =>
setApprovalAction(currentApprovalItem.itemId, 'approve')
}
loading={
approvalSubmitting &&
approvalDraftDecisions[currentApprovalItem.itemId]?.action === 'approve'
approvalDraftDecisions[currentApprovalItem.itemId]?.action ===
'approve'
}
data-testid={chatSelectors.approvalBatchApproveButton}
>
同意
</Button>
<Button
onClick={() => setApprovalAction(currentApprovalItem.itemId, 'reject')}
onClick={() =>
setApprovalAction(currentApprovalItem.itemId, 'reject')
}
disabled={approvalSubmitting}
data-testid={chatSelectors.approvalBatchDenyButton}
>
@@ -1915,9 +2033,14 @@ const ChatPage: React.FC = () => {
<input
className="w-full rounded-[10px] border border-[#E7CFA5] bg-white px-3 py-2 text-sm text-[#6F5311] outline-none placeholder:text-[#B68A2D]"
placeholder="告诉馆长该怎么做"
value={approvalDraftDecisions[currentApprovalItem.itemId]?.message || ''}
value={
approvalDraftDecisions[currentApprovalItem.itemId]?.message || ''
}
onChange={(event) =>
setApprovalGuideMessage(currentApprovalItem.itemId, event.target.value)
setApprovalGuideMessage(
currentApprovalItem.itemId,
event.target.value
)
}
disabled={approvalSubmitting}
data-testid={chatSelectors.approvalBatchGuideInput}
@@ -1966,13 +2089,16 @@ const ChatPage: React.FC = () => {
data-testid={workbenchSelectors.referenceAiWarning}
>
<HiOutlineExclamationCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>已选本地参考资料含未完全索引内容,AI 可能需要直接读取原文件,耗时更长。</span>
<span>
已选本地参考资料含未完全索引内容,AI 可能需要直接读取原文件,耗时更长。
</span>
</div>
) : null}
{composerMode === 'search' &&
selectedReferences.some(
(reference) => reference.kind === 'directory' && reference.kbAvailability === 'partial'
(reference) =>
reference.kind === 'directory' && reference.kbAvailability === 'partial'
) ? (
<div
className="mt-3 flex items-start gap-2 rounded-[14px] border border-[#FDECC8] bg-[#FFF9ED] px-3 py-2.5 text-sm text-[#8A6510]"
@@ -44,6 +44,32 @@ describe('AssistantTurnCard', () => {
expect(screen.queryByTestId('workbench-assistant-final-answer-assistant-1')).toBeNull()
})
it('renders search placeholder with the same streaming surface before final text', () => {
render(
<AssistantTurnCard
messageId="assistant-search-1"
turn={createTurn({
messageId: 'assistant-search-1',
processItems: [
{
id: 'assistant-search-1:thinking',
text: '俺正在搜索中',
status: 'running',
createdAt: 1,
sourceEventType: 'thinking'
}
]
})}
isStreaming
onOpenFile={vi.fn()}
onOpenApply={vi.fn()}
/>
)
expect(screen.getByText('俺正在搜索中')).toBeTruthy()
expect(screen.queryByTestId('workbench-assistant-final-answer-assistant-search-1')).toBeNull()
})
it('renders markdown final answer below process stream', () => {
render(
<AssistantTurnCard
@@ -80,5 +106,8 @@ describe('AssistantTurnCard', () => {
).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-')
})
})
@@ -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')
})
})