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:
1 parent
a012a68839
commit
7ddf7c5fa3
11 files changed
+483
-81
No files matched your search
@@ -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
@@ -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
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user