From 3a7e11a7cfb10bb36586d096b449a1b6146b6c27 Mon Sep 17 00:00:00 2001 From: zhangmo8 Date: Mon, 20 Jul 2026 12:23:38 +0800 Subject: [PATCH 01/12] refactor(renderer): split scopes and fix races Restructure renderer scope boundaries per docs/architecture/renderer-scope-optimization: - Move ChatTabView into apps/chat-main; drop unused views/SettingsTabView - Extract foundation/appearance/documentAppearance shared by all renderer entries - Session store: paginated list with cursor, tombstoned deletes, targeted activate/deactivate IPC; refetch when a pending first page is invalidated - Chat search: canonical debounced query, incremental per-row highlighting with a MutationObserver; drain pending observer work on same-query re-apply and count only rendered user-message content - Message window fast path returns a fresh array so totalHeight and the window range react to tail growth; batch measures no longer force per-row full layout rebuilds - Scroll state machine rejects a late session-restore scroll after the user explicitly navigated (search/spotlight) - Split row-level isStreamingMessage (activity grouping) from thread-level isInGeneratingThread (fork gating, variant auto-switch) - Always clear plan snapshots for deleted messages even after switching views --- .../renderer-scope-optimization/plan.md | 80 +++ .../renderer-scope-optimization/spec.md | 62 ++ .../renderer-scope-optimization/tasks.md | 16 + src/renderer/floating/main.ts | 45 +- src/renderer/settings/App.vue | 42 +- .../src/apps/chat-main/ChatMainApp.vue | 102 ++- .../{views => apps/chat-main}/ChatTabView.vue | 0 src/renderer/src/components/WindowSideBar.vue | 98 ++- .../src/components/chat/MessageList.vue | 25 +- .../src/components/chat/MessageListRow.vue | 3 + .../message/MessageItemAssistant.vue | 5 +- .../src/composables/chat/chatScrollState.ts | 15 +- .../composables/message/useMessageWindow.ts | 216 ++++++- .../src/features/chat-page/ChatPage.vue | 84 ++- .../chat-page/composables/useChatSearch.ts | 92 ++- .../composables/useDisplayMessages.ts | 17 + .../composables/useMessageActions.ts | 23 +- .../composables/useMessageVirtualization.ts | 43 +- .../composables/useToolInteraction.ts | 9 +- .../appearance/documentAppearance.ts | 95 +++ src/renderer/src/lib/chatSearch.ts | 307 ++++++++- src/renderer/src/pages/NewThreadPage.vue | 57 +- src/renderer/src/router/index.ts | 2 +- src/renderer/src/stores/language.ts | 64 +- src/renderer/src/stores/theme.ts | 94 ++- src/renderer/src/stores/ui/draft.ts | 1 - src/renderer/src/stores/ui/message.ts | 14 + src/renderer/src/stores/ui/project.ts | 90 ++- src/renderer/src/stores/ui/session.ts | 191 ++++-- src/renderer/src/stores/ui/sessionIpc.ts | 72 ++- src/renderer/src/views/SettingsTabView.vue | 96 --- test/renderer/components/App.startup.test.ts | 49 +- test/renderer/components/ChatPage.test.ts | 80 ++- test/renderer/components/ChatTabView.test.ts | 2 +- test/renderer/components/MessageList.test.ts | 74 ++- test/renderer/components/SettingsApp.test.ts | 203 +++++- .../renderer/components/WindowSideBar.test.ts | 188 +++++- .../message/MessageItemAssistant.test.ts | 18 +- .../composables/chat/chatScrollState.test.ts | 15 + .../useMessageVirtualization.test.ts | 185 ++++++ .../composables/useMessageWindow.test.ts | 168 ++++- .../composables/useChatSearch.test.ts | 137 ++++ .../composables/useMessageActions.test.ts | 55 +- .../composables/useToolInteraction.test.ts | 15 +- .../appearance/documentAppearance.test.ts | 77 +++ test/renderer/lib/chatSearch.test.ts | 156 +++++ test/renderer/pages/NewThreadPage.test.ts | 87 ++- test/renderer/stores/languageStore.test.ts | 71 ++ test/renderer/stores/messageStore.test.ts | 55 ++ test/renderer/stores/projectStore.test.ts | 147 +++++ test/renderer/stores/sessionStore.test.ts | 605 +++++++++++++++++- test/renderer/stores/themeStore.test.ts | 95 +++ 52 files changed, 4026 insertions(+), 516 deletions(-) create mode 100644 docs/architecture/renderer-scope-optimization/plan.md create mode 100644 docs/architecture/renderer-scope-optimization/spec.md create mode 100644 docs/architecture/renderer-scope-optimization/tasks.md rename src/renderer/src/{views => apps/chat-main}/ChatTabView.vue (100%) create mode 100644 src/renderer/src/foundation/appearance/documentAppearance.ts delete mode 100644 src/renderer/src/views/SettingsTabView.vue create mode 100644 test/renderer/composables/useMessageVirtualization.test.ts create mode 100644 test/renderer/features/chat-page/composables/useChatSearch.test.ts create mode 100644 test/renderer/foundation/appearance/documentAppearance.test.ts create mode 100644 test/renderer/stores/languageStore.test.ts create mode 100644 test/renderer/stores/themeStore.test.ts diff --git a/docs/architecture/renderer-scope-optimization/plan.md b/docs/architecture/renderer-scope-optimization/plan.md new file mode 100644 index 0000000000..40cc9bba89 --- /dev/null +++ b/docs/architecture/renderer-scope-optimization/plan.md @@ -0,0 +1,80 @@ +# 实施计划 + +## Slice 1:会话与 deeplink 的并发边界 + +### 新会话提交 + +`NewThreadPage` 将持有单个 `isSubmitting` ref,并从 `onSubmit`、`onCommandSubmit` 进入统一 guarded submit 路径。锁从文件准备前开始,到 `submitText` 成功且页面状态清理完成后释放;异常仅记录并释放锁,不清除用户草稿。ACP 既有会话的 `sendMessage` 与普通 `createSession` 使用同一互斥语义。 + +发送 UI 同时接收锁状态,避免按钮表现为可点击但 handler 静默返回。 + +### Session 更新 + +- `refreshSessionsByIds` 为每个 refresh invocation 分配递增 revision,并将其登记到每个请求的 session ID。旧请求只会丢弃已被更新请求覆盖的 ID;不同 ID 的并发结果可各自合并。全量 `sessionListEpoch` 仍使旧定向响应整体失效,过期行不能更新 sessions、active shell 或 error。 +- 定向刷新仅在它仍拥有至少一个 session ID 时清除或写入它自己标记的 refresh error;不覆盖首屏或分页错误。 +- `mergeSessions` 保留已知 session 的较新 `updatedAt`,但允许相同 timestamp 的完整更新(例如状态修正)按到达顺序合并。 +- `sessionIpc` 按目标 webContents id 缓存 identity 未就绪时的最新定向 activation/deactivation 事件;`session` store 在取得本窗口 id 后仅处理本窗口事件,其他窗口事件不能覆盖它。非定向 list/update 不受影响。 + +### Start deeplink + +`applyStartDeeplink` 接收并守卫 payload token。在 `await currentDraftDefaultsTask`、`nextTick`、`ensureEnabledModelsReady` 之后确认 draft store 当前 token 仍相同;仅在仍当前时写 message/system prompt/model,且只清除相同 token 的 pending payload。ChatMain 的 start deeplink 激活流程也在每个 async 边界后确认 token,旧 payload 不得再路由、选择 agent 或关闭 session。当前 main 固定关闭的 `autoSend` 保持不消费。 + +## Slice 2:流式列表和搜索 + +### 行级流式状态 + +ChatPage 计算当前 stream message id,MessageList 按 `item.id` 生成 `isStreamingMessage`。MessageListRow / MessageItemAssistant 接收该行状态,保留 prop 默认兼容。会话是否仍在生成的全局状态继续保留给页面 shell、composer/stop action 等真正需要的消费者。 + +### 搜索 + +使用 VueUse 防抖的 canonical query 作为 expensive match collection / highlight 的输入。关闭时立即清空 search result 和 highlight。结果索引使用与 display model 相同的可渲染 block 投影,避免计数包含永远不会挂载的内容;结果导航使用已提交的查询,必要时在等待中禁用导航或保持最后稳定结果,避免旧结果与新 query 混用。MutationObserver 只聚合受影响行并按 rAF 刷新,明确抑制自身的高亮 DOM mutation。 + +不在本 slice 更改 markdown virtualization 语义,除非 review 证明它能以小范围改动保留现有匹配和跳转正确性。 + +### 历史加载状态 + +将 message store 的历史加载结果升级为受判别状态的返回值或 state,调用方据此区分 loaded/exhausted/error。失败状态在 ChatPage 顶部使用已有 shadcn button 和 i18n 文案提供 retry;成功及 exhausted 不造成额外打扰。 + +## Slice 3:多窗口 appearance foundation + +在 `src/renderer/src/foundation/appearance/` 放置只依赖浏览器/Vue 基础能力的纯 helper 或 composable:应用 theme class、font class、document lang/dir,并对短暂主题切换禁用 transition。数据读取和 renderer-specific listener 仍留在各 app,由 app 将解析后的 appearance state 传入 foundation helper。 + +chat main 迁移现有 `syncAppearanceClasses`;settings/floating 仅复用适用部分,保持各自 preload API 和 listener 生命周期。settings 的 persisted language 异步读取用 watcher cleanup 防止已失效的读取覆盖新语言事件。不能令 foundation import Pinia、feature、chat store 或 API client。 + +`ChatTabView` 已迁入 `apps/chat-main/`,router 保持按需加载该 route host,启动职责与路由契约不变。 + +## Slice 4:相邻 renderer 状态机收敛 + +### 项目环境重排 + +`projectStore.reorderEnvironments` 使用 renderer 内递增 revision 保护乐观排序。并发的旧请求仍向它的调用方报错,但不得在后到请求已经开始或完成后回滚 environments,避免拖拽和菜单重排相互覆盖。 + +### 流式虚拟窗口 + +稳定前缀 + append-only tail contract 仅用于能够证明 append-only 的 placeholder/fallback stream。虚拟化直接从该 contract 读取可见窗口,避免每个 token 先 materialize 完整历史数组;inline stream 保留保守全量路径,直到有前/中/后段 geometry contract 和完整 correctness 证明。tail 替换同时回收已离开 tail 的估高 cache。 + +## Slice 5:循环 review 与可证据化收尾 + +每完成一个 slice: + +1. 审查变更 diff、依赖方向、用户路径与异步状态机。 +2. 修复 review 明确发现的问题。 +3. 再次搜索 renderer 中相邻 owner 是否存在同类可安全收敛点。 +4. 仅当没有高/中风险、低回归风险的剩余优化,进入最终验证。 + +最终统一执行: + +```bash +pnpm run format +pnpm run i18n +pnpm run lint +pnpm run typecheck +pnpm run test:renderer +pnpm run architecture:renderer-baseline:check +``` + +根据改动覆盖补充或运行直接 renderer suite。确认 git diff、测试与质量门禁后,创建 `--base dev` 的 PR。 + +## 回滚 + +所有改动均是 renderer 内部状态边界和展示优化:不涉及 IPC 或数据迁移。可以按 slice 反向恢复,且用户已有 session、配置和消息不需要迁移。 \ No newline at end of file diff --git a/docs/architecture/renderer-scope-optimization/spec.md b/docs/architecture/renderer-scope-optimization/spec.md new file mode 100644 index 0000000000..19275a1680 --- /dev/null +++ b/docs/architecture/renderer-scope-optimization/spec.md @@ -0,0 +1,62 @@ +# Renderer Scope 收敛、可靠性与性能优化 + +## 背景 + +Renderer 已有独立的 main、settings、floating、splash 和 browser-overlay entry,且 chat main 的 composition root 已迁入 `apps/chat-main`。不过主聊天会话创建、会话列表刷新和 deeplink 仍存在并发窗口;流式消息在状态切换时会向全部可见行广播会话级状态;多窗口外观初始化也存在重复实现。 + +本工作在不改变 IPC、持久化数据、用户可见功能或 renderer entry 的前提下,继续收敛 renderer scope、消除已确认的异步竞态,并降低长会话流式与搜索时的无效计算。 + +## 目标 + +1. 让新建会话提交具备 renderer 侧互斥,避免同一草稿的快速重复触发创建多个会话。 +2. 防止过期的 session refresh 结果覆盖较新的本地或 IPC 更新,并保证 runtime webContents identity 尚未就绪时不会错误丢弃定向状态事件。 +3. 让 start deeplink 的异步默认值、模型解析、路由跳转和会话关闭只为仍是当前 token 的 payload 提交,不能由旧 deeplink 覆盖后到 deeplink 的导航意图。 +4. 将流式会话状态收敛到正在流式输出的消息行,避免历史 assistant 行在流开始和结束时无意义重算。 +5. 降低聊天搜索输入期间的同步全量扫描与 DOM 高亮频率,并保证结果计数、可见 DOM 高亮和键盘导航使用同一可渲染内容语义。 +6. 让历史消息加载区分 exhausted 与失败,失败时提供可访问的重试路径。 +7. 在不共享运行时 Pinia/Vue 实例的前提下,提取 renderer-only 外观初始化能力,减少 chat main、settings、floating 的主题/字体/语言初始化漂移。 +8. 保持既有 `features/chat-page/model/displayMessage.ts` 的 chat feature model 所有权;不为纯目录移动制造跨 feature “共享层”。 +9. 每个实现切片完成后执行独立 review;发现问题先修复,再继续寻找明确且低风险的 renderer 优化点。 + +## 约束 + +- 代码改动范围限于 `src/renderer/`、对应 renderer 测试和本 SDD 文档;不变更 main、preload、shared IPC contract、数据库 schema 或用户设置格式。 +- 不改变现有发送、deeplink、会话选择、流式消息、搜索、历史加载的正常用户路径语义。 +- renderer app 维持独立 bootstrap、Pinia、i18n 和生命周期,不能共享运行中实例。 +- 新的用户可见文案必须使用既有 i18n key;若既有 key 无法表达状态,才添加全 locale key。 +- 不新增 renderer API facade;`renderer/api/*Client` 保持唯一 IPC adapter。 +- 性能优化以减少可验证的无效重算为目标,不预设未测量的复杂 cache/worker 方案。 +- 按用户要求,开发中不运行 lint、typecheck 或测试;所有质量门禁仅在 PR 创建前统一执行。 + +## 非目标 + +- 不移动或重命名 Vite HTML entry。 +- 不改 main 进程 stream 合帧、会话幂等、session 持久化协议或当前固定关闭的 start deeplink 自动发送产品语义。 +- 不重写成熟的 scroll controller、message window 锚定机制或 stream tombstone/generation gate。 +- 不把所有 `components/message` 移入 chat feature;它们可读取 chat 的纯 display model contract,详见 `chat-display-model-boundary`。 +- 不以 bundle 分包替换为主要优化手段,除非 production profiling 证明常规文本会话的首屏解析是明确瓶颈。 + +## 验收标准 + +1. 连续触发同一条新会话提交时,在首个提交完成前不会发出第二次 create/send;失败后仍可重新提交,且草稿只在成功时清除。 +2. 定向 session refresh 以 session ID 为粒度处理重叠:同一 ID 只有最新请求可以提交结果,不同 ID 的并发结果都可提交;全量列表 epoch 仍会使旧定向结果整体失效,且合并不会用更旧 `updatedAt` 覆盖较新 session 数据。 +3. webContents identity 异步就绪期间收到的定向 activation/deactivation 被保留,并在 identity 就绪后按已有 session activation 逻辑处理。 +4. 被较新 start deeplink 取代的异步任务不写入 message/draft/model,也不清除较新的 pending payload。 +5. `MessageListRow` 只为当前流式 assistant message 接收生成中状态;历史行的活动分组结果不因会话级状态切换而变化。 +6. 聊天搜索的文本扫描和 DOM 高亮经过防抖;关闭、结果导航、可见行高亮行为保持可用。 +7. 历史加载在 failure 与 exhausted 情况有不同 UI 状态;failure 可重试且不误报已到底。 +8. 共享外观能力不导入 chat feature/store;每个 app 仍按自己的数据源初始化并在 cleanup 时解绑。 +9. 每项实现后完成独立只读 review,最终 review 未发现需要在本 scope 中继续处理的高/中风险问题。 +10. 最终执行 format、i18n、lint、typecheck、针对性与 renderer tests,并在通过后创建以 `dev` 为 base 的 PR。 + +## 风险与兼容性 + +- 提交互斥必须覆盖文件准备等异步步骤;否则第二次触发仍可能穿透。 +- session `updatedAt` 只可作为 renderer 防倒灌保护,不能替代 main 持久化的线性化保证。 +- 深链 token 必须在每个异步边界后复核,且清除动作必须与 token 绑定。 +- 搜索防抖只延后计算,不能使 Escape、关闭、箭头导航使用旧索引。 +- 外观 bootstrap 的初始读取和订阅顺序需兼容各 renderer 的 preload API 差异。 + +## GitHub Issue + +本工作由用户直接要求提交 PR;未请求创建或同步 GitHub Issue。 \ No newline at end of file diff --git a/docs/architecture/renderer-scope-optimization/tasks.md b/docs/architecture/renderer-scope-optimization/tasks.md new file mode 100644 index 0000000000..8ef01394a7 --- /dev/null +++ b/docs/architecture/renderer-scope-optimization/tasks.md @@ -0,0 +1,16 @@ +# 任务清单 + +- [x] 建立 renderer scope 优化设计、约束和验证计划。 +- [ ] 审查并修复 NewThreadPage 提交互斥与 deeplink token 竞态(含 ChatMain 激活抢占)。 +- [ ] 审查并修复 session refresh 的陈旧结果及 runtime identity 早到 IPC 事件。 +- [ ] 添加对应 renderer 回归测试(最终统一运行)。 +- [x] 把流式生成状态收敛到单条 assistant 行,并审查渲染影响。 +- [x] 对聊天搜索加入低风险防抖/高亮调度优化,并审查导航正确性。 +- [x] 区分历史消息 exhausted/error 并添加重试反馈。 +- [ ] 提取不依赖 chat 的 renderer appearance foundation,并迁移适用 app,保持 language direction 与初始化事件一致。 +- [x] 核验并清理无引用的 `views/SettingsTabView.vue` 遗留设置视图。 +- [x] 将 `ChatTabView` 路由宿主迁入 `apps/chat-main/` 并保持 lazy route。 +- [ ] 保护项目环境重排的并发乐观回滚,并收敛 append-only 流式虚拟窗口数据路径。 +- [ ] 对每个切片执行独立 review,修复发现的问题并继续寻找剩余优化。 +- [ ] 执行最终 format、i18n、lint、typecheck、renderer tests 与 architecture baseline check。 +- [ ] 审阅最终 diff,提交 commit 并创建 base 为 `dev` 的 PR。 \ No newline at end of file diff --git a/src/renderer/floating/main.ts b/src/renderer/floating/main.ts index c512743ede..e155bf209b 100644 --- a/src/renderer/floating/main.ts +++ b/src/renderer/floating/main.ts @@ -3,8 +3,11 @@ import { createApp, defineComponent, h, ref } from 'vue' import { createI18n } from 'vue-i18n' import FloatingButton from './FloatingButton.vue' import locales, { pluralRules } from '../src/i18n' +import { + applyDocumentAppearance, + resolveDocumentDirection +} from '../src/foundation/appearance/documentAppearance' -const RTL_LANGUAGES = new Set(['fa-IR', 'he-IL']) type FloatingLocale = keyof typeof locales const i18n = createI18n({ @@ -25,16 +28,14 @@ const applyLanguage = (language: string) => { const resolvedLanguage = resolveLanguage(language) i18n.global.locale.value = resolvedLanguage - document.documentElement.lang = resolvedLanguage - document.documentElement.dir = RTL_LANGUAGES.has(resolvedLanguage) ? 'rtl' : 'ltr' + applyDocumentAppearance({ + language: resolvedLanguage, + direction: resolveDocumentDirection(resolvedLanguage) + }) } const applyTheme = (nextTheme: 'dark' | 'light') => { - document.documentElement.dataset.theme = nextTheme - document.documentElement.classList.remove('dark', 'light') - document.body.classList.remove('dark', 'light') - document.documentElement.classList.add(nextTheme) - document.body.classList.add(nextTheme) + applyDocumentAppearance({ theme: nextTheme, themeDataset: true }) floatingTheme.value = nextTheme } @@ -50,24 +51,42 @@ const app = createApp(Root) app.use(i18n) app.mount('#app') +let languageRevision = 0 +const unsubscribeLanguageChanged = window.floatingButtonAPI.onLanguageChanged((language) => { + languageRevision += 1 + applyLanguage(language) +}) +const initialLanguageRevision = languageRevision + void window.floatingButtonAPI .getLanguage() - .then(applyLanguage) + .then((language) => { + if (languageRevision === initialLanguageRevision) { + applyLanguage(language) + } + }) .catch((error) => { console.warn('Failed to initialize floating widget language:', error) }) -const unsubscribeLanguageChanged = window.floatingButtonAPI.onLanguageChanged(applyLanguage) +let themeRevision = 0 +const unsubscribeThemeChanged = window.floatingButtonAPI.onThemeChanged((theme) => { + themeRevision += 1 + applyTheme(theme) +}) +const initialThemeRevision = themeRevision void window.floatingButtonAPI .getTheme() - .then(applyTheme) + .then((theme) => { + if (themeRevision === initialThemeRevision) { + applyTheme(theme) + } + }) .catch((error) => { console.warn('Failed to initialize floating widget theme:', error) }) -const unsubscribeThemeChanged = window.floatingButtonAPI.onThemeChanged(applyTheme) - window.addEventListener( 'beforeunload', () => { diff --git a/src/renderer/settings/App.vue b/src/renderer/settings/App.vue index 32727a9147..631e85a04b 100644 --- a/src/renderer/settings/App.vue +++ b/src/renderer/settings/App.vue @@ -94,7 +94,6 @@ import { useRouter, useRoute, RouterView } from 'vue-router' import { onMounted, onBeforeUnmount, Ref, ref, watch, computed, nextTick, unref } from 'vue' import { useI18n } from 'vue-i18n' import { useEventListener, useTitle } from '@vueuse/core' -import { createConfigClient } from '@api/ConfigClient' import { createDeviceClient } from '@api/DeviceClient' import { createWindowClient } from '@api/WindowClient' import { getRuntimeArch, getRuntimePlatform } from '@api/runtime' @@ -118,6 +117,7 @@ import { useProviderDeeplinkImportStore } from '@/stores/providerDeeplinkImport' import { useMcpInstallDeeplinkHandler } from '../src/lib/storeInitializer' import { ensureIconsLoaded } from '../src/lib/iconLoader' import { useFontManager } from '../src/composables/useFontManager' +import { applyDocumentAppearance } from '../src/foundation/appearance/documentAppearance' import { markStartupInteractive } from '../src/lib/startupDeferred' import type { DatabaseRepairSuggestedPayload } from '@shared/types/databaseSchema' import type { LLM_PROVIDER } from '@shared/types/provider' @@ -141,7 +141,6 @@ type SettingsWindowState = Window & { __deepchatSettingsPendingSection?: string | null } -const configClient = createConfigClient() const deviceClient = createDeviceClient() const windowClient = createWindowClient() const settingsEventCleanups: Array<() => void> = [] @@ -576,22 +575,37 @@ const SETTINGS_TAB_TEST_IDS: Record = { const getSettingsTabTestId = (name: string) => SETTINGS_TAB_TEST_IDS[name] ?? `settings-tab-${name.replace(/^settings-/, '')}` -// Watch language changes and update i18n + HTML dir watch( - () => languageStore.language, - async () => { - locale.value = await configClient.getLanguage() - document.documentElement.dir = languageStore.dir - } + [() => themeStore.themeMode, () => themeStore.isDark, () => uiSettingsStore.fontSizeClass], + ([themeMode, isDark, fontSizeClass], previous) => { + const theme = themeMode === 'system' ? (isDark ? 'dark' : 'light') : themeMode + const previousTheme = previous + ? previous[0] === 'system' + ? previous[1] + ? 'dark' + : 'light' + : previous[0] + : null + + applyDocumentAppearance({ + theme, + fontSizeClass, + disableThemeTransition: previousTheme === null || previousTheme !== theme + }) + }, + { immediate: true } ) -// Watch font size changes and update classes +// The language store owns the sole initial IPC snapshot and its change listener. +// Settings only projects that resolved state onto this window's document. +void languageStore.initLanguage?.() + watch( - () => uiSettingsStore.fontSizeClass, - (newClass, oldClass) => { - if (oldClass) document.documentElement.classList.remove(oldClass) - document.documentElement.classList.add(newClass) - } + [() => locale.value, () => languageStore.dir], + ([language, direction]) => { + applyDocumentAppearance({ language, direction }) + }, + { immediate: true } ) const handleErrorClosed = () => { diff --git a/src/renderer/src/apps/chat-main/ChatMainApp.vue b/src/renderer/src/apps/chat-main/ChatMainApp.vue index eee16723d3..10be2eeb7e 100644 --- a/src/renderer/src/apps/chat-main/ChatMainApp.vue +++ b/src/renderer/src/apps/chat-main/ChatMainApp.vue @@ -13,7 +13,7 @@ import { usePageRouterStore } from '@/stores/ui/pageRouter' import { Toaster } from '@shadcn/components/ui/sonner' import { useToast } from '@/components/use-toast' import { useUiSettingsStore } from '@/stores/uiSettingsStore' -import { useThemeStore, type ThemeMode } from '@/stores/theme' +import { useThemeStore } from '@/stores/theme' import { useLanguageStore } from '@/stores/language' import { useI18n } from 'vue-i18n' import TranslatePopup from '@/components/popup/TranslatePopup.vue' @@ -25,6 +25,7 @@ import { initAppStores, useMcpInstallDeeplinkHandler } from '@/lib/storeInitiali import { ensureIconsLoaded } from '@/lib/iconLoader' import 'vue-sonner/style.css' // vue-sonner v2 requires this import import { useFontManager } from '@/composables/useFontManager' +import { applyDocumentAppearance } from '@/foundation/appearance/documentAppearance' import AppBar from '@/components/AppBar.vue' import { useDeviceVersion } from '@/composables/useDeviceVersion' import WindowSideBar from '@/components/WindowSideBar.vue' @@ -84,7 +85,7 @@ watch( const themeStore = useThemeStore() const langStore = useLanguageStore() const modelCheckStore = useModelCheckStore() -const { t } = useI18n() +const { t, locale } = useI18n() const toasterTheme = computed(() => themeStore.themeMode === 'system' ? (themeStore.isDark ? 'dark' : 'light') : themeStore.themeMode ) @@ -95,39 +96,33 @@ let errorDisplayTimer: number | null = null const { setup: setupMcpDeeplink, cleanup: cleanupMcpDeeplink } = useMcpInstallDeeplinkHandler() -const resolveThemeName = (themeMode: ThemeMode, isDark: boolean) => { - return themeMode === 'system' ? (isDark ? 'dark' : 'light') : themeMode -} - -const syncAppearanceClasses = (themeName: string, fontSizeClass: string) => { - if (typeof document === 'undefined') { - return - } - - const root = document.documentElement - // 切换期间临时禁用过渡,让主题瞬时生效,避免大量元素同时跑颜色过渡造成的重绘卡顿 - root.classList.add('dc-theme-switching') - - for (const target of [root, document.body]) { - target.classList.remove('light', 'dark', 'system') - target.classList.add(themeName) - target.classList.remove('text-xs', 'text-sm', 'text-base', 'text-lg', 'text-xl', 'text-2xl') - target.classList.add(fontSizeClass) - } +watch( + [() => themeStore.themeMode, () => themeStore.isDark, () => uiSettingsStore.fontSizeClass], + ([themeMode, isDark, fontSizeClass], previous) => { + const theme = themeMode === 'system' ? (isDark ? 'dark' : 'light') : themeMode + const previousTheme = previous + ? previous[0] === 'system' + ? previous[1] + ? 'dark' + : 'light' + : previous[0] + : null + + applyDocumentAppearance({ + theme, + fontSizeClass, + disableThemeTransition: previousTheme === null || previousTheme !== theme + }) + }, + { immediate: true } +) - // 强制同步重算,使本次类切换在「过渡已禁用」的状态下完成 - void root.offsetWidth - // 下一帧恢复过渡,不影响日常 hover 等交互动画 - requestAnimationFrame(() => { - root.classList.remove('dc-theme-switching') - }) -} +void langStore.initLanguage?.() watch( - [() => themeStore.themeMode, () => themeStore.isDark, () => uiSettingsStore.fontSizeClass], - ([themeMode, isDark, fontSizeClass]) => { - const nextThemeName = resolveThemeName(themeMode, isDark) - syncAppearanceClasses(nextThemeName, fontSizeClass) + [() => locale.value, () => langStore.dir], + ([language, direction]) => { + applyDocumentAppearance({ language, direction }) }, { immediate: true } ) @@ -315,27 +310,65 @@ const activatePendingStartDeeplink = async () => { return } + const isCurrentPendingStartDeeplink = () => draftStore.pendingStartDeeplink?.token === token + if (!isCurrentPendingStartDeeplink()) { + return + } + processingStartDeeplinkToken.value = token try { const initComplete = Boolean(await configClient.getSetting('init_complete')) - if (!initComplete) { + if (!isCurrentPendingStartDeeplink() || !initComplete) { return } await router.isReady() + if (!isCurrentPendingStartDeeplink()) { + return + } + if (router.currentRoute.value.name !== 'chat') { + if (!isCurrentPendingStartDeeplink()) { + return + } + await router.push({ name: 'chat' }) + if (!isCurrentPendingStartDeeplink()) { + return + } } + if (!isCurrentPendingStartDeeplink()) { + return + } agentStore.setSelectedAgent('deepchat') + if (!isCurrentPendingStartDeeplink()) { + return + } + if (sessionStore.hasActiveSession) { + if (!isCurrentPendingStartDeeplink()) { + return + } + await sessionStore.closeSession() + if (!isCurrentPendingStartDeeplink()) { + return + } + processedStartDeeplinkToken.value = token return } + if (!isCurrentPendingStartDeeplink()) { + return + } pageRouterStore.goToNewThread({ refresh: true }) + if (!isCurrentPendingStartDeeplink()) { + return + } + processedStartDeeplinkToken.value = token } finally { if (processingStartDeeplinkToken.value === token) { @@ -353,8 +386,7 @@ const handleStartDeeplink = (_event: unknown, payload?: Omit { (defaultChatWorkspacePath.value.length > 0 && projectPath === defaultChatWorkspacePath.value) ) } -const isWorkspaceSession = (session: UISession) => !isChatSession(session) const isChatProjectGroup = (group: SessionGroup) => group.id === NO_PROJECT_GROUP_ID || (defaultChatWorkspacePath.value.length > 0 && @@ -848,7 +847,7 @@ const compareProjectGroups = (left: SessionGroup, right: SessionGroup) => { return 0 } -const filteredGroups = computed(() => { +const orderedFilteredGroups = computed(() => { const groups = baseFilteredGroups.value if (sessionStore.groupMode !== 'project') { return groups @@ -870,20 +869,61 @@ const compareSidebarSessions = (left: UISession, right: UISession) => { return left.title.localeCompare(right.title) || left.id.localeCompare(right.id) } -const sortSidebarSessions = (sessions: UISession[]) => [...sessions].sort(compareSidebarSessions) -const chatSessions = computed(() => - sortSidebarSessions( - baseFilteredGroups.value.flatMap((group) => { - if (sessionStore.groupMode === 'project') { - return isChatProjectGroup(group) ? group.sessions : [] +const ensureSortedSessions = ( + sessions: UISession[], + compare: (left: UISession, right: UISession) => number +) => { + for (let index = 1; index < sessions.length; index += 1) { + if (compare(sessions[index - 1], sessions[index]) > 0) { + return [...sessions].sort(compare) + } + } + + return sessions +} +const sessionSections = computed(() => { + if (sessionStore.groupMode === 'project') { + const chatSessions = ensureSortedSessions( + orderedFilteredGroups.value.filter(isChatProjectGroup).flatMap((group) => group.sessions), + compareSidebarSessions + ) + + return { + chatSessions, + workspaceGroups: orderedFilteredGroups.value.filter(isProjectDirectoryGroup).map((group) => { + const sessions = ensureSortedSessions(group.sessions, compareSidebarSessions) + return sessions === group.sessions ? group : { ...group, sessions } + }) + } + } + + const chatSessions: UISession[] = [] + const workspaceGroups: SessionGroup[] = [] + for (const group of orderedFilteredGroups.value) { + const workspaceSessions: UISession[] = [] + for (const session of group.sessions) { + if (isChatSession(session)) { + chatSessions.push(session) + } else { + workspaceSessions.push(session) } + } - return group.sessions.filter(isChatSession) - }) - ) -) + if (workspaceSessions.length > 0) { + workspaceGroups.push({ + ...group, + sessions: ensureSortedSessions(workspaceSessions, compareSidebarSessions) + }) + } + } + + return { + chatSessions: ensureSortedSessions(chatSessions, compareSidebarSessions), + workspaceGroups + } +}) const chatSectionGroup = computed(() => { - const sessions = chatSessions.value + const sessions = sessionSections.value.chatSessions if (sessions.length === 0) { return null } @@ -895,18 +935,7 @@ const chatSectionGroup = computed(() => { sessions } }) -const workspaceGroups = computed(() => { - if (sessionStore.groupMode === 'project') { - return filteredGroups.value.filter(isProjectDirectoryGroup) - } - - return baseFilteredGroups.value - .map((group) => ({ - ...group, - sessions: sortSidebarSessions(group.sessions.filter(isWorkspaceSession)) - })) - .filter((group) => group.sessions.length > 0) -}) +const workspaceGroups = computed(() => sessionSections.value.workspaceGroups) const visibleGroups = computed(() => [ ...(chatSectionGroup.value ? [chatSectionGroup.value] : []), ...workspaceGroups.value @@ -946,6 +975,13 @@ const getGroupIcon = (group: SessionGroup) => const isGroupCollapsed = (group: SessionGroup) => collapsedGroupIds.value.has(getGroupIdentifier(group)) +const canAutoFillSessionList = computed( + () => + normalizedSessionSearchQuery.value.length === 0 && + !isPinnedSectionCollapsed.value && + !visibleGroups.value.some(isGroupCollapsed) +) + const visibleShortcutSessions = computed(() => { if (collapsed.value) { return [] @@ -1585,7 +1621,12 @@ const handleSessionListScroll = () => { // 这里在加载/过滤变化后主动检测视口是否被填满,未满且仍有更多数据时继续加载。 let isFillingSessionList = false const ensureSessionListFilled = async () => { - if (isFillingSessionList || isProjectGroupDragging.value || collapsed.value) { + if ( + isFillingSessionList || + isProjectGroupDragging.value || + collapsed.value || + !canAutoFillSessionList.value + ) { return } isFillingSessionList = true @@ -1599,6 +1640,7 @@ const ensureSessionListFilled = async () => { !listElement || isProjectGroupDragging.value || collapsed.value || + !canAutoFillSessionList.value || !sessionStore.hasMore || sessionStore.loadingMore || sessionStore.loading @@ -1636,8 +1678,8 @@ const visibleSessionFingerprint = computed(() => ].join('|') ) -// 会话列表内容、过滤、分组折叠或容器高度变化后,若视口未被填满则继续加载, -// 保证「滚动加载更多」在首屏内容过少或可见内容被过滤/折叠后也能启动(issue #1762)。 +// 会话列表内容或容器高度变化后,未过滤且所有分组展开时若视口仍未填满则继续加载, +// 保证「滚动加载更多」在首屏内容过少时也能启动(issue #1762),而不会因隐藏行扫完分页。 watch( [ () => sessionStore.sessions.length, diff --git a/src/renderer/src/components/chat/MessageList.vue b/src/renderer/src/components/chat/MessageList.vue index 87067c4358..a8ca3d5f06 100644 --- a/src/renderer/src/components/chat/MessageList.vue +++ b/src/renderer/src/components/chat/MessageList.vue @@ -12,6 +12,7 @@ :key="item.renderKey ?? item.id" :item="item" :is-generating="isGenerating" + :is-streaming-message="item.id === streamingMessageId" :show-trace="traceMessageIdSet.has(item.id)" :is-capturing="isCapturingValue" :is-read-only="isReadOnly" @@ -61,9 +62,10 @@ const props = withDefaults( ephemeralRateLimitBlock?: DisplayAssistantMessageBlock | null ephemeralRateLimitMessageId?: string | null isGenerating?: boolean + streamingMessageId?: string | null traceMessageIds?: string[] isReadOnly?: boolean - allMessagesForCapture?: MessageListItem[] + resolveCaptureParentId?: (messageId: string, parentId?: string) => string | undefined beforeSpacerHeight?: number afterSpacerHeight?: number disableMarkdownVirtualization?: boolean @@ -73,9 +75,9 @@ const props = withDefaults( ephemeralRateLimitBlock: null, ephemeralRateLimitMessageId: null, isGenerating: false, + streamingMessageId: null, traceMessageIds: () => [], isReadOnly: false, - allMessagesForCapture: () => [], beforeSpacerHeight: 0, afterSpacerHeight: 0, disableMarkdownVirtualization: false @@ -99,9 +101,6 @@ const shouldDisableMarkdownVirtualization = computed( () => props.disableMarkdownVirtualization || isCapturingValue.value ) const allRenderedMessages = computed(() => props.messages) -const captureSearchMessages = computed(() => - props.allMessagesForCapture.length > 0 ? props.allMessagesForCapture : props.messages -) const onRetry = (messageId: string) => emit('retry', messageId) const onDelete = (messageId: string) => emit('delete', messageId) @@ -112,16 +111,18 @@ const onTrace = (messageId: string) => emit('trace', messageId) const onEditSave = (payload: { messageId: string; text: string }) => emit('editSave', payload) const onMeasure = (payload: { messageId: string; height: number }) => emit('measure', payload) -const resolveCaptureParentId = (messageId: string, parentId?: string): string | undefined => { - const messageItems = captureSearchMessages.value +const resolveVisibleCaptureParentId = ( + messageId: string, + parentId?: string +): string | undefined => { if (parentId) { - const parentMessage = messageItems.find((msg) => msg.id === parentId) + const parentMessage = props.messages.find((msg) => msg.id === parentId) if (parentMessage?.role === 'user') return parentId } - const messageIndex = messageItems.findIndex((msg) => msg.id === messageId) + const messageIndex = props.messages.findIndex((msg) => msg.id === messageId) if (messageIndex <= 0) return undefined for (let index = messageIndex - 1; index >= 0; index -= 1) { - const candidate = messageItems[index] as DisplayMessage + const candidate = props.messages[index] as DisplayMessage if (candidate.role === 'user') return candidate.id } return undefined @@ -133,7 +134,9 @@ const handleCopyImage = async ( fromTop: boolean, modelInfo: { model_name: string; model_provider: string } ) => { - const resolvedParentId = resolveCaptureParentId(messageId, parentId) + const resolvedParentId = props.resolveCaptureParentId + ? props.resolveCaptureParentId(messageId, parentId) + : resolveVisibleCaptureParentId(messageId, parentId) await captureMessage({ messageId, parentId: resolvedParentId, fromTop, modelInfo }) } diff --git a/src/renderer/src/components/chat/MessageListRow.vue b/src/renderer/src/components/chat/MessageListRow.vue index 49b854fa40..e7212c6df4 100644 --- a/src/renderer/src/components/chat/MessageListRow.vue +++ b/src/renderer/src/components/chat/MessageListRow.vue @@ -35,6 +35,7 @@ :message="item as DisplayAssistantMessage" :use-legacy-actions="false" :is-in-generating-thread="isGenerating" + :is-streaming-message="isStreamingMessage" :show-trace="showTrace" :is-capturing-image="isCapturing" :is-read-only="isReadOnly" @@ -65,6 +66,7 @@ const props = withDefaults( defineProps<{ item: MessageListItem isGenerating?: boolean + isStreamingMessage?: boolean showTrace?: boolean isCapturing?: boolean isReadOnly?: boolean @@ -72,6 +74,7 @@ const props = withDefaults( }>(), { isGenerating: false, + isStreamingMessage: false, showTrace: false, isCapturing: false, isReadOnly: false, diff --git a/src/renderer/src/components/message/MessageItemAssistant.vue b/src/renderer/src/components/message/MessageItemAssistant.vue index 38be3ca188..3f5ba57924 100644 --- a/src/renderer/src/components/message/MessageItemAssistant.vue +++ b/src/renderer/src/components/message/MessageItemAssistant.vue @@ -247,6 +247,7 @@ const props = defineProps<{ isCapturingImage: boolean useLegacyActions?: boolean isInGeneratingThread?: boolean + isStreamingMessage?: boolean showTrace?: boolean isReadOnly?: boolean disableMarkdownVirtualization?: boolean @@ -399,7 +400,9 @@ const currentContent = computed(() => { }) const shouldGroupActivity = computed(() => { - if (resolvedIsInGeneratingThread.value) return false + // Row-level: only the actively streaming row stays ungrouped so its activity + // renders live; thread-level generating must not ungroup settled history. + if (props.isStreamingMessage) return false return currentMessage.value.status !== 'pending' }) diff --git a/src/renderer/src/composables/chat/chatScrollState.ts b/src/renderer/src/composables/chat/chatScrollState.ts index 499442f53b..db8ae72652 100644 --- a/src/renderer/src/composables/chat/chatScrollState.ts +++ b/src/renderer/src/composables/chat/chatScrollState.ts @@ -39,6 +39,8 @@ export type ChatScrollState = { activeGesture: boolean lastCommittedRequestId: number resumeUserOwnedAfterNavigation: boolean + /** True once the user explicitly navigated (search/spotlight) this session. */ + hasExplicitNavigation: boolean } export type ChatScrollEvent = @@ -67,7 +69,8 @@ export function createChatScrollState(sessionEpoch = 0): ChatScrollState { nearBottom: true, activeGesture: false, lastCommittedRequestId: 0, - resumeUserOwnedAfterNavigation: false + resumeUserOwnedAfterNavigation: false, + hasExplicitNavigation: false } } @@ -104,7 +107,8 @@ export function reduceChatScrollState( ...state, mode: 'navigating', resumeUserOwnedAfterNavigation: state.resumeUserOwnedAfterNavigation || state.userOwned, - userOwned: false + userOwned: false, + hasExplicitNavigation: true } case 'explicit-navigation-complete': return { @@ -161,6 +165,13 @@ export function canAcceptChatScrollRequest( case 'submit': return true case 'session-restore': + // A restore that lands after the user already jumped somewhere (search, + // spotlight) must not yank the viewport back to the bottom. + return ( + !state.userOwned && + !state.hasExplicitNavigation && + (state.mode === 'restoring' || state.mode === 'following') + ) case 'auto-follow': return !state.userOwned && (state.mode === 'restoring' || state.mode === 'following') case 'history-prepend': diff --git a/src/renderer/src/composables/message/useMessageWindow.ts b/src/renderer/src/composables/message/useMessageWindow.ts index 57410b9743..af49bd0e7a 100644 --- a/src/renderer/src/composables/message/useMessageWindow.ts +++ b/src/renderer/src/composables/message/useMessageWindow.ts @@ -13,10 +13,21 @@ export type MessageLayoutEntry = { export type MessageMeasurementSnapshot = Readonly> +/** + * An explicit promise from the display-message layer: `stable` is an unchanged + * history prefix and `tail` is the only high-frequency, append-only segment. + * A null value means callers must use the conservative full-layout path. + */ +export type MessageLayoutSegments = { + stable: readonly MessageListItem[] + tail: readonly MessageListItem[] +} + type ReadableRef = { readonly value: T } type UseMessageWindowOptions = { messages: ReadableRef + layoutSegments?: ReadableRef } const MIN_HEIGHT = 96 @@ -110,8 +121,43 @@ function estimateHeight(msg: MessageListItem): number { return withRowSpacing(clamp(h)) } +type EstimatedHeightCacheEntry = { + message: MessageListItem + measurementKey: string + updatedAt: number + content: MessageListItem['content'] + estimatedHeight: number +} + +function canReuseEstimatedHeight( + cached: EstimatedHeightCacheEntry | undefined, + message: MessageListItem, + measurementKey: string +): cached is EstimatedHeightCacheEntry { + return Boolean( + cached && + cached.message === message && + cached.measurementKey === measurementKey && + cached.updatedAt === message.updatedAt && + cached.content === message.content + ) +} + export function useMessageWindow(options: UseMessageWindowOptions) { const measuredHeights = shallowRef>({}) + const estimatedHeightCache = new Map() + const entryByMessageId = new Map() + const entryByMeasurementKey = new Map() + let lastLayoutEntries: MessageLayoutEntry[] | undefined + let lastLayoutStable: readonly MessageListItem[] | undefined + let lastLayoutStableLength = -1 + let lastLayoutTail: readonly MessageListItem[] | undefined + // Keep segment keys alongside their entries so append-only token updates can + // discard departed tail estimates without walking the whole history cache. + let lastLayoutStableMeasurementKeys = new Set() + let lastLayoutTailMeasurementKeys = new Set() + let measurementVersion = 0 + let lastLayoutMeasurementVersion = -1 let measureFlushQueued = false const flushMeasuredHeights = () => { @@ -129,11 +175,115 @@ export function useMessageWindow(options: UseMessageWindowOptions) { } const entries = computed(() => { + const measurements = measuredHeights.value + const previousEntries = lastLayoutEntries ?? [] + const segments = options.layoutSegments?.value ?? null + const canReuseLayoutSegments = + segments !== null && + segments.stable === lastLayoutStable && + segments.stable.length === lastLayoutStableLength && + segments.tail !== lastLayoutTail && + previousEntries.length === segments.stable.length + (lastLayoutTail?.length ?? 0) && + lastLayoutMeasurementVersion === measurementVersion + + // Depend on the full list only when the append-only layout contract cannot + // safely replace it. This keeps token updates off displayMessages.concat(). + if (canReuseLayoutSegments) { + const stableLength = segments.stable.length + const activeTailEstimateKeys = new Set() + const nextTailEntries: MessageLayoutEntry[] = [] + let offset = stableLength > 0 ? previousEntries[stableLength - 1].bottom : 0 + + for (const message of segments.tail) { + const measurementKey = message.renderKey ?? message.id + activeTailEstimateKeys.add(measurementKey) + const cachedEstimate = estimatedHeightCache.get(measurementKey) + const estimated = canReuseEstimatedHeight(cachedEstimate, message, measurementKey) + ? cachedEstimate.estimatedHeight + : estimateHeight(message) + if (!canReuseEstimatedHeight(cachedEstimate, message, measurementKey)) { + estimatedHeightCache.set(measurementKey, { + message, + measurementKey, + updatedAt: message.updatedAt, + content: message.content, + estimatedHeight: estimated + }) + } + + const measured = measurements[measurementKey] + const entry: MessageLayoutEntry = { + id: message.id, + measurementKey, + orderSeq: message.orderSeq, + estimatedHeight: estimated, + measuredHeight: measured, + top: offset, + bottom: offset + (measured ?? estimated) + } + offset = entry.bottom + nextTailEntries.push(entry) + } + + // The fast path otherwise never visits removed tail rows, so their estimates + // would remain cached indefinitely. Stable keys are cached whenever the stable + // segment changes; compare only the previous/current tail key sets per token. + for (const key of lastLayoutTailMeasurementKeys) { + if (!activeTailEstimateKeys.has(key) && !lastLayoutStableMeasurementKeys.has(key)) { + estimatedHeightCache.delete(key) + } + } + + for (let index = stableLength; index < previousEntries.length; index += 1) { + const entry = previousEntries[index] + entryByMessageId.delete(entry.id) + entryByMeasurementKey.delete(entry.measurementKey) + } + // Reuse the stable prefix entries but return a fresh array: computeds + // compare by reference, so returning the mutated previous array would + // suppress every downstream totalHeight / window-range update. + const nextFastPathEntries = previousEntries.slice(0, stableLength) + for (const entry of nextTailEntries) { + nextFastPathEntries.push(entry) + entryByMessageId.set(entry.id, entry) + entryByMeasurementKey.set(entry.measurementKey, entry) + } + + lastLayoutEntries = nextFastPathEntries + lastLayoutStable = segments.stable + lastLayoutStableLength = segments.stable.length + lastLayoutTail = segments.tail + lastLayoutTailMeasurementKeys = activeTailEstimateKeys + lastLayoutMeasurementVersion = measurementVersion + return nextFastPathEntries + } + + // Only materialize the complete list when the append-only contract cannot be + // used. In the segment fast path, callers may keep this full list for search + // without paying its construction cost for token-level layout updates. + const messages = options.messages.value let offset = 0 - return options.messages.value.map((msg) => { + const activeEstimateKeys = new Set() + const nextEntryByMessageId = new Map() + const nextEntryByMeasurementKey = new Map() + const nextEntries = messages.map((msg) => { const measurementKey = msg.renderKey ?? msg.id - const measured = measuredHeights.value[measurementKey] - const estimated = estimateHeight(msg) + activeEstimateKeys.add(measurementKey) + const cachedEstimate = estimatedHeightCache.get(measurementKey) + const estimated = canReuseEstimatedHeight(cachedEstimate, msg, measurementKey) + ? cachedEstimate.estimatedHeight + : estimateHeight(msg) + if (!canReuseEstimatedHeight(cachedEstimate, msg, measurementKey)) { + estimatedHeightCache.set(measurementKey, { + message: msg, + measurementKey, + updatedAt: msg.updatedAt, + content: msg.content, + estimatedHeight: estimated + }) + } + + const measured = measurements[measurementKey] const height = measured ?? estimated const entry: MessageLayoutEntry = { id: msg.id, @@ -145,8 +295,36 @@ export function useMessageWindow(options: UseMessageWindowOptions) { bottom: offset + height } offset = entry.bottom + nextEntryByMessageId.set(entry.id, entry) + nextEntryByMeasurementKey.set(entry.measurementKey, entry) return entry }) + + for (const key of estimatedHeightCache.keys()) { + if (!activeEstimateKeys.has(key)) { + estimatedHeightCache.delete(key) + } + } + + entryByMessageId.clear() + entryByMeasurementKey.clear() + nextEntryByMessageId.forEach((entry, id) => entryByMessageId.set(id, entry)) + nextEntryByMeasurementKey.forEach((entry, key) => entryByMeasurementKey.set(key, entry)) + lastLayoutEntries = nextEntries + lastLayoutStable = segments?.stable + lastLayoutStableLength = segments?.stable.length ?? -1 + lastLayoutTail = segments?.tail + lastLayoutStableMeasurementKeys = new Set( + segments + ? nextEntries.slice(0, segments.stable.length).map((entry) => entry.measurementKey) + : [] + ) + lastLayoutTailMeasurementKeys = new Set( + segments ? nextEntries.slice(segments.stable.length).map((entry) => entry.measurementKey) : [] + ) + lastLayoutMeasurementVersion = measurementVersion + + return nextEntries }) const totalHeight = computed(() => entries.value[entries.value.length - 1]?.bottom ?? 0) @@ -154,26 +332,32 @@ export function useMessageWindow(options: UseMessageWindowOptions) { function getEntry(messageId: string): MessageLayoutEntry | undefined { // Ensure callers see the latest measurements even if a microtask flush is pending. flushMeasuredHeights() - return entries.value.find((e) => e.id === messageId || e.measurementKey === messageId) + void entries.value + return peekEntry(messageId) + } + + function peekEntry(messageId: string): MessageLayoutEntry | undefined { + return entryByMessageId.get(messageId) ?? entryByMeasurementKey.get(messageId) } function setMeasuredHeight(messageId: string, height: number): number { if (!Number.isFinite(height) || height <= 0) return 0 const rounded = Math.ceil(height) + // Read the last computed layout maps directly: flushing the pending measure + // batch here would recompute the full layout once per row, defeating the + // coalescing that scheduleMeasuredHeightsFlush provides. Rows only measure + // after they rendered from a computed layout, so the maps cover them. + const entry = peekEntry(messageId) + const measurementKey = entry?.measurementKey ?? messageId const map = measuredHeights.value - const prev = map[messageId] + const prev = map[measurementKey] if (prev === rounded) return 0 - // Use map baseline when present; otherwise estimate from current messages list - // without forcing a full entries recompute mid-batch. - let baseline = prev - if (baseline === undefined) { - const msg = options.messages.value.find( - (item) => item.id === messageId || item.renderKey === messageId - ) - baseline = msg ? estimateHeight(msg) : rounded - } + // Use map baseline when present; otherwise use the current layout entry. This + // avoids a second linear message search and reuses the cached estimate. + const baseline = prev ?? entry?.estimatedHeight ?? rounded const delta = rounded - baseline - map[messageId] = rounded + map[measurementKey] = rounded + measurementVersion += 1 scheduleMeasuredHeightsFlush() // Keep the map accurate but suppress tiny deltas so ChatPage does not // re-run bottom-follow / anchor-restore for sub-threshold noise. @@ -183,6 +367,7 @@ export function useMessageWindow(options: UseMessageWindowOptions) { function clearMeasurements() { measureFlushQueued = false + measurementVersion += 1 measuredHeights.value = {} } @@ -193,6 +378,7 @@ export function useMessageWindow(options: UseMessageWindowOptions) { function restoreMeasurements(snapshot: MessageMeasurementSnapshot): void { measureFlushQueued = false + measurementVersion += 1 const restored: Record = {} for (const [messageId, height] of Object.entries(snapshot)) { if (Number.isFinite(height) && height > 0) { diff --git a/src/renderer/src/features/chat-page/ChatPage.vue b/src/renderer/src/features/chat-page/ChatPage.vue index 6460bc8a04..0a848f6b25 100644 --- a/src/renderer/src/features/chat-page/ChatPage.vue +++ b/src/renderer/src/features/chat-page/ChatPage.vue @@ -61,16 +61,39 @@ {{ t('common.loading') }} + sessionStore.activeSession?.sessionKind const isGenerating = computed( () => sessionStore.activeSession?.status === 'working' || isCurrentSessionStreaming.value ) +const streamingMessageId = computed(() => + isCurrentSessionStreaming.value ? messageStore.currentStreamMessageId : null +) const RATE_LIMIT_STREAM_MESSAGE_PREFIX = '__rate_limit__:' const INITIAL_MESSAGE_RESTORE_COUNT = 100 const MESSAGE_WINDOWING_THRESHOLD = 160 @@ -449,7 +475,6 @@ let restoreMessageWindowMeasurements = (snapshot: MessageMeasurementSnapshot) => // Rebound after useChatSearch is created below (it needs the message window, which // is set up after the session-change watch that calls these on first run). let clearChatSearchStateRef = () => {} -let cancelScheduledChatSearchRefreshRef = () => {} let clearMessageActionsForSessionChange = () => {} let measurementSessionId = '' let handledCommittedSessionId: string | null = null @@ -626,14 +651,18 @@ function onScroll() { } } -async function loadOlderMessagesAtTop(): Promise { +async function retryOlderMessages(): Promise { + await loadOlderMessagesAtTop({ force: true }) +} + +async function loadOlderMessagesAtTop(options: { force?: boolean } = {}): Promise { if (chatScrollController.activeOperation.value?.reason === 'history-prepend') { return } if ( messageStore.isLoadingHistory || !messageStore.hasMoreHistory || - messageStore.messageIds.length < INITIAL_MESSAGE_RESTORE_COUNT + (!options.force && messageStore.messageIds.length < INITIAL_MESSAGE_RESTORE_COUNT) ) { return } @@ -642,10 +671,10 @@ async function loadOlderMessagesAtTop(): Promise { if (!el) { return } - if (el.scrollTop > TOP_HISTORY_THRESHOLD) { + if (!options.force && el.scrollTop > TOP_HISTORY_THRESHOLD) { return } - if (el.scrollHeight - el.clientHeight <= TOP_HISTORY_THRESHOLD) { + if (!options.force && el.scrollHeight - el.clientHeight <= TOP_HISTORY_THRESHOLD) { return } @@ -778,6 +807,7 @@ function cacheCurrentMessageMeasurements(): void { const { displayMessages, + layoutSegments, ephemeralRateLimitBlock, ephemeralRateLimitMessageId, hasFirstStreamingContent, @@ -796,8 +826,32 @@ const { isCurrentSessionStreaming }) +const resolveCaptureParentId = (messageId: string, parentId?: string): string | undefined => { + const messages = displayMessages.value + if (parentId) { + const parentMessage = messages.find((message) => message.id === parentId) + if (parentMessage?.role === 'user') { + return parentId + } + } + + const messageIndex = messages.findIndex((message) => message.id === messageId) + if (messageIndex <= 0) { + return undefined + } + + for (let index = messageIndex - 1; index >= 0; index -= 1) { + if (messages[index]?.role === 'user') { + return messages[index].id + } + } + + return undefined +} + const messageWindow = useMessageWindow({ - messages: displayMessages + messages: displayMessages, + layoutSegments }) clearMessageWindowMeasurements = messageWindow.clearMeasurements captureMessageWindowMeasurements = messageWindow.captureMeasurements @@ -828,6 +882,7 @@ const isListScrolling = listGestures.isListScrolling const virtualization = useMessageVirtualization({ viewport: scrollContainer, displayMessages, + layoutSegments, messageWindow, windowingThreshold: MESSAGE_WINDOWING_THRESHOLD, initialWindowCount: MESSAGE_INITIAL_WINDOW_COUNT, @@ -862,7 +917,7 @@ const { goToNextChatSearchMatch, goToPreviousChatSearchMatch, handleSearchKeydown, - cancelScheduledChatSearchRefresh + disposeChatSearch } = useChatSearch({ messageSearchRoot, displayMessages, @@ -872,7 +927,6 @@ const { waitForNextAnimationFrame }) clearChatSearchStateRef = clearChatSearchState -cancelScheduledChatSearchRefreshRef = cancelScheduledChatSearchRefresh // Load messages when sessionId changes, then scroll to bottom watch( @@ -1013,7 +1067,9 @@ const { isReadOnlySession, chatClient, loadMessagesForSession, - applyRestoredSessionSummary + applyRestoredSessionSummary, + currentRestoreRequestId, + canWriteSessionView }) const { @@ -1123,7 +1179,8 @@ const { clearPlanSnapshotForDeletedMessage, loadMessagesForSession, applyRestoredSessionSummary, - isCurrentSession: (sessionId) => props.sessionId === sessionId + currentRestoreRequestId, + canWriteSessionView }) clearMessageActionsForSessionChange = clearForSessionChange @@ -1206,7 +1263,7 @@ onUnmounted(() => { cleanupVoiceInput() cancelAllPlanSnapshotClearTimers() stopChatPageEventBridge() - clearChatSearchHighlights(messageSearchRoot.value) + disposeChatSearch() if (spotlightJumpTimer) { window.clearTimeout(spotlightJumpTimer) spotlightJumpTimer = null @@ -1220,7 +1277,6 @@ onUnmounted(() => { } listGestures.reset() virtualization.cancelPendingMeasureFlush() - cancelScheduledChatSearchRefresh() pendingInputStore.clear() }) diff --git a/src/renderer/src/features/chat-page/composables/useChatSearch.ts b/src/renderer/src/features/chat-page/composables/useChatSearch.ts index 69da4a9a47..72bf08b815 100644 --- a/src/renderer/src/features/chat-page/composables/useChatSearch.ts +++ b/src/renderer/src/features/chat-page/composables/useChatSearch.ts @@ -1,4 +1,5 @@ import { computed, nextTick, ref, watch, type ComputedRef, type Ref } from 'vue' +import { refDebounced } from '@vueuse/core' import { applyChatSearchHighlights, collectChatSearchResults, @@ -41,6 +42,10 @@ export function useChatSearch(options: UseChatSearchOptions) { const isChatSearchOpen = ref(false) const chatSearchQuery = ref('') const activeChatSearchIndex = ref(0) + // Keep the input untouched, but debounce and compare one canonical query everywhere else. + // This avoids a whitespace-only edit briefly mixing the old and new search state. + const canonicalChatSearchQuery = computed(() => chatSearchQuery.value.trim()) + const debouncedChatSearchQuery = refDebounced(canonicalChatSearchQuery, 150) const chatSearchBarRef = ref<{ focusInput: () => void selectInput: () => void @@ -48,23 +53,42 @@ export function useChatSearch(options: UseChatSearchOptions) { let chatSearchRefreshFrame: number | null = null let pendingChatSearchReveal = false + let chatSearchOperationId = 0 - const chatSearchResults = computed(() => - collectChatSearchResults(displayMessages.value, chatSearchQuery.value) + const isSearchQuerySettled = computed( + () => canonicalChatSearchQuery.value === debouncedChatSearchQuery.value ) + const chatSearchResults = computed(() => { + if (!isChatSearchOpen.value || !isSearchQuerySettled.value) { + return [] + } - async function refreshChatSearchHighlights(revealActive: boolean) { - if (!isChatSearchOpen.value) { + return collectChatSearchResults(displayMessages.value, debouncedChatSearchQuery.value) + }) + + const resolvedChatSearchQuery = () => debouncedChatSearchQuery.value + const isCurrentSearchOperation = (operationId: number, query: string) => + operationId === chatSearchOperationId && + isChatSearchOpen.value && + isSearchQuerySettled.value && + resolvedChatSearchQuery() === query + + async function refreshChatSearchHighlights( + revealActive: boolean, + operationId = chatSearchOperationId + ) { + if (!isChatSearchOpen.value || !isSearchQuerySettled.value) { return } + const query = resolvedChatSearchQuery() await nextTick() - if (!isChatSearchOpen.value) { + if (!isCurrentSearchOperation(operationId, query)) { return } const root = messageSearchRoot.value - applyChatSearchHighlights(root, chatSearchQuery.value) + applyChatSearchHighlights(root, query) if (chatSearchResults.value.length === 0) { activeChatSearchIndex.value = 0 @@ -75,21 +99,24 @@ export function useChatSearch(options: UseChatSearchOptions) { activeChatSearchIndex.value = nextIndex const activeResult = chatSearchResults.value[nextIndex] if (revealActive) { - await revealChatSearchResult(activeResult, 'auto') - } else { + await revealChatSearchResult(activeResult, 'auto', operationId, query) + } else if (isCurrentSearchOperation(operationId, query)) { setActiveChatSearchResult(root, activeResult, { scroll: false }) } } async function revealChatSearchResult( result: ChatSearchResult | undefined, - behavior: ScrollBehavior = 'auto' + behavior: ScrollBehavior = 'auto', + operationId = chatSearchOperationId, + query = resolvedChatSearchQuery() ) { - if (!result) return + if (!result || !isCurrentSearchOperation(operationId, query)) return await nextTick() + if (!isCurrentSearchOperation(operationId, query)) return const root = messageSearchRoot.value - applyChatSearchHighlights(root, chatSearchQuery.value) + applyChatSearchHighlights(root, query) if (!hasWindowEntry(result.messageId)) return const requestId = requestChatScroll('search-navigation', { @@ -99,8 +126,11 @@ export function useChatSearch(options: UseChatSearchOptions) { }) if (requestId === null) return await waitForNextAnimationFrame() + if (!isCurrentSearchOperation(operationId, query)) return await nextTick() - applyChatSearchHighlights(root, chatSearchQuery.value) + if (!isCurrentSearchOperation(operationId, query)) return + applyChatSearchHighlights(root, query) + if (!isCurrentSearchOperation(operationId, query)) return setActiveChatSearchResult(root, result, { behavior, scroll: false }) } @@ -114,6 +144,12 @@ export function useChatSearch(options: UseChatSearchOptions) { chatSearchRefreshFrame = null } + function disposeChatSearch() { + chatSearchOperationId += 1 + cancelScheduledChatSearchRefresh() + clearChatSearchHighlights(messageSearchRoot.value) + } + function scheduleChatSearchHighlights(revealActive = false) { if (!isChatSearchOpen.value) { return @@ -125,7 +161,7 @@ export function useChatSearch(options: UseChatSearchOptions) { chatSearchRefreshFrame = null const shouldReveal = pendingChatSearchReveal pendingChatSearchReveal = false - void refreshChatSearchHighlights(shouldReveal) + void refreshChatSearchHighlights(shouldReveal, chatSearchOperationId) }) } @@ -136,6 +172,7 @@ export function useChatSearch(options: UseChatSearchOptions) { } function clearChatSearchState() { + chatSearchOperationId += 1 cancelScheduledChatSearchRefresh() clearChatSearchHighlights(messageSearchRoot.value) chatSearchQuery.value = '' @@ -146,7 +183,9 @@ export function useChatSearch(options: UseChatSearchOptions) { function openChatSearch() { isChatSearchOpen.value = true focusChatSearchInput() - void refreshChatSearchHighlights(true) + if (isSearchQuerySettled.value) { + void refreshChatSearchHighlights(true) + } } function closeChatSearch() { @@ -164,6 +203,7 @@ export function useChatSearch(options: UseChatSearchOptions) { chatSearchResults.value.length activeChatSearchIndex.value = normalizedIndex + chatSearchOperationId += 1 void revealChatSearchResult(chatSearchResults.value[normalizedIndex], behavior) } @@ -218,15 +258,30 @@ export function useChatSearch(options: UseChatSearchOptions) { return false } - watch(chatSearchQuery, () => { - activeChatSearchIndex.value = 0 + watch( + canonicalChatSearchQuery, + (query) => { + chatSearchOperationId += 1 + activeChatSearchIndex.value = 0 + if (query !== debouncedChatSearchQuery.value) { + clearChatSearchHighlights(messageSearchRoot.value) + } + }, + { flush: 'sync' } + ) + + watch(debouncedChatSearchQuery, () => { + if (!isChatSearchOpen.value || !isSearchQuerySettled.value) { + return + } + scheduleChatSearchHighlights(true) }) watch( [visibleDisplayMessages, chatSearchResults], () => { - if (!isChatSearchOpen.value) { + if (!isChatSearchOpen.value || !isSearchQuerySettled.value) { return } @@ -247,6 +302,7 @@ export function useChatSearch(options: UseChatSearchOptions) { goToNextChatSearchMatch, goToPreviousChatSearchMatch, handleSearchKeydown, - cancelScheduledChatSearchRefresh + cancelScheduledChatSearchRefresh, + disposeChatSearch } } diff --git a/src/renderer/src/features/chat-page/composables/useDisplayMessages.ts b/src/renderer/src/features/chat-page/composables/useDisplayMessages.ts index d0506a875b..ed31de4ad1 100644 --- a/src/renderer/src/features/chat-page/composables/useDisplayMessages.ts +++ b/src/renderer/src/features/chat-page/composables/useDisplayMessages.ts @@ -7,6 +7,7 @@ import type { DisplayMessage, DisplayMessageUsage } from '@/features/chat-page/model/displayMessage' +import type { MessageLayoutSegments } from '@/composables/message/useMessageWindow' import { filterRenderableAssistantBlocks, hasRenderableAssistantBlocks @@ -442,6 +443,21 @@ export function useDisplayMessages(options: UseDisplayMessagesOptions) { return msgs }) + const layoutSegments = computed(() => { + const stable = stableDisplayMessages.value + const tail = streamingDisplayTail.value + const streamId = messageStore.currentStreamMessageId + + // Inline streams are reinserted at their persisted order and therefore cannot + // promise an append-only tail. Any stable list replacement also invalidates the + // contract by reference, forcing useMessageWindow to rebuild conservatively. + if (streamId && hasInlineStreamingTarget.value && tail[0]?.id === streamId) { + return null + } + + return { stable, tail } + }) + const displayMessages = computed(() => { const stable = stableDisplayMessages.value const tail = streamingDisplayTail.value @@ -482,6 +498,7 @@ export function useDisplayMessages(options: UseDisplayMessagesOptions) { return { displayMessages, + layoutSegments, ephemeralRateLimitBlock, ephemeralRateLimitMessageId, hasFirstStreamingContent, diff --git a/src/renderer/src/features/chat-page/composables/useMessageActions.ts b/src/renderer/src/features/chat-page/composables/useMessageActions.ts index c3d075ea39..c0dcd1bf46 100644 --- a/src/renderer/src/features/chat-page/composables/useMessageActions.ts +++ b/src/renderer/src/features/chat-page/composables/useMessageActions.ts @@ -23,7 +23,8 @@ type UseMessageActionsOptions = { clearPlanSnapshotForDeletedMessage: (sessionId: string, messageId: string) => void loadMessagesForSession: (sessionId: string) => Promise applyRestoredSessionSummary: (session: unknown) => void - isCurrentSession: (sessionId: string) => boolean + currentRestoreRequestId: () => number + canWriteSessionView: (sessionId: string, requestId: number) => boolean } /** @@ -35,8 +36,10 @@ export function useMessageActions(options: UseMessageActionsOptions) { const pendingDeleteMessageId = ref(null) const showDeleteMessageDialog = computed(() => Boolean(pendingDeleteMessageId.value)) - async function refreshAfterRetryFailure(sessionId: string) { - options.applyRestoredSessionSummary(await options.loadMessagesForSession(sessionId)) + async function refreshAfterRetryFailure(sessionId: string, requestId: number) { + const restoredSession = await options.loadMessagesForSession(sessionId) + if (!options.canWriteSessionView(sessionId, requestId)) return + options.applyRestoredSessionSummary(restoredSession) } async function retryMessage( @@ -48,13 +51,15 @@ export function useMessageActions(options: UseMessageActionsOptions) { if (options.isReadOnlySession.value || !messageId) return if (blocksInteraction && options.hasBlockingInteraction()) return + const requestId = options.currentRestoreRequestId() try { options.beginPlanTurn(sessionId) options.messageStore.clearStreamingState() await options.sessionClient.retryMessage(sessionId, messageId) } catch (error) { console.error(errorMessage, error) - await refreshAfterRetryFailure(sessionId) + if (!options.canWriteSessionView(sessionId, requestId)) return + await refreshAfterRetryFailure(sessionId, requestId) } } @@ -72,14 +77,18 @@ export function useMessageActions(options: UseMessageActionsOptions) { if (!messageId || options.isReadOnlySession.value) return const sessionId = options.sessionId() + const requestId = options.currentRestoreRequestId() pendingDeleteMessageId.value = null try { options.messageStore.clearStreamingState() await options.sessionClient.deleteMessage(sessionId, messageId) + // Plan snapshots are stored per session id, not per view; clean up even + // when the user switched sessions while the confirm dialog was open. options.clearPlanSnapshotForDeletedMessage(sessionId, messageId) - if (options.isCurrentSession(sessionId)) { - options.applyRestoredSessionSummary(await options.loadMessagesForSession(sessionId)) - } + if (!options.canWriteSessionView(sessionId, requestId)) return + const restoredSession = await options.loadMessagesForSession(sessionId) + if (!options.canWriteSessionView(sessionId, requestId)) return + options.applyRestoredSessionSummary(restoredSession) } catch (error) { console.error('[ChatPage] delete message failed:', error) } diff --git a/src/renderer/src/features/chat-page/composables/useMessageVirtualization.ts b/src/renderer/src/features/chat-page/composables/useMessageVirtualization.ts index ed2b8c141e..ae76d43625 100644 --- a/src/renderer/src/features/chat-page/composables/useMessageVirtualization.ts +++ b/src/renderer/src/features/chat-page/composables/useMessageVirtualization.ts @@ -1,5 +1,8 @@ import { computed, ref, type ComputedRef, type Ref } from 'vue' -import type { useMessageWindow } from '@/composables/message/useMessageWindow' +import type { + MessageLayoutSegments, + useMessageWindow +} from '@/composables/message/useMessageWindow' import type { DisplayMessage } from '@/features/chat-page/model/displayMessage' type MessageWindow = ReturnType @@ -12,6 +15,11 @@ type LogicalViewportAnchor = { type UseMessageVirtualizationOptions = { viewport: Ref displayMessages: ComputedRef + /** + * Append-only layout contract. This is only used to build the rendered range; + * displayMessages remains the complete list for search and external consumers. + */ + layoutSegments?: ComputedRef messageWindow: MessageWindow windowingThreshold: number initialWindowCount: number @@ -117,9 +125,36 @@ export function useMessageVirtualization(options: UseMessageVirtualizationOption } }) - const visibleDisplayMessages = computed(() => - displayMessages.value.slice(messageWindowRange.value.start, messageWindowRange.value.end) - ) + const visibleDisplayMessages = computed(() => { + const { start, end } = messageWindowRange.value + const segments = options.layoutSegments?.value + + // The segment contract guarantees a stable prefix plus an append-only tail. + // Use it only when it exactly matches the window layout; inline streams return + // null and retain the conservative complete-list slice below. + if ( + segments !== null && + segments !== undefined && + messageWindow.entries.value.length === segments.stable.length + segments.tail.length + ) { + if (end <= segments.stable.length) { + return segments.stable.slice(start, end) as DisplayMessage[] + } + if (start >= segments.stable.length) { + return segments.tail.slice( + start - segments.stable.length, + end - segments.stable.length + ) as DisplayMessage[] + } + + return [ + ...segments.stable.slice(start), + ...segments.tail.slice(0, end - segments.stable.length) + ] as DisplayMessage[] + } + + return displayMessages.value.slice(start, end) + }) const messageWindowBeforeHeight = computed(() => messageWindowRange.value.before) const messageWindowAfterHeight = computed(() => messageWindowRange.value.after) diff --git a/src/renderer/src/features/chat-page/composables/useToolInteraction.ts b/src/renderer/src/features/chat-page/composables/useToolInteraction.ts index 9d8cd5fae0..4e297cd131 100644 --- a/src/renderer/src/features/chat-page/composables/useToolInteraction.ts +++ b/src/renderer/src/features/chat-page/composables/useToolInteraction.ts @@ -43,6 +43,8 @@ type UseToolInteractionOptions = { chatClient: ChatClientLike loadMessagesForSession: (sessionId: string) => Promise applyRestoredSessionSummary: (session: unknown) => void + currentRestoreRequestId: () => number + canWriteSessionView: (sessionId: string, requestId: number) => boolean } function parseSubagentProgress(value: unknown): SubagentProgressPayload | null { @@ -144,6 +146,8 @@ export function useToolInteraction(options: UseToolInteractionOptions) { return } + const sessionId = options.sessionId() + const requestId = options.currentRestoreRequestId() isHandlingInteraction.value = true try { const result = await options.chatClient.respondToolInteraction({ @@ -152,7 +156,10 @@ export function useToolInteraction(options: UseToolInteractionOptions) { toolCallId: interaction.toolCallId, response }) - options.applyRestoredSessionSummary(await options.loadMessagesForSession(options.sessionId())) + if (!options.canWriteSessionView(sessionId, requestId)) return + const restoredSession = await options.loadMessagesForSession(sessionId) + if (!options.canWriteSessionView(sessionId, requestId)) return + options.applyRestoredSessionSummary(restoredSession) if (result.handledInline) { return } diff --git a/src/renderer/src/foundation/appearance/documentAppearance.ts b/src/renderer/src/foundation/appearance/documentAppearance.ts new file mode 100644 index 0000000000..3c9a5fdbda --- /dev/null +++ b/src/renderer/src/foundation/appearance/documentAppearance.ts @@ -0,0 +1,95 @@ +export type ResolvedTheme = 'dark' | 'light' +export type DocumentDirection = 'auto' | 'ltr' | 'rtl' + +type ApplyDocumentAppearanceOptions = { + theme?: ResolvedTheme + fontSizeClass?: string + language?: string + direction?: DocumentDirection + disableThemeTransition?: boolean + themeDataset?: boolean +} + +const FONT_SIZE_CLASSES = ['text-xs', 'text-sm', 'text-base', 'text-lg', 'text-xl', 'text-2xl'] +const RTL_LANGUAGES = new Set(['fa-IR', 'he-IL']) + +export const resolveDocumentDirection = (language: string): DocumentDirection => + RTL_LANGUAGES.has(language) ? 'rtl' : 'auto' + +const getDocumentTargets = (): [HTMLElement, HTMLElement] | null => { + if (typeof document === 'undefined' || !document.body) { + return null + } + + return [document.documentElement, document.body] +} + +/** + * Projects already-resolved renderer appearance state onto the current document. + * Apps retain ownership of settings, stores, and IPC listeners; this module only + * applies values to DOM state and deliberately has no Vue or renderer dependencies. + */ +export const applyDocumentAppearance = ({ + theme, + fontSizeClass, + language, + direction, + disableThemeTransition = false, + themeDataset = false +}: ApplyDocumentAppearanceOptions): void => { + const targets = getDocumentTargets() + if (!targets) { + return + } + + const [root] = targets + const currentTheme = root.classList.contains('dark') + ? 'dark' + : root.classList.contains('light') + ? 'light' + : null + const shouldDisableThemeTransition = + disableThemeTransition && Boolean(theme) && currentTheme !== theme + + if (shouldDisableThemeTransition) { + root.classList.add('dc-theme-switching') + } + + if (theme) { + for (const target of targets) { + target.classList.remove('light', 'dark', 'system') + target.classList.add(theme) + } + + if (themeDataset) { + root.dataset.theme = theme + } + } + + if (fontSizeClass) { + for (const target of targets) { + target.classList.remove(...FONT_SIZE_CLASSES) + target.classList.add(fontSizeClass) + } + } + + if (language) { + root.lang = language + } + + if (direction) { + root.dir = direction + } + + if (shouldDisableThemeTransition) { + if (typeof requestAnimationFrame === 'undefined') { + root.classList.remove('dc-theme-switching') + return + } + + void root.offsetWidth + requestAnimationFrame(() => { + root.classList.remove('dc-theme-switching') + }) + } +} diff --git a/src/renderer/src/lib/chatSearch.ts b/src/renderer/src/lib/chatSearch.ts index aacc981208..fef6b2f58c 100644 --- a/src/renderer/src/lib/chatSearch.ts +++ b/src/renderer/src/lib/chatSearch.ts @@ -1,5 +1,15 @@ const HIGHLIGHT_SELECTOR = '[data-chat-search-match]' const ACTIVE_HIGHLIGHT_SELECTOR = '[data-chat-search-active]' +const HIGHLIGHTED_QUERY_ATTRIBUTE = 'data-chat-search-highlighted-query' +const MESSAGE_ROW_SELECTOR = '[data-message-id]' +type HighlightObserverState = { + observer: MutationObserver + frame: number | null + rowsToRefresh: Set + isApplyingHighlights: boolean +} + +const highlightedRowObservers = new WeakMap() export type ChatSearchMatch = HTMLElement export type ChatSearchResult = { @@ -75,6 +85,192 @@ const collectSearchableTextNodes = (root: ParentNode): Text[] => { return nodes } +const getRowsToHighlight = ( + root: ParentNode, + query: string, + isSameQuery: boolean +): ParentNode[] => { + if (!isSameQuery) { + return [root] + } + + if (!(root instanceof Element || root instanceof Document || root instanceof DocumentFragment)) { + return [] + } + + const rows = Array.from(root.querySelectorAll(MESSAGE_ROW_SELECTOR)) + if (rows.length === 0) { + return [root] + } + + return rows.filter((row) => row.getAttribute(HIGHLIGHTED_QUERY_ATTRIBUTE) !== query) +} + +const markRowsHighlighted = (roots: ParentNode[], query: string): void => { + roots.forEach((root) => { + if (root instanceof HTMLElement && root.matches(MESSAGE_ROW_SELECTOR)) { + root.setAttribute(HIGHLIGHTED_QUERY_ATTRIBUTE, query) + return + } + + if ( + !(root instanceof Element || root instanceof Document || root instanceof DocumentFragment) + ) { + return + } + + root.querySelectorAll(MESSAGE_ROW_SELECTOR).forEach((row) => { + row.setAttribute(HIGHLIGHTED_QUERY_ATTRIBUTE, query) + }) + }) +} + +const clearHighlightedRowMarkers = (root: ParentNode): void => { + if (!(root instanceof Element || root instanceof Document || root instanceof DocumentFragment)) { + return + } + + root.querySelectorAll(`[${HIGHLIGHTED_QUERY_ATTRIBUTE}]`).forEach((row) => { + row.removeAttribute(HIGHLIGHTED_QUERY_ATTRIBUTE) + }) +} + +const stopHighlightObserver = (root: ParentNode): void => { + const state = highlightedRowObservers.get(root) + if (!state) return + + state.observer.disconnect() + if (state.frame !== null) { + window.cancelAnimationFrame(state.frame) + } + highlightedRowObservers.delete(root) +} + +const getMessageRow = (node: Node): HTMLElement | null => { + const element = node instanceof HTMLElement ? node : node.parentElement + return element?.closest(MESSAGE_ROW_SELECTOR) ?? null +} + +const clearHighlightsInRoot = (root: ParentNode): void => { + root.querySelectorAll(HIGHLIGHT_SELECTOR).forEach((highlight) => { + const parent = highlight.parentNode + if (!parent) { + return + } + + parent.replaceChild(document.createTextNode(highlight.textContent ?? ''), highlight) + parent.normalize() + }) +} + +// A same-query re-apply tears the observer down before its pending refresh runs, +// yet those rows still carry the highlighted-query marker and would be skipped +// by every future same-query scan. Strip their marks and markers first so the +// caller's rescan rebuilds them. +const drainHighlightObserver = (root: ParentNode): void => { + const state = highlightedRowObservers.get(root) + if (!state) return + + const pendingRows = new Set(state.rowsToRefresh) + state.observer.takeRecords().forEach((record) => { + const row = getMessageRow(record.target) + if (row) { + pendingRows.add(row) + } + }) + stopHighlightObserver(root) + pendingRows.forEach((row) => { + clearHighlightsInRoot(row) + row.removeAttribute(HIGHLIGHTED_QUERY_ATTRIBUTE) + }) +} + +const highlightRow = (row: HTMLElement, query: string): void => { + clearHighlightsInRoot(row) + row.removeAttribute(HIGHLIGHTED_QUERY_ATTRIBUTE) + + collectSearchableTextNodes(row).forEach((node) => { + const fragment = buildHighlightedFragment(node.nodeValue ?? '', query) + if (fragment && node.parentNode) { + node.parentNode.replaceChild(fragment, node) + } + }) + + row.setAttribute(HIGHLIGHTED_QUERY_ATTRIBUTE, query) +} + +const observeHighlightedRows = (root: ParentNode, query: string): void => { + if (!(root instanceof HTMLElement) || typeof MutationObserver === 'undefined') { + return + } + + stopHighlightObserver(root) + let state: HighlightObserverState + const scheduleRefresh = () => { + if (state.frame !== null) return + + state.frame = window.requestAnimationFrame(() => { + state.frame = null + if (state.isApplyingHighlights || getAppliedSearchQuery(root) !== query) { + state.rowsToRefresh.clear() + return + } + + const rows = Array.from(state.rowsToRefresh) + state.rowsToRefresh.clear() + state.isApplyingHighlights = true + try { + rows.forEach((row) => highlightRow(row, query)) + // MutationObserver callbacks run after this task. Drain mutations made by our + // own mark replacement before dropping the guard so they cannot enqueue work. + observer.takeRecords() + } finally { + state.isApplyingHighlights = false + } + }) + } + const observer = new MutationObserver((records) => { + if (state.isApplyingHighlights || getAppliedSearchQuery(root) !== query) { + return + } + + records.forEach((record) => { + const targetRow = getMessageRow(record.target) + if (targetRow) { + state.rowsToRefresh.add(targetRow) + } + + record.addedNodes.forEach((node) => { + if (!(node instanceof HTMLElement)) return + if (node.matches(MESSAGE_ROW_SELECTOR)) { + if (node.getAttribute(HIGHLIGHTED_QUERY_ATTRIBUTE) !== query) { + state.rowsToRefresh.add(node) + } + return + } + node.querySelectorAll(MESSAGE_ROW_SELECTOR).forEach((row) => { + if (row.getAttribute(HIGHLIGHTED_QUERY_ATTRIBUTE) !== query) { + state.rowsToRefresh.add(row) + } + }) + }) + }) + + if (state.rowsToRefresh.size > 0) { + scheduleRefresh() + } + }) + + state = { + observer, + frame: null, + rowsToRefresh: new Set(), + isApplyingHighlights: false + } + observer.observe(root, { childList: true, characterData: true, subtree: true }) + highlightedRowObservers.set(root, state) +} + const buildHighlightedFragment = (value: string, query: string): DocumentFragment | null => { const fragment = document.createDocumentFragment() const lowerValue = value.toLowerCase() @@ -157,6 +353,8 @@ export const clearChatSearchHighlights = (root: ParentNode | null | undefined): highlight.classList.remove('chat-search-highlight--active') }) + clearHighlightedRowMarkers(root) + stopHighlightObserver(root) setAppliedSearchQuery(root, null) } @@ -174,25 +372,34 @@ export const applyChatSearchHighlights = ( return [] } - // Same query: keep existing marks and only highlight newly mounted text nodes. - // Full clear+rebuild on every virtual-window change is the main flicker source. + // Same query: retain existing marks and walk only virtual-list rows that were + // mounted since their last highlight pass. This avoids scanning the entire + // visible message tree on every window shift. const appliedQuery = getAppliedSearchQuery(root) - if (appliedQuery !== null && appliedQuery !== normalizedQuery) { + const isSameQuery = appliedQuery === normalizedQuery + if (appliedQuery !== null && !isSameQuery) { clearChatSearchHighlights(root) + } else if (isSameQuery) { + drainHighlightObserver(root) } - const searchableNodes = collectSearchableTextNodes(root) - searchableNodes.forEach((node) => { - const value = node.nodeValue ?? '' - const fragment = buildHighlightedFragment(value, normalizedQuery) - if (!fragment || !node.parentNode) { - return - } + const rootsToHighlight = getRowsToHighlight(root, normalizedQuery, isSameQuery) + rootsToHighlight.forEach((highlightRoot) => { + const searchableNodes = collectSearchableTextNodes(highlightRoot) + searchableNodes.forEach((node) => { + const value = node.nodeValue ?? '' + const fragment = buildHighlightedFragment(value, normalizedQuery) + if (!fragment || !node.parentNode) { + return + } - node.parentNode.replaceChild(fragment, node) + node.parentNode.replaceChild(fragment, node) + }) }) + markRowsHighlighted(rootsToHighlight, normalizedQuery) setAppliedSearchQuery(root, normalizedQuery) + observeHighlightedRows(root, normalizedQuery) return Array.from(root.querySelectorAll(HIGHLIGHT_SELECTOR)) } @@ -210,29 +417,67 @@ const countOccurrences = (value: string, query: string): number => { return count } -const collectUnknownText = (value: unknown, output: string[]): void => { - if (typeof value === 'string') { - output.push(value) +// Visible label parity with MessageItemUser's getVisibleMentionLabel. +const getMentionBlockLabel = (block: Record): string => { + const id = typeof block.id === 'string' ? block.id : '' + const text = typeof block.content === 'string' ? block.content : '' + if (block.category === 'prompts') return id || text + if (block.category === 'context') return id || 'context' + return text +} + +const appendDisplayContentText = (content: unknown, output: string[]): void => { + if (!content || typeof content !== 'object') return + + if (Array.isArray(content)) { + content.forEach((block) => { + if (!block || typeof block !== 'object') return + const record = block as Record + const extra = record.extra as Record | undefined + if (record.type === 'plan' || extra?.internalTool === true) { + return + } + if (typeof record.content === 'string') { + output.push(record.content) + } + + // Tool details are part of MessageBlockToolCall's rendered content. Keep these + // searchable while deliberately ignoring persistence-only metadata on the block. + if (record.type === 'tool_call' && record.tool_call && typeof record.tool_call === 'object') { + const toolCall = record.tool_call as Record + for (const key of ['name', 'params', 'response', 'server_name', 'server_description']) { + if (typeof toolCall[key] === 'string') { + output.push(toolCall[key]) + } + } + } + }) return } - if (!value || typeof value !== 'object') return - if (Array.isArray(value)) { - value.forEach((item) => collectUnknownText(item, output)) + + // Mirror MessageItemUser's rendering: rich content blocks replace the raw + // text, so counting both would double every match and desync matchIndex from + // the DOM marks inside the row. + const userContent = content as Record + if (Array.isArray(userContent.content) && userContent.content.length > 0) { + userContent.content.forEach((block) => { + if (!block || typeof block !== 'object') return + const record = block as Record + if (record.type === 'mention') { + const label = getMentionBlockLabel(record) + if (label) { + output.push(label) + } + return + } + if (typeof record.content === 'string') { + output.push(record.content) + } + }) return } - - const record = value as Record - for (const key of [ - 'text', - 'content', - 'name', - 'params', - 'response', - 'server_name', - 'server_description', - 'tool_call' - ]) { - collectUnknownText(record[key], output) + if (typeof userContent.text === 'string') { + output.push(userContent.text) } } @@ -246,7 +491,7 @@ export const collectChatSearchResults = ( const results: ChatSearchResult[] = [] for (const message of messages) { const chunks: string[] = [] - collectUnknownText(message.content, chunks) + appendDisplayContentText(message.content, chunks) let matchIndex = 0 for (const chunk of chunks) { const count = countOccurrences(chunk, normalizedQuery) diff --git a/src/renderer/src/pages/NewThreadPage.vue b/src/renderer/src/pages/NewThreadPage.vue index 960932497d..bf88827238 100644 --- a/src/renderer/src/pages/NewThreadPage.vue +++ b/src/renderer/src/pages/NewThreadPage.vue @@ -105,7 +105,7 @@ :session-id="acpDraftSessionId" :workspace-path="projectStore.selectedProject?.path ?? null" :is-acp-session="isAcpSelectedAgent" - :submit-disabled="isAcpWorkdirUnavailable" + :submit-disabled="isAcpWorkdirUnavailable || isSubmitting" @update:files="onFilesChange" @pending-skills-change="onPendingSkillsChange" @command-submit="onCommandSubmit" @@ -117,7 +117,7 @@ :show-voice-input="isVoiceInputEnabled" :is-voice-input-listening="isVoiceInputListening" :is-voice-input-transcribing="isVoiceInputTranscribing" - :send-disabled="isAcpWorkdirUnavailable || !message.trim()" + :send-disabled="isAcpWorkdirUnavailable || isSubmitting || !message.trim()" @attach="onAttach" @voice-input="onToggleVoiceInput" @send="onSubmit" @@ -231,6 +231,7 @@ const modelGuideTargetRef = ref(null) const firstChatGuideHostRef = ref(null) const firstChatGuideTargetRef = ref(null) const isVoiceInputEnabled = ref(false) +const isSubmitting = ref(false) const chatInputRef = ref<{ triggerAttach: () => void insertRecognizedText?: (text: string) => void @@ -773,55 +774,63 @@ const resolveStartModelSelection = ( : null } +const isCurrentStartDeeplink = (token: number): boolean => + draftStore.pendingStartDeeplink?.token === token + const applyStartDeeplink = async (payload: StartDeeplinkPayload) => { const draftDefaultsTask = currentDraftDefaultsTask if (draftDefaultsTask) { await draftDefaultsTask + if (!isCurrentStartDeeplink(payload.token)) return } await nextTick() + if (!isCurrentStartDeeplink(payload.token)) return + message.value = buildStartMessage(payload) draftStore.systemPrompt = payload.systemPrompt const modelsReady = await ensureEnabledModelsReady() + if (!isCurrentStartDeeplink(payload.token)) return + const matchedModel = modelsReady ? resolveStartModelSelection(payload.modelId) : null if (matchedModel) { draftStore.providerId = matchedModel.providerId draftStore.modelId = matchedModel.modelId } - draftStore.clearPendingStartDeeplink() + if (isCurrentStartDeeplink(payload.token)) { + draftStore.clearPendingStartDeeplink() + } } -async function onSubmit() { - if (isAcpWorkdirUnavailable.value) return - - const text = message.value.trim() - if (!text) return - if (shouldIgnoreManualCompactionDraft(text)) return - const files = (await prepareFilesForCurrentModel([...attachedFiles.value])).map((f) => toRaw(f)) +async function submitDraft(text: string, clearMessage: boolean) { + if (isSubmitting.value || isAcpWorkdirUnavailable.value) return + if (!text || shouldIgnoreManualCompactionDraft(text)) return + isSubmitting.value = true try { + const files = (await prepareFilesForCurrentModel([...attachedFiles.value])).map((file) => + toRaw(file) + ) await submitText(text, files) - message.value = '' + if (clearMessage) { + message.value = '' + } attachedFiles.value = [] - } catch (e) { - console.error('[NewThreadPage] submit failed:', e) + } catch (error) { + console.error('[NewThreadPage] submit failed:', error) + } finally { + isSubmitting.value = false } } +async function onSubmit() { + await submitDraft(message.value.trim(), true) +} + async function onCommandSubmit(command: string) { - if (isAcpWorkdirUnavailable.value) return - const text = command.trim() - if (!text) return - if (shouldIgnoreManualCompactionDraft(text)) return - const files = (await prepareFilesForCurrentModel([...attachedFiles.value])).map((f) => toRaw(f)) - try { - await submitText(text, files) - attachedFiles.value = [] - } catch (e) { - console.error('[NewThreadPage] submit failed:', e) - } + await submitDraft(command.trim(), false) } function shouldIgnoreManualCompactionDraft(text: string): boolean { diff --git a/src/renderer/src/router/index.ts b/src/renderer/src/router/index.ts index e4108b129a..de82d6f024 100644 --- a/src/renderer/src/router/index.ts +++ b/src/renderer/src/router/index.ts @@ -10,7 +10,7 @@ const router = createRouter({ { path: '/chat', name: 'chat', - component: () => import('@/views/ChatTabView.vue'), + component: () => import('@/apps/chat-main/ChatTabView.vue'), meta: { titleKey: 'routes.chat', icon: 'lucide:message-square' diff --git a/src/renderer/src/stores/language.ts b/src/renderer/src/stores/language.ts index 1db496e318..5040a068f1 100644 --- a/src/renderer/src/stores/language.ts +++ b/src/renderer/src/stores/language.ts @@ -1,11 +1,14 @@ import { defineStore } from 'pinia' -import { ref, onMounted } from 'vue' +import { ref, onMounted, onScopeDispose } from 'vue' import { useI18n } from 'vue-i18n' import { createConfigClient } from '../../api/ConfigClient' const RTL_LIST = ['fa-IR', 'he-IL'] let languageListenerRegistered = false +let languageInitialization: Promise | null = null +let languageStateRevision = 0 +let removeLanguageListener: (() => void) | null = null export const useLanguageStore = defineStore('language', () => { const { locale } = useI18n({ useScope: 'global' }) const language = ref('system') @@ -13,25 +16,40 @@ export const useLanguageStore = defineStore('language', () => { const dir = ref('auto' as 'auto' | 'rtl' | 'ltr') // 初始化设置 const initLanguage = async () => { - try { - const languageState = await configClient.getLanguageState() - // 获取语言 - language.value = languageState.requestedLanguage || 'system' - // 设置语言 - locale.value = languageState.locale - dir.value = RTL_LIST.indexOf(locale.value) >= 0 ? 'rtl' : 'auto' - // 监听语言变更事件 - if (!languageListenerRegistered) { - languageListenerRegistered = true - configClient.onLanguageChanged((payload) => { - language.value = payload.requestedLanguage - locale.value = payload.locale - dir.value = payload.direction === 'rtl' ? 'rtl' : 'auto' - }) - } - } catch (error) { - console.error('初始化语言失败:', error) + if (languageInitialization) { + return languageInitialization } + + const initialization = (async () => { + try { + // Register before reading the initial snapshot so an IPC event cannot be lost. + if (!languageListenerRegistered) { + languageListenerRegistered = true + removeLanguageListener = configClient.onLanguageChanged((payload) => { + languageStateRevision += 1 + language.value = payload.requestedLanguage + locale.value = payload.locale + dir.value = payload.direction + }) + } + + const snapshotRevision = languageStateRevision + const languageState = await configClient.getLanguageState() + if (snapshotRevision !== languageStateRevision) { + return + } + + language.value = languageState.requestedLanguage || 'system' + locale.value = languageState.locale + dir.value = RTL_LIST.includes(locale.value) ? 'rtl' : 'auto' + } catch (error) { + languageInitialization = null + console.error('初始化语言失败:', error) + } + })() + + languageInitialization = initialization + return initialization } // 更新语言 @@ -45,6 +63,14 @@ export const useLanguageStore = defineStore('language', () => { await initLanguage() }) + onScopeDispose(() => { + removeLanguageListener?.() + removeLanguageListener = null + languageListenerRegistered = false + languageStateRevision += 1 + languageInitialization = null + }) + return { language, updateLanguage, diff --git a/src/renderer/src/stores/theme.ts b/src/renderer/src/stores/theme.ts index a32aad29f4..f88aaa3ff8 100644 --- a/src/renderer/src/stores/theme.ts +++ b/src/renderer/src/stores/theme.ts @@ -1,6 +1,6 @@ import { useDark, useToggle } from '@vueuse/core' import { defineStore } from 'pinia' -import { ref } from 'vue' +import { onScopeDispose, ref } from 'vue' import { createConfigClient } from '../../api/ConfigClient' export type ThemeMode = 'dark' | 'light' | 'system' @@ -9,42 +9,27 @@ export const useThemeStore = defineStore('theme', () => { const isDark = useDark() const toggleDark = useToggle(isDark) const configClient = createConfigClient() - let listenersRegistered = false - - // 存储当前主题模式 const themeMode = ref('system') + let listenersRegistered = false + let stateRevision = 0 + let themeInitialization: Promise | null = null + let removeThemeListeners: (() => void) | null = null - // 初始化主题 - const initTheme = async () => { - const currentTheme = (await configClient.getTheme()) as ThemeMode - themeMode.value = currentTheme - - // 获取当前实际的深色模式状态 - const isDarkMode = await configClient.getCurrentThemeIsDark() - console.log('initTheme - theme:', currentTheme, 'isDark:', isDarkMode) - toggleDark(isDarkMode) - setupThemeListeners() + const applyThemeState = (theme: ThemeMode, dark: boolean) => { + themeMode.value = theme + toggleDark(dark) } - initTheme() - const handleSystemThemeChange = (payload: { isDark: boolean }) => { - const isDarkMode = payload.isDark - console.log('handleSystemThemeChange', isDarkMode) - // 只有在系统模式下才跟随系统主题变化 + stateRevision += 1 if (themeMode.value === 'system') { - toggleDark(isDarkMode) + toggleDark(payload.isDark) } } - const handleUserThemeChange = (payload: { theme: ThemeMode }) => { - const theme = payload.theme - if (themeMode.value !== theme) { - configClient.getCurrentThemeIsDark().then((isDark) => { - console.log('handleUserThemeChange', theme, isDark) - themeMode.value = theme - toggleDark(isDark) - }) - } + + const handleUserThemeChange = (payload: { theme: ThemeMode; isDark: boolean }) => { + stateRevision += 1 + applyThemeState(payload.theme, payload.isDark) } const setupThemeListeners = () => { @@ -53,31 +38,72 @@ export const useThemeStore = defineStore('theme', () => { } listenersRegistered = true - configClient.onSystemThemeChanged(handleSystemThemeChange) - configClient.onThemeChanged(handleUserThemeChange) + const unsubscribeSystemTheme = configClient.onSystemThemeChanged(handleSystemThemeChange) + const unsubscribeTheme = configClient.onThemeChanged(handleUserThemeChange) + removeThemeListeners = () => { + unsubscribeSystemTheme() + unsubscribeTheme() + removeThemeListeners = null + listenersRegistered = false + stateRevision += 1 + } + } + + // Register listeners before reading the snapshot so updates from another window cannot be lost. + const initTheme = async () => { + if (themeInitialization) { + return themeInitialization + } + + const initialization = (async () => { + try { + setupThemeListeners() + const snapshotRevision = stateRevision + const themeState = await configClient.getThemeState() + if (snapshotRevision !== stateRevision) { + return + } + + applyThemeState(themeState.theme, themeState.isDark) + } catch (error) { + themeInitialization = null + console.error('初始化主题失败:', error) + } + })() + + themeInitialization = initialization + return initialization } - // 设置主题模式 const setThemeMode = async (mode: ThemeMode) => { + const requestRevision = ++stateRevision themeMode.value = mode const isDarkMode = await configClient.setTheme(mode) + if (requestRevision !== stateRevision) { + return + } - // 设置界面深色/浅色状态 toggleDark(isDarkMode) } // 循环切换主题:light -> dark -> system -> light const cycleTheme = async () => { - console.log('cycleTheme', themeMode.value) if (themeMode.value === 'light') await setThemeMode('dark') else if (themeMode.value === 'dark') await setThemeMode('system') else await setThemeMode('light') } + onScopeDispose(() => { + removeThemeListeners?.() + }) + + void initTheme() + return { isDark, toggleDark, themeMode, + initTheme, cycleTheme, setThemeMode } diff --git a/src/renderer/src/stores/ui/draft.ts b/src/renderer/src/stores/ui/draft.ts index e6a1af6377..6dc8d6e0b0 100644 --- a/src/renderer/src/stores/ui/draft.ts +++ b/src/renderer/src/stores/ui/draft.ts @@ -15,7 +15,6 @@ export interface StartDeeplinkPayload { modelId: string | null systemPrompt: string mentions: string[] - autoSend: boolean } // --- Store --- diff --git a/src/renderer/src/stores/ui/message.ts b/src/renderer/src/stores/ui/message.ts index 80010f49e5..97340e2136 100644 --- a/src/renderer/src/stores/ui/message.ts +++ b/src/renderer/src/stores/ui/message.ts @@ -69,6 +69,10 @@ export const useMessageStore = defineStore('message', () => { const nextCursor = ref(null) const hasMoreHistory = ref(false) const isLoadingHistory = ref(false) + // History pagination errors are intentionally kept separate from exhaustion. A + // failed request must not make the UI look like the conversation has reached + // its beginning, and the error is only exposed for the current committed view. + const historyLoadError = ref(false) const parsedMessageCache = new Map() const recentSessionViews = new RecentMessageViewCache() const messageMutationRevisions = new Map() @@ -448,6 +452,7 @@ export const useMessageStore = defineStore('message', () => { latestHistoryRequestId += 1 latestLoadSessionId = null isLoadingHistory.value = false + historyLoadError.value = false currentSessionId.value = sessionId } @@ -526,6 +531,7 @@ export const useMessageStore = defineStore('message', () => { nextCursor.value = view.nextCursor hasMoreHistory.value = view.hasMoreHistory isLoadingHistory.value = false + historyLoadError.value = false lastPersistedRevision.value += 1 if (options.clearRecentViewDirty) { dirtyRecentSessionViews.delete(view.sessionId) @@ -562,6 +568,7 @@ export const useMessageStore = defineStore('message', () => { latestHistoryRequestId += 1 latestLoadSessionId = null isLoadingHistory.value = false + historyLoadError.value = false commitSessionView(cachedView) return true } @@ -638,6 +645,7 @@ export const useMessageStore = defineStore('message', () => { latestHistoryRequestId += 1 latestLoadSessionId = sessionId isLoadingHistory.value = false + historyLoadError.value = false const mutationRevisionAtStart = getMessageMutationRevision(sessionId) const cacheInvalidationRevisionAtStart = getRecentViewInvalidationRevision(sessionId) try { @@ -707,6 +715,7 @@ export const useMessageStore = defineStore('message', () => { const sessionId = committedSessionId.value const requestId = ++latestHistoryRequestId + historyLoadError.value = false isLoadingHistory.value = true try { const page = await sessionClient.listMessagesPage(sessionId, { @@ -739,6 +748,9 @@ export const useMessageStore = defineStore('message', () => { return incomingIds.length } catch (error) { console.error('Failed to load older messages:', error) + if (isCurrentHistoryRequest(requestId, sessionId)) { + historyLoadError.value = true + } return 0 } finally { if (isCurrentHistoryRequest(requestId, sessionId)) { @@ -825,6 +837,7 @@ export const useMessageStore = defineStore('message', () => { nextCursor.value = null hasMoreHistory.value = false isLoadingHistory.value = false + historyLoadError.value = false parsedMessageCache.clear() hydratingStreamMessageIds.clear() recentSessionViews.clear() @@ -949,6 +962,7 @@ export const useMessageStore = defineStore('message', () => { nextCursor, hasMoreHistory, isLoadingHistory, + historyLoadError, messages, getAssistantMessageBlocks, getUserMessageContent, diff --git a/src/renderer/src/stores/ui/project.ts b/src/renderer/src/stores/ui/project.ts index 2c80d0824d..a2d01d13c5 100644 --- a/src/renderer/src/stores/ui/project.ts +++ b/src/renderer/src/stores/ui/project.ts @@ -33,6 +33,11 @@ export const useProjectStore = defineStore('project', () => { const selectionSource = ref('none') const error = ref(null) let listenersRegistered = false + let environmentReorderRevision = 0 + // Every snapshot request owns the state it reads. Local mutations and IPC updates + // advance the corresponding revision so an older response cannot overwrite them. + let environmentSnapshotRevision = 0 + let projectSnapshotRevision = 0 // --- Getters --- const selectedProject = computed(() => @@ -94,6 +99,7 @@ export const useProjectStore = defineStore('project', () => { _event?: unknown, payload?: string | { path?: string | null } ) => { + projectSnapshotRevision += 1 defaultProjectPath.value = normalizePath( typeof payload === 'string' ? payload : (payload?.path ?? null) ) @@ -105,6 +111,7 @@ export const useProjectStore = defineStore('project', () => { path: string | null | undefined, chatWorkspacePath?: string | null ) => { + projectSnapshotRevision += 1 defaultProjectPath.value = normalizePath(path) defaultChatWorkspacePath.value = normalizePath(chatWorkspacePath) projects.value = reconcileProjects(projects.value) @@ -117,6 +124,8 @@ export const useProjectStore = defineStore('project', () => { handleDefaultProjectPathChanged(undefined, { path }) }) projectClient.onEnvironmentsChanged(({ action, path }) => { + environmentSnapshotRevision += 1 + projectSnapshotRevision += 1 if (action === 'remove' && path) { projects.value = projects.value.filter((project) => project.path !== path) if (selectedProjectPath.value === path) { @@ -134,19 +143,30 @@ export const useProjectStore = defineStore('project', () => { // --- Actions --- async function loadDefaultProjectPath(): Promise { + const snapshotRevision = ++projectSnapshotRevision try { - applyBootstrapDefaultProjectPath(await configClient.getDefaultProjectPath()) + const path = await configClient.getDefaultProjectPath() + if (snapshotRevision !== projectSnapshotRevision) { + return + } + applyBootstrapDefaultProjectPath(path) } catch (e) { - error.value = `Failed to load default project path: ${e}` + if (snapshotRevision === projectSnapshotRevision) { + error.value = `Failed to load default project path: ${e}` + } } } async function fetchProjects(): Promise { + const snapshotRevision = ++projectSnapshotRevision try { const [result, nextDefaultProjectPath] = await Promise.all([ projectClient.listRecent(20), configClient.getDefaultProjectPath() ]) + if (snapshotRevision !== projectSnapshotRevision) { + return + } defaultProjectPath.value = normalizePath(nextDefaultProjectPath) projects.value = reconcileProjects( @@ -159,22 +179,32 @@ export const useProjectStore = defineStore('project', () => { ) applyDefaultSelection() } catch (e) { - error.value = `Failed to load projects: ${e}` + if (snapshotRevision === projectSnapshotRevision) { + error.value = `Failed to load projects: ${e}` + } } } - async function fetchEnvironments(): Promise { + async function fetchEnvironments(shouldApply: () => boolean = () => true): Promise { + const snapshotRevision = ++environmentSnapshotRevision + const canApply = () => snapshotRevision === environmentSnapshotRevision && shouldApply() + try { const [active, archived, removed] = await Promise.all([ projectClient.listEnvironments('active'), projectClient.listEnvironments('archived'), projectClient.listEnvironments('removed') ]) + if (!canApply()) { + return + } environments.value = active archivedEnvironments.value = archived removedEnvironments.value = removed } catch (e) { - error.value = `Failed to load environments: ${e}` + if (canApply()) { + error.value = `Failed to load environments: ${e}` + } } } @@ -193,11 +223,16 @@ export const useProjectStore = defineStore('project', () => { async function setDefaultProject(path: string | null): Promise { const normalizedPath = normalizePath(path) + const mutationRevision = ++projectSnapshotRevision try { await configClient.setDefaultProjectPath(normalizedPath) - handleDefaultProjectPathChanged(undefined, { path: normalizedPath }) + if (mutationRevision === projectSnapshotRevision) { + handleDefaultProjectPathChanged(undefined, { path: normalizedPath }) + } } catch (e) { - error.value = `Failed to update default project path: ${e}` + if (mutationRevision === projectSnapshotRevision) { + error.value = `Failed to update default project path: ${e}` + } throw e } } @@ -225,6 +260,8 @@ export const useProjectStore = defineStore('project', () => { return } + const reorderRevision = ++environmentReorderRevision + environmentSnapshotRevision += 1 const orderedPathSet = new Set(orderedPaths) environments.value = [ @@ -237,37 +274,58 @@ export const useProjectStore = defineStore('project', () => { try { await projectClient.reorderEnvironments(orderedPaths) - await fetchEnvironments() + if (reorderRevision === environmentReorderRevision) { + await fetchEnvironments(() => reorderRevision === environmentReorderRevision) + } } catch (e) { - environments.value = previousEnvironments - error.value = `Failed to reorder environments: ${e}` + if (reorderRevision === environmentReorderRevision) { + environments.value = previousEnvironments + error.value = `Failed to reorder environments: ${e}` + } throw e } } async function archiveEnvironment(path: string): Promise { + const mutationRevision = ++environmentSnapshotRevision + projectSnapshotRevision += 1 try { await projectClient.archiveEnvironment(path) - await fetchEnvironments() + if (mutationRevision === environmentSnapshotRevision) { + await fetchEnvironments() + } } catch (e) { - error.value = `Failed to archive environment: ${e}` + if (mutationRevision === environmentSnapshotRevision) { + error.value = `Failed to archive environment: ${e}` + } throw e } } async function restoreEnvironment(path: string): Promise { + const mutationRevision = ++environmentSnapshotRevision + projectSnapshotRevision += 1 try { await projectClient.restoreEnvironment(path) - await fetchEnvironments() + if (mutationRevision === environmentSnapshotRevision) { + await fetchEnvironments() + } } catch (e) { - error.value = `Failed to restore environment: ${e}` + if (mutationRevision === environmentSnapshotRevision) { + error.value = `Failed to restore environment: ${e}` + } throw e } } async function removeEnvironment(path: string): Promise<{ clearedSessionIds: string[] }> { + const mutationRevision = ++environmentSnapshotRevision + projectSnapshotRevision += 1 try { const result = await projectClient.removeEnvironment(path) + if (mutationRevision !== environmentSnapshotRevision) { + return result + } projects.value = projects.value.filter((project) => project.path !== path) if (selectedProjectPath.value === path) { selectProject(null) @@ -275,7 +333,9 @@ export const useProjectStore = defineStore('project', () => { await Promise.all([loadDefaultProjectPath(), fetchEnvironments()]) return result } catch (e) { - error.value = `Failed to remove environment: ${e}` + if (mutationRevision === environmentSnapshotRevision) { + error.value = `Failed to remove environment: ${e}` + } throw e } } diff --git a/src/renderer/src/stores/ui/session.ts b/src/renderer/src/stores/ui/session.ts index 1fef3c0b8d..45441e570a 100644 --- a/src/renderer/src/stores/ui/session.ts +++ b/src/renderer/src/stores/ui/session.ts @@ -247,11 +247,27 @@ function sortSessions(items: UISession[]): UISession[] { }) } +function isNewerSessionUpdate(existing: UISession, update: UISession): boolean { + const existingUpdatedAt = existing.updatedAt + const updateUpdatedAt = update.updatedAt + // `updatedAt` is the only cross-window ordering signal for lightweight session + // snapshots. Equal timestamps have no causal ordering, so retaining the value + // already rendered prevents a delayed stale response from replacing it. + return ( + Number.isFinite(existingUpdatedAt) && + Number.isFinite(updateUpdatedAt) && + updateUpdatedAt <= existingUpdatedAt + ) +} + function mergeSessions(current: UISession[], updates: UISession[]): UISession[] { const next = new Map(current.map((session) => [session.id, session])) for (const update of updates) { const existing = next.get(update.id) + if (existing && isNewerSessionUpdate(existing, update)) { + continue + } next.set(update.id, existing ? { ...existing, ...update } : update) } @@ -280,6 +296,15 @@ export const useSessionStore = defineStore('session', () => { let groupModeUpdateVersion = 0 let initialPageRequestId = 0 let nextPageRequestId = 0 + // A list epoch protects the first-page/pagination cursor chain. Targeted updates + // only merge individual rows, so they must not invalidate an in-flight next page. + let sessionListEpoch = 0 + let sessionByIdsRefreshRevision = 0 + const sessionByIdRefreshRevisions = new Map() + // Deleted sessions must stay absent while requests started before their deletion settle. + // IDs are stable database identifiers, so they are safe tombstones for this store lifetime. + const removedSessionIds = new Set() + let sessionByIdsErrorRevision: number | null = null let activationNavigationRequestId = 0 let newConversationProjectDirIntentId = 0 let sessionFetchPromise: Promise | null = null @@ -296,10 +321,12 @@ export const useSessionStore = defineStore('session', () => { const hasMore = ref(false) const nextCursor = ref<{ updatedAt: number; id: string } | null>(null) const error = ref(null) + let sessionIpcBinding: ReturnType | null = null void getCurrentWebContentsId() .then((webContentsId) => { myWebContentsId.value = webContentsId + sessionIpcBinding?.flushPendingTargetedUpdate() }) .catch((identityError) => { console.warn('[sessionStore] Failed to resolve runtime webContents id:', identityError) @@ -362,11 +389,30 @@ export const useSessionStore = defineStore('session', () => { } const upsertSessions = (updates: UISession[]): void => { - sessions.value = mergeSessions(sessions.value, updates) + sessions.value = mergeSessions( + sessions.value, + updates.filter((session) => !removedSessionIds.has(session.id)) + ) } const removeSessions = (sessionIds: string[]): void => { const targetIds = new Set(sessionIds) + // A dropped in-flight first page must be re-requested, or the sidebar sits + // with no skeleton, no rows, and no error until an unrelated event fires. + const hadPendingInitialFetch = sessionFetchPromise !== null || loading.value + // Invalidate every in-flight list response and per-ID refresh for deleted rows. + // This also lets their finally blocks retire loading state without changing it. + sessionListEpoch += 1 + initialPageRequestId += 1 + nextPageRequestId += 1 + loading.value = false + loadingMore.value = false + // A deleted row should not keep a superseded first-page request deduplicated. + sessionFetchPromise = null + for (const sessionId of targetIds) { + removedSessionIds.add(sessionId) + sessionByIdRefreshRevisions.set(sessionId, ++sessionByIdsRefreshRevision) + } sessions.value = sessions.value.filter((session) => !targetIds.has(session.id)) for (const sessionId of targetIds) { agentPlanStore.purge(sessionId) @@ -388,6 +434,10 @@ export const useSessionStore = defineStore('session', () => { setActiveSessionId(null) pageRouter.goToNewThread() } + + if (hadPendingInitialFetch && !hasLoadedInitialPage.value) { + void fetchSessions() + } } const activeSession: ComputedRef = computed(() => { @@ -542,6 +592,8 @@ export const useSessionStore = defineStore('session', () => { }): Promise => { if (options.reset) { const requestId = ++initialPageRequestId + const listEpoch = ++sessionListEpoch + loadingMore.value = false loading.value = true error.value = null @@ -556,11 +608,13 @@ export const useSessionStore = defineStore('session', () => { prioritizeSessionId: options.prioritizeSessionId ?? undefined }) - if (requestId !== initialPageRequestId) { + if (requestId !== initialPageRequestId || listEpoch !== sessionListEpoch) { return } - const nextSessions = result.items.map(mapToUISession) + const nextSessions = result.items + .map(mapToUISession) + .filter((session) => !removedSessionIds.has(session.id)) sessions.value = options.preserveExisting ? mergeSessions(sessions.value, nextSessions) : sortSessions(nextSessions) @@ -569,7 +623,9 @@ export const useSessionStore = defineStore('session', () => { nextCursor.value = result.nextCursor syncSelectedAgentToSession(activeSessionId.value) } catch (loadError) { - error.value = `Failed to load sessions: ${loadError}` + if (requestId === initialPageRequestId && listEpoch === sessionListEpoch) { + error.value = `Failed to load sessions: ${loadError}` + } } finally { if (requestId === initialPageRequestId) { loading.value = false @@ -584,6 +640,7 @@ export const useSessionStore = defineStore('session', () => { } const requestId = ++nextPageRequestId + const listEpoch = sessionListEpoch loadingMore.value = true error.value = null @@ -595,7 +652,7 @@ export const useSessionStore = defineStore('session', () => { includeSubagents: false }) - if (requestId !== nextPageRequestId) { + if (requestId !== nextPageRequestId || listEpoch !== sessionListEpoch) { return } @@ -606,9 +663,11 @@ export const useSessionStore = defineStore('session', () => { `[Startup][Renderer] startup.session.page.appended count=${result.items.length} total=${sessions.value.length}` ) } catch (loadError) { - error.value = `Failed to load more sessions: ${loadError}` + if (requestId === nextPageRequestId && listEpoch === sessionListEpoch) { + error.value = `Failed to load more sessions: ${loadError}` + } } finally { - if (requestId === nextPageRequestId) { + if (requestId === nextPageRequestId && listEpoch === sessionListEpoch) { loadingMore.value = false } } @@ -650,31 +709,77 @@ export const useSessionStore = defineStore('session', () => { return } + const refreshRevision = ++sessionByIdsRefreshRevision + const requestedRevisions = new Map() for (const sessionId of normalizedIds) { + sessionByIdRefreshRevisions.set(sessionId, refreshRevision) + requestedRevisions.set(sessionId, refreshRevision) messageStore.invalidateRecentSessionView(sessionId) } + const listEpoch = sessionListEpoch + const hasCurrentRequestedId = (): boolean => + listEpoch === sessionListEpoch && + normalizedIds.some( + (sessionId) => + sessionByIdRefreshRevisions.get(sessionId) === requestedRevisions.get(sessionId) + ) - error.value = null try { const items = await sessionClient.getLightweightByIds(normalizedIds) - upsertSessions(items.map(mapToUISession)) + if (listEpoch !== sessionListEpoch) { + return + } + + // A newer request for the same ID wins, but disjoint IDs from concurrent + // targeted refreshes may safely merge independently. + const acceptedItems = items.filter( + (item) => + requestedRevisions.has(item.id) && + sessionByIdRefreshRevisions.get(item.id) === requestedRevisions.get(item.id) + ) + if (!hasCurrentRequestedId()) { + return + } + + upsertSessions(acceptedItems.map(mapToUISession)) + + // This background refresh may only clear an error it owns; first-page and + // pagination errors remain visible until their own request resolves. + if ( + sessionByIdsErrorRevision !== null && + sessionByIdsErrorRevision <= refreshRevision && + error.value?.startsWith('Failed to refresh sessions:') + ) { + error.value = null + sessionByIdsErrorRevision = null + } const activeId = activeSessionId.value if (activeId) { - const activeItem = items.find((item) => item.id === activeId) + const activeItem = acceptedItems.find((item) => item.id === activeId) if (activeItem) { updateBootstrapActiveSession(mapToUISession(activeItem)) syncSelectedAgentToSession(activeId) } } } catch (refreshError) { - error.value = `Failed to refresh sessions: ${refreshError}` + if (hasCurrentRequestedId()) { + const canReplaceError = + error.value === null || + (sessionByIdsErrorRevision !== null && + sessionByIdsErrorRevision <= refreshRevision && + error.value.startsWith('Failed to refresh sessions:')) + if (canReplaceError) { + error.value = `Failed to refresh sessions: ${refreshError}` + sessionByIdsErrorRevision = refreshRevision + } + } } } async function createSession(input: CreateSessionInput): Promise { error.value = null - createActivationNavigationRequest() + const requestId = createActivationNavigationRequest() try { const result = await sessionClient.create(input) const session = result.session @@ -683,7 +788,12 @@ export const useSessionStore = defineStore('session', () => { ...mapToUISession(session), ...(hasInitialTurn ? { status: 'working' as const } : {}) } + // Creation is durable even if the user has navigated elsewhere while it was pending. upsertSessions([lightweightSession]) + if (activationNavigationRequestId !== requestId) { + return + } + setActiveSessionId(session.id) bootstrapActiveSession.value = lightweightSession activeSessionSummary.value = { @@ -694,7 +804,9 @@ export const useSessionStore = defineStore('session', () => { pageRouter.goToChat(session.id) await completeOnboardingStep('first-chat') } catch (createError) { - error.value = `Failed to create session: ${createError}` + if (activationNavigationRequestId === requestId) { + error.value = `Failed to create session: ${createError}` + } throw createError } } @@ -719,21 +831,28 @@ export const useSessionStore = defineStore('session', () => { } pageRouter.goToChat(sessionId) } catch (selectError) { - error.value = `Failed to select session: ${selectError}` + if (activationNavigationRequestId === requestId) { + error.value = `Failed to select session: ${selectError}` + } } } async function closeSession(options: CloseSessionOptions = {}): Promise { error.value = null - createActivationNavigationRequest() + const requestId = createActivationNavigationRequest() try { messageStore.clearStreamingState() await sessionClient.deactivate() + if (activationNavigationRequestId !== requestId) { + return + } clearActiveSessionSummary() setActiveSessionId(null) pageRouter.goToNewThread(options.refresh ? { refresh: true } : {}) } catch (closeError) { - error.value = `Failed to close session: ${closeError}` + if (activationNavigationRequestId === requestId) { + error.value = `Failed to close session: ${closeError}` + } } } @@ -999,39 +1118,34 @@ export const useSessionStore = defineStore('session', () => { } function getPinnedSessions(agentId: string | null): UISession[] { - const pinned = sortSessions( + return sortSessions( sessions.value.filter( - (session) => isRegularSession(session) && session.isPinned && !session.isDraft + (session) => + isRegularSession(session) && + session.isPinned && + !session.isDraft && + (agentId === null || session.agentId === agentId) ) ) - - if (agentId === null) return pinned - - return pinned.filter((session) => session.agentId === agentId) } function getFilteredGroups(agentId: string | null): SessionGroup[] { const visibleSessions = sortSessions( sessions.value.filter( - (session) => isRegularSession(session) && !session.isDraft && !session.isPinned + (session) => + isRegularSession(session) && + !session.isDraft && + !session.isPinned && + (agentId === null || session.agentId === agentId) ) ) - const grouped = - groupMode.value === 'time' ? groupByTime(visibleSessions) : groupByProject(visibleSessions) - - if (agentId === null) return grouped - - return grouped - .map((group) => ({ - id: group.id, - label: group.label, - labelKey: group.labelKey, - sessions: group.sessions.filter((session) => session.agentId === agentId) - })) - .filter((group) => group.sessions.length > 0) + + return groupMode.value === 'time' + ? groupByTime(visibleSessions) + : groupByProject(visibleSessions) } - const cleanupIpcBindings = bindSessionStoreIpc({ + sessionIpcBinding = bindSessionStoreIpc({ webContentsId: () => myWebContentsId.value, fetchSessions, refreshSessionsByIds, @@ -1063,7 +1177,7 @@ export const useSessionStore = defineStore('session', () => { applySessionStatus(sessionId, status) } }) - registerStoreCleanup(cleanupIpcBindings) + registerStoreCleanup(sessionIpcBinding.cleanup) void ensureGroupModeLoaded() return { @@ -1075,6 +1189,7 @@ export const useSessionStore = defineStore('session', () => { loadingMore, hasLoadedInitialPage, hasMore, + nextCursor, error, activeSession, sessionGroups, diff --git a/src/renderer/src/stores/ui/sessionIpc.ts b/src/renderer/src/stores/ui/sessionIpc.ts index 428fc39878..9f78906a13 100644 --- a/src/renderer/src/stores/ui/sessionIpc.ts +++ b/src/renderer/src/stores/ui/sessionIpc.ts @@ -10,22 +10,62 @@ interface BindSessionStoreIpcOptions { onStatusChanged: (sessionId: string, status: string) => void } -export function bindSessionStoreIpc(options: BindSessionStoreIpcOptions): () => void { +type TargetedSessionUpdate = { + reason: 'activated' | 'deactivated' + webContentsId: number + activeSessionId?: string | null +} + +export interface SessionStoreIpcBinding { + cleanup: () => void + flushPendingTargetedUpdate: () => void +} + +export function bindSessionStoreIpc(options: BindSessionStoreIpcOptions): SessionStoreIpcBinding { const sessionClient = createSessionClient() + const pendingTargetedUpdates = new Map() + + const applyTargetedUpdate = (payload: TargetedSessionUpdate): void => { + const webContentsId = options.webContentsId() + if (webContentsId === null) { + pendingTargetedUpdates.set(payload.webContentsId, payload) + return + } + + if (payload.webContentsId !== webContentsId) return + + if (payload.reason === 'activated' && payload.activeSessionId) { + void options.onActivated(payload.activeSessionId) + return + } + + if (payload.reason === 'deactivated') { + options.onDeactivated() + } + } + + const flushPendingTargetedUpdate = (): void => { + const webContentsId = options.webContentsId() + if (webContentsId === null) return + + const pendingUpdate = pendingTargetedUpdates.get(webContentsId) + pendingTargetedUpdates.clear() + if (pendingUpdate) { + applyTargetedUpdate(pendingUpdate) + } + } + const cleanups = [ sessionClient.onUpdated((payload) => { - const webContentsId = options.webContentsId() if ( - payload.reason === 'activated' && - payload.activeSessionId && - payload.webContentsId === webContentsId + (payload.reason === 'activated' || payload.reason === 'deactivated') && + typeof payload.webContentsId === 'number' ) { - void options.onActivated(payload.activeSessionId) - return - } - - if (payload.reason === 'deactivated' && payload.webContentsId === webContentsId) { - options.onDeactivated() + applyTargetedUpdate({ + reason: payload.reason, + webContentsId: payload.webContentsId, + activeSessionId: payload.activeSessionId + }) return } @@ -55,9 +95,13 @@ export function bindSessionStoreIpc(options: BindSessionStoreIpcOptions): () => }) ] - return () => { - for (const cleanup of cleanups) { - cleanup() + return { + flushPendingTargetedUpdate, + cleanup: () => { + pendingTargetedUpdates.clear() + for (const cleanup of cleanups) { + cleanup() + } } } } diff --git a/src/renderer/src/views/SettingsTabView.vue b/src/renderer/src/views/SettingsTabView.vue deleted file mode 100644 index ff3b84769a..0000000000 --- a/src/renderer/src/views/SettingsTabView.vue +++ /dev/null @@ -1,96 +0,0 @@ - - - - - diff --git a/test/renderer/components/App.startup.test.ts b/test/renderer/components/App.startup.test.ts index 94b00d7ffa..76c842cfee 100644 --- a/test/renderer/components/App.startup.test.ts +++ b/test/renderer/components/App.startup.test.ts @@ -247,12 +247,13 @@ const mountApp = async (options?: { const agentStore = { setSelectedAgent: vi.fn() } + let nextStartDeeplinkToken = 0 const draftStore = reactive({ pendingStartDeeplink: null as null | Record, setPendingStartDeeplink: vi.fn((payload: Record) => { draftStore.pendingStartDeeplink = { ...payload, - token: 1 + token: ++nextStartDeeplinkToken } }) }) @@ -337,7 +338,8 @@ const mountApp = async (options?: { vi.doMock('vue-i18n', () => ({ useI18n: () => ({ - t: (key: string) => key + t: (key: string) => key, + locale: ref('zh-CN') }) })) @@ -410,7 +412,7 @@ const mountApp = async (options?: { useModelStore: () => modelStore })) vi.doMock('@/lib/storeInitializer', () => ({ - initAppStores: vi.fn(), + initAppStores: vi.fn().mockResolvedValue(undefined), useMcpInstallDeeplinkHandler: () => ({ setup: setupMcpDeeplink, cleanup: cleanupMcpDeeplink @@ -797,7 +799,7 @@ describe('App startup welcome flow', () => { modelId: 'deepseek-chat', systemPrompt: 'Be concise', mentions: ['README.md'], - autoSend: false + autoSend: true }) await flushPromises() @@ -805,14 +807,49 @@ describe('App startup welcome flow', () => { msg: '你好,DeepChat', modelId: 'deepseek-chat', systemPrompt: 'Be concise', - mentions: ['README.md'], - autoSend: false + mentions: ['README.md'] }) expect(agentStore.setSelectedAgent).toHaveBeenCalledWith('deepchat') expect(sessionStore.closeSession).toHaveBeenCalledTimes(1) expect(pageRouterStore.goToNewThread).not.toHaveBeenCalled() }) + it('does not let a replaced start deeplink overwrite the newer navigation intent', async () => { + const { draftStore, pageRouterStore, sessionStore, ipcOn } = await mountApp({ + initComplete: true, + routeName: 'chat', + hasActiveSession: true + }) + let releaseFirstClose: (() => void) | undefined + const firstClose = new Promise((resolve) => { + releaseFirstClose = resolve + }) + sessionStore.closeSession.mockImplementationOnce(() => firstClose) + + const startHandler = ipcOn.mock.calls.find( + ([eventName]: [string]) => eventName === 'appRuntime.startDeeplinkRequested' + )?.[1] + + expect(startHandler).toBeTypeOf('function') + + startHandler?.({ msg: 'A', autoSend: false }) + await flushPromises() + expect(sessionStore.closeSession).toHaveBeenCalledTimes(1) + + sessionStore.hasActiveSession = false + startHandler?.({ msg: 'B', autoSend: false }) + await flushPromises() + + expect(pageRouterStore.goToNewThread).toHaveBeenCalledTimes(1) + expect(draftStore.pendingStartDeeplink).toMatchObject({ msg: 'B', token: 2 }) + + releaseFirstClose?.() + await flushPromises() + + expect(pageRouterStore.goToNewThread).toHaveBeenCalledTimes(1) + expect(draftStore.pendingStartDeeplink).toMatchObject({ msg: 'B', token: 2 }) + }) + it('opens spotlight from the global shortcut event', async () => { const { ipcOn, spotlightStore } = await mountApp({ initComplete: true, diff --git a/test/renderer/components/ChatPage.test.ts b/test/renderer/components/ChatPage.test.ts index 5da5de18d5..0eb5c0cf1e 100644 --- a/test/renderer/components/ChatPage.test.ts +++ b/test/renderer/components/ChatPage.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { defineComponent, provide, reactive } from 'vue' +import { defineComponent, provide, reactive, ref } from 'vue' import { flushPromises, mount } from '@vue/test-utils' import { WORKSPACE_EVENTS } from '@/events' @@ -59,6 +59,7 @@ type SetupOptions = { deferStartupTasks?: boolean autoScrollEnabled?: boolean cachedMeasurements?: Readonly> + mockChatSearch?: boolean performanceReporter?: { recordChatSession: ReturnType } @@ -66,6 +67,7 @@ type SetupOptions = { const setup = async (options: SetupOptions = {}) => { vi.resetModules() + vi.doUnmock('@/features/chat-page/composables/useChatSearch') const activeStatus = String(options.activeSessionPatch?.status ?? 'idle') const sessionStore = reactive({ @@ -128,6 +130,7 @@ const setup = async (options: SetupOptions = {}) => { : options.currentStreamSessionId, hasMoreHistory: false, isLoadingHistory: false, + historyLoadError: false, messageIds: ( options.messages ?? [ buildAssistantMessage([ @@ -316,6 +319,9 @@ const setup = async (options: SetupOptions = {}) => { vi.doMock('@shadcn/components/ui/tooltip', () => ({ TooltipProvider: passthrough('TooltipProvider') })) + vi.doMock('@shadcn/components/ui/button', () => ({ + Button: clickStub('Button') + })) vi.doMock('@shadcn/components/ui/alert-dialog', () => ({ AlertDialog: defineComponent({ name: 'AlertDialog', @@ -380,9 +386,9 @@ const setup = async (options: SetupOptions = {}) => { type: Boolean, default: false }, - allMessagesForCapture: { - type: Array, - default: () => [] + resolveCaptureParentId: { + type: Function, + default: undefined }, beforeSpacerHeight: { type: Number, @@ -524,6 +530,24 @@ const setup = async (options: SetupOptions = {}) => { ' + + ` applyChatSearchHighlights(container, 'alpha') + expect(container.querySelectorAll('mark[data-chat-search-match="true"]')).toHaveLength(1) expect(setActiveChatSearchResult(container, results[0], { scroll: false })).toBeNull() const active = setActiveChatSearchResult(container, results[2], { scroll: false }) diff --git a/test/renderer/stores/memoryActivityStore.test.ts b/test/renderer/stores/memoryActivityStore.test.ts index e51f7ca4ae..996c06ec10 100644 --- a/test/renderer/stores/memoryActivityStore.test.ts +++ b/test/renderer/stores/memoryActivityStore.test.ts @@ -158,7 +158,10 @@ async function setupStore() { getRuntimeWebContentsId: vi.fn(async () => null) })) vi.doMock('../../../src/renderer/src/stores/ui/sessionIpc', () => ({ - bindSessionStoreIpc: vi.fn(() => () => undefined) + bindSessionStoreIpc: vi.fn(() => ({ + cleanup: () => undefined, + flushPendingTargetedUpdate: () => undefined + })) })) vi.doMock('../../../src/renderer/src/stores/ui/messageIpc', () => ({ bindMessageStoreIpc: vi.fn(() => () => undefined) diff --git a/test/renderer/stores/projectStore.test.ts b/test/renderer/stores/projectStore.test.ts index 273e943bf3..48f09b5590 100644 --- a/test/renderer/stores/projectStore.test.ts +++ b/test/renderer/stores/projectStore.test.ts @@ -309,6 +309,67 @@ describe('projectStore default project handling', () => { ]) }) + it('does not roll back an archive that succeeds while a reorder is pending', async () => { + const { store, projectPresenter } = await setupStore() + const original = [ + createEnvironment('/work/a', 0), + createEnvironment('/work/b', 1), + createEnvironment('/work/c', 2) + ] + const activeAfterArchive = [createEnvironment('/work/b', 0), createEnvironment('/work/c', 1)] + const archived = [{ ...createEnvironment('/work/a', 0), status: 'archived' as const }] + store.environments.value = original + + const pendingReorder = deferred<{ updated: boolean }>() + projectPresenter.reorderEnvironments.mockImplementationOnce(() => pendingReorder.promise) + projectPresenter.getEnvironments.mockImplementation(async (status: string) => { + if (status === 'active') return activeAfterArchive + if (status === 'archived') return archived + return [] + }) + + const reorderRequest = store.reorderEnvironments(['/work/b', '/work/a', '/work/c']) + await store.archiveEnvironment('/work/a') + + pendingReorder.reject(new Error('reorder failed')) + await expect(reorderRequest).rejects.toThrow('reorder failed') + + expect(store.environments.value).toEqual(activeAfterArchive) + expect(store.archivedEnvironments.value).toEqual(archived) + expect(store.error.value).toBeNull() + }) + + it('lets a later archive own the refresh after an earlier reorder succeeds', async () => { + const { store, projectPresenter } = await setupStore() + const activeAfterArchive = [createEnvironment('/work/b', 0), createEnvironment('/work/c', 1)] + store.environments.value = [ + createEnvironment('/work/a', 0), + createEnvironment('/work/b', 1), + createEnvironment('/work/c', 2) + ] + + const pendingReorder = deferred<{ updated: boolean }>() + const pendingArchive = deferred<{ updated: boolean }>() + projectPresenter.reorderEnvironments.mockImplementationOnce(() => pendingReorder.promise) + projectPresenter.archiveEnvironment.mockImplementationOnce(() => pendingArchive.promise) + projectPresenter.getEnvironments.mockImplementation(async (status: string) => + status === 'active' ? activeAfterArchive : [] + ) + + const reorderRequest = store.reorderEnvironments(['/work/b', '/work/a', '/work/c']) + const archiveRequest = store.archiveEnvironment('/work/a') + + pendingReorder.resolve({ updated: true }) + await reorderRequest + expect(projectPresenter.getEnvironments).not.toHaveBeenCalled() + + pendingArchive.resolve({ updated: true }) + await archiveRequest + + expect(store.environments.value).toEqual(activeAfterArchive) + expect(projectPresenter.getEnvironments).toHaveBeenCalledTimes(3) + }) + it('does not apply a stale reorder refresh after a newer reorder succeeds', async () => { const { store, projectPresenter } = await setupStore() store.environments.value = [ diff --git a/test/renderer/stores/sessionStore.test.ts b/test/renderer/stores/sessionStore.test.ts index 90e02a2012..ee043ad1ba 100644 --- a/test/renderer/stores/sessionStore.test.ts +++ b/test/renderer/stores/sessionStore.test.ts @@ -1695,6 +1695,39 @@ describe('sessionStore pagination', () => { expect(store.error.value).toBeNull() }) + it('preserves a targeted update that commits while an older first page is pending', async () => { + const { store, sessionClient } = await setupStore() + const pendingFirstPage = createDeferred<{ + items: ReturnType[] + hasMore: boolean + nextCursor: null + }>() + sessionClient.listLightweight.mockReturnValueOnce(pendingFirstPage.promise) + + const fetch = store.fetchSessions() + await vi.waitFor(() => expect(sessionClient.listLightweight).toHaveBeenCalledTimes(1)) + + sessionClient.getLightweightByIds.mockResolvedValueOnce([ + createSession({ id: 'session-refresh', title: 'Targeted update', updatedAt: 3 }) + ]) + await store.refreshSessionsByIds(['session-refresh']) + + pendingFirstPage.resolve({ + items: [createSession({ id: 'session-refresh', title: 'Stale first page', updatedAt: 2 })], + hasMore: false, + nextCursor: null + }) + await fetch + + expect(store.sessions.value).toEqual([ + expect.objectContaining({ + id: 'session-refresh', + title: 'Targeted update', + updatedAt: 3 + }) + ]) + }) + it('invalidates a pending targeted update after a new full list refresh', async () => { const { store, sessionClient } = await setupStore() const pendingTargetedUpdate = createDeferred[]>() From eef1e0995dcffadb4962a1c9e0232755084fa2f2 Mon Sep 17 00:00:00 2001 From: zhangmo8 Date: Mon, 20 Jul 2026 15:42:54 +0800 Subject: [PATCH 04/12] fix(chat): restore interrupt actions --- .../chat-interrupt-actions-stall/spec.md | 101 +++++++++++++ .../src/components/chat/ChatInputToolbar.vue | 29 +++- .../src/features/chat-page/ChatPage.vue | 28 +++- .../composables/useComposerSubmit.ts | 72 +++++++-- src/renderer/src/stores/ui/session.ts | 63 ++++++-- src/renderer/src/stores/ui/sessionIpc.ts | 4 +- .../components/ChatInputToolbar.test.ts | 54 +++++++ test/renderer/components/ChatPage.test.ts | 85 ++++++++++- .../composables/useComposerSubmit.test.ts | 143 ++++++++++++++++++ test/renderer/stores/sessionStore.test.ts | 60 +++++++- 10 files changed, 597 insertions(+), 42 deletions(-) create mode 100644 docs/issues/chat-interrupt-actions-stall/spec.md create mode 100644 test/renderer/features/chat-page/composables/useComposerSubmit.test.ts diff --git a/docs/issues/chat-interrupt-actions-stall/spec.md b/docs/issues/chat-interrupt-actions-stall/spec.md new file mode 100644 index 0000000000..a0302394f7 --- /dev/null +++ b/docs/issues/chat-interrupt-actions-stall/spec.md @@ -0,0 +1,101 @@ +# Chat Interrupt Actions Can Stall Silently + +## Issue + +The chat composer can show Stop or Steer as available while clicking the control produces no +visible result. The symptom is intermittent because it depends on session-restore timing, transient +composer gates, or a failed main-process action. + +## Impact + +- A stale `working` session snapshot can leave Stop/Steer visible after the active turn has ended. +- Composer Steer looks enabled while session preparation, a missing ACP workdir, or a pending tool + interaction causes the submit path to return without feedback. +- Stop ignores the main-process `{ stopped: false }` result, and composer Steer does not catch a + rejected request, so users cannot distinguish a pending action from a failed action. +- Repeated clicks can dispatch duplicate operations because neither action exposes an in-flight + state. + +## Root Cause + +1. `sessions.status.changed` includes a version, but `sessionIpc` drops it before applying status to + the session store. +2. Session restore reads the session snapshot before reading messages. A status event can arrive + between those reads, after which the older restore snapshot unconditionally replaces + `activeSessionSummary.status`. +3. `disableQueueSteerAction` is wired to queued-item Steer but not to the composer toolbar Steer + button. The handler repeats those gates as silent early returns. +4. Stop discards the `stopped` response, while Stop and composer Steer only log failures (or leave an + unhandled rejection) and have no pending state. + +## Fix Plan + +- Track the newest observed status event per session, including its version and mapped UI status. +- Reject older status events and merge the newest observed status into asynchronous list, hydrate, + and restore snapshots so stale reads cannot roll the UI backward. +- Pass the existing Steer availability state into `ChatInputToolbar`; disable the button and expose + the existing unavailable tooltip when the action cannot run. +- Add session-scoped pending state for composer Steer and Stop, render the existing shadcn spinner, + prevent duplicate dispatch, retain the draft on failure, and surface destructive toasts. +- Treat `{ stopped: false }` as a user-visible failure and only rebaseline the plan after composer + Steer is accepted. + +## Constraints + +- Do not change the Stop/Steer IPC route schemas or queue ordering semantics. +- Do not optimistically mark a session idle; runtime status events remain authoritative. +- Do not clear a draft when Steer fails or when its completion belongs to a superseded session view. +- Preserve the existing automatic queue drain after Stop/Steer. +- Use existing i18n keys and shadcn primitives; no new custom loading control. + +## UI Change + +BEFORE + +```text +Generating + draft: [Steer (always enabled)] [Queue] +Generating, no draft: [Stop] +Request in flight: no visible pending state or failure feedback +``` + +AFTER + +```text +Generating + draft: [Steer disabled | spinner] [Queue disabled while steering] +Generating, no draft: [Stop spinner] +Request failure: destructive toast; Steer draft remains available +``` + +## Task Checklist + +- [x] Preserve monotonic status events across asynchronous session snapshots. +- [x] Wire composer Steer availability and pending state through the toolbar. +- [x] Surface Stop/Steer failures and prevent duplicate requests. +- [x] Add renderer store and component regression tests. +- [x] Run format, i18n, lint, typecheck, and focused tests. +- [x] Commit and push the fix to PR #2000. + +## Validation + +- An `idle` status event cannot be overwritten by an older `generating` restore snapshot, and the + inverse transition is equally protected. +- Status events older than the last applied version are ignored. +- Composer Steer is visibly disabled whenever its handler would reject the action. +- Pending Stop/Steer renders progress and accepts only one click. +- Rejected Steer retains the draft and shows an error toast. +- Stop returning `{ stopped: false }` shows an error toast. +- Existing queue/steer runtime tests remain green. + +### Results + +- `pnpm exec vitest run` for the affected session store, toolbar, ChatPage, and composer suites: + 169 tests passed. +- `pnpm exec vitest run test/renderer/components/MemorySettings.test.ts`: 11 tests passed + when rerunning the unrelated full-suite timeout in isolation. +- `pnpm run typecheck`, `pnpm run format`, `pnpm run i18n`, and `pnpm run lint`: passed on + Node 24.14.1. + +## GitHub + +- Target PR: https://github.com/ThinkInAIXYZ/deepchat/pull/2000 +- No separate GitHub issue was requested or created. diff --git a/src/renderer/src/components/chat/ChatInputToolbar.vue b/src/renderer/src/components/chat/ChatInputToolbar.vue index 87e0d5b291..53da6865a3 100644 --- a/src/renderer/src/components/chat/ChatInputToolbar.vue +++ b/src/renderer/src/components/chat/ChatInputToolbar.vue @@ -109,14 +109,23 @@ variant="outline" size="sm" class="h-7 gap-1.5 rounded-lg px-2.5 text-foreground" + :disabled="steerDisabled || isSteering" + :aria-busy="isSteering || undefined" @click="emit('steer')" > - + + {{ t('chat.input.steer') }} -

{{ t('chat.input.steer') }}

+

+ {{ + steerDisabled && !isSteering + ? t('chat.pendingInput.steerUnavailable') + : t('chat.input.steer') + }} +

@@ -136,11 +145,18 @@ size="icon" class="h-7 w-7 rounded-full" :disabled=" - buttonMode === 'send' ? sendDisabled : buttonMode === 'queue' ? queueDisabled : false + buttonMode === 'send' + ? sendDisabled + : buttonMode === 'queue' + ? queueDisabled + : isStopping " + :aria-busy="buttonMode === 'stop' && isStopping ? true : undefined" @click="handlePrimaryAction" > + sessionStore.activeSession?.sessionKind const isGenerating = computed( () => sessionStore.activeSession?.status === 'working' || isCurrentSessionStreaming.value ) +const stoppingSessionIds = ref>(new Set()) +const isStopping = computed(() => stoppingSessionIds.value.has(props.sessionId)) const streamingMessageId = computed(() => isCurrentSessionStreaming.value ? messageStore.currentStreamMessageId : null ) @@ -1118,6 +1123,7 @@ const { message, attachedFiles, hasDraftInput, + isSteering, isQueueSubmitDisabled, isInputSubmitDisabled, disableQueueSteerAction, @@ -1229,11 +1235,29 @@ function onToggleVoiceInput() { async function onStop() { if (isReadOnlySession.value) return if (!isGenerating.value) return + const sessionId = props.sessionId + if (stoppingSessionIds.value.has(sessionId)) return + + stoppingSessionIds.value = new Set(stoppingSessionIds.value).add(sessionId) try { - agentPlanStore.freezeActive(props.sessionId) - await chatClient.stopStream({ sessionId: props.sessionId }) + const result = await chatClient.stopStream({ sessionId }) + if (!result.stopped) { + throw new Error('Generation could not be stopped') + } + agentPlanStore.freezeActive(sessionId) } catch (error) { console.error('[ChatPage] cancel generation failed:', error) + if (props.sessionId === sessionId) { + toast({ + title: t('chat.input.stop'), + description: t('common.error.requestFailed'), + variant: 'destructive' + }) + } + } finally { + const next = new Set(stoppingSessionIds.value) + next.delete(sessionId) + stoppingSessionIds.value = next } } diff --git a/src/renderer/src/features/chat-page/composables/useComposerSubmit.ts b/src/renderer/src/features/chat-page/composables/useComposerSubmit.ts index a4cc7dc081..56835e11be 100644 --- a/src/renderer/src/features/chat-page/composables/useComposerSubmit.ts +++ b/src/renderer/src/features/chat-page/composables/useComposerSubmit.ts @@ -18,7 +18,7 @@ type PendingInputStore = ReturnType type ChatClientLike = { sendMessage: (sessionId: string, payload: SendMessageInput) => Promise - steerActiveTurn: (sessionId: string, payload: SendMessageInput) => Promise + steerActiveTurn: (sessionId: string, payload: SendMessageInput) => Promise<{ accepted: boolean }> } type SessionClientLike = { @@ -101,13 +101,16 @@ export function useComposerSubmit(options: UseComposerSubmitOptions) { const message = ref('') const attachedFiles = ref([]) + const steeringSessionIds = ref>(new Set()) let attachmentFilterToken = 0 const hasInputText = computed(() => Boolean(message.value.trim())) const hasAttachments = computed(() => attachedFiles.value.length > 0) const hasDraftInput = computed(() => hasInputText.value || hasAttachments.value) + const isSteering = computed(() => steeringSessionIds.value.has(options.sessionId())) const isQueueSubmitDisabled = computed( () => + isSteering.value || isSessionViewPreparing.value || isAcpWorkdirMissing.value || !hasDraftInput.value || @@ -116,6 +119,7 @@ export function useComposerSubmit(options: UseComposerSubmitOptions) { ) const isInputSubmitDisabled = computed( () => + isSteering.value || isSessionViewPreparing.value || isAcpWorkdirMissing.value || options.hasBlockingInteraction() || @@ -127,9 +131,20 @@ export function useComposerSubmit(options: UseComposerSubmitOptions) { isSessionViewPreparing.value || !isGenerating.value || isAcpWorkdirMissing.value || - options.hasBlockingInteraction() + options.hasBlockingInteraction() || + isSteering.value ) + function setSteering(sessionId: string, pending: boolean): void { + const next = new Set(steeringSessionIds.value) + if (pending) { + next.add(sessionId) + } else { + next.delete(sessionId) + } + steeringSessionIds.value = next + } + function notifyUnsupportedAudioAttachments( selection: { providerId: string; modelId: string }, rejectedAudioFiles: MessageFile[] @@ -200,6 +215,7 @@ export function useComposerSubmit(options: UseComposerSubmitOptions) { function canSubmitNow(): boolean { if (isReadOnlySession.value) return false + if (isSteering.value) return false if (isSessionViewPreparing.value) return false if (isAcpWorkdirMissing.value) return false if (options.hasBlockingInteraction()) return false @@ -360,24 +376,47 @@ export function useComposerSubmit(options: UseComposerSubmitOptions) { } async function onSteer() { - if (!canSubmitNow()) return const sessionId = options.sessionId() + if (!canSubmitNow() || disableQueueSteerAction.value) return + if (steeringSessionIds.value.has(sessionId)) return const restoreRequestId = options.currentRestoreRequestId() const text = message.value.trim() - const files = (await prepareFilesForCurrentModel([...attachedFiles.value])).map((f) => toRaw(f)) - if (!options.canWriteSessionView(sessionId, restoreRequestId)) return - if (!text && files.length === 0) return - const handledCompaction = await handleManualCompactionCommand(text, sessionId, restoreRequestId) - if (!options.canWriteSessionView(sessionId, restoreRequestId)) return - if (handledCompaction) { - return + const selectedFiles = [...attachedFiles.value] + if (!text && selectedFiles.length === 0) return + + setSteering(sessionId, true) + try { + const files = (await prepareFilesForCurrentModel(selectedFiles)).map((file) => toRaw(file)) + if (!options.canWriteSessionView(sessionId, restoreRequestId)) return + if (!text && files.length === 0) return + const handledCompaction = await handleManualCompactionCommand( + text, + sessionId, + restoreRequestId + ) + if (!options.canWriteSessionView(sessionId, restoreRequestId)) return + if (handledCompaction) return + + const result = await chatClient.steerActiveTurn(sessionId, withMessageSkills(text, files)) + if (!result.accepted) { + throw new Error('Steer request was not accepted') + } + options.beginPlanTurn(sessionId) + if (!options.canWriteSessionView(sessionId, restoreRequestId)) return + message.value = '' + attachedFiles.value = [] + clearComposerSkills() + } catch (error) { + console.error('[ChatPage] steer active turn failed:', error) + if (options.canWriteSessionView(sessionId, restoreRequestId)) { + toast({ + title: t('chat.pendingInput.steerFailed'), + variant: 'destructive' + }) + } + } finally { + setSteering(sessionId, false) } - options.beginPlanTurn(sessionId) - await chatClient.steerActiveTurn(sessionId, withMessageSkills(text, files)) - if (!options.canWriteSessionView(sessionId, restoreRequestId)) return - message.value = '' - attachedFiles.value = [] - clearComposerSkills() } async function onFilesChange(files: MessageFile[]) { @@ -399,6 +438,7 @@ export function useComposerSubmit(options: UseComposerSubmitOptions) { message, attachedFiles, hasDraftInput, + isSteering, isQueueSubmitDisabled, isInputSubmitDisabled, disableQueueSteerAction, diff --git a/src/renderer/src/stores/ui/session.ts b/src/renderer/src/stores/ui/session.ts index 0ed07f785c..f3e93356d0 100644 --- a/src/renderer/src/stores/ui/session.ts +++ b/src/renderer/src/stores/ui/session.ts @@ -303,6 +303,7 @@ export const useSessionStore = defineStore('session', () => { const sessionByIdRefreshRevisions = new Map() let targetedSessionCommitRevision = 0 const targetedSessionCommitRevisions = new Map() + const observedSessionStatuses = new Map() // Deleted sessions must stay absent while requests started before their deletion settle. // IDs are stable database identifiers, so they are safe tombstones for this store lifetime. const removedSessionIds = new Set() @@ -390,6 +391,24 @@ export const useSessionStore = defineStore('session', () => { bootstrapActiveSession.value = session } + const mergeObservedSessionStatus = (session: T): T => { + const observed = observedSessionStatuses.get(session.id) + if (!observed || session.status === observed.status) { + return session + } + + return { + ...session, + status: observed.status + } + } + + const mapSessionSnapshot = (session: SessionListItem | SessionWithState): UISession => + mergeObservedSessionStatus(mapToUISession(session)) + + const mapActiveSessionSnapshot = (session: SessionWithState): UIActiveSessionSummary => + mergeObservedSessionStatus(mapToUIActiveSessionSummary(session)) + const upsertSessions = (updates: UISession[]): void => { sessions.value = mergeSessions( sessions.value, @@ -428,6 +447,7 @@ export const useSessionStore = defineStore('session', () => { sessionFetchPromise = null for (const sessionId of targetIds) { removedSessionIds.add(sessionId) + observedSessionStatuses.delete(sessionId) sessionByIdRefreshRevisions.set(sessionId, ++sessionByIdsRefreshRevision) } sessions.value = sessions.value.filter((session) => !targetIds.has(session.id)) @@ -517,7 +537,18 @@ export const useSessionStore = defineStore('session', () => { agentStore.setSelectedAgent(targetAgentId) } - const applySessionStatus = (sessionId: string, status: string): void => { + const applySessionStatus = (sessionId: string, status: string, version?: number): void => { + if (version !== undefined) { + const observed = observedSessionStatuses.get(sessionId) + if (observed && version < observed.version) { + return + } + observedSessionStatuses.set(sessionId, { + version, + status: mapSessionStatus(status) + }) + } + messageStore.invalidateRecentSessionView(sessionId) const nextStatus = mapSessionStatus(status) const index = sessions.value.findIndex((session) => session.id === sessionId) @@ -557,11 +588,11 @@ export const useSessionStore = defineStore('session', () => { return } - const lightweightSession = mapToUISession(session) + const lightweightSession = mapSessionSnapshot(session) upsertSessions([lightweightSession]) if (activeSessionId.value !== session.id) return - activeSessionSummary.value = mapToUIActiveSessionSummary(session) + activeSessionSummary.value = mapActiveSessionSnapshot(session) bootstrapActiveSession.value = lightweightSession syncSelectedAgentToSession(session.id) } @@ -598,7 +629,9 @@ export const useSessionStore = defineStore('session', () => { setActiveSessionId(nextActiveSessionId) clearActiveSessionSummary() - updateBootstrapActiveSession(input.activeSession ? mapToUISession(input.activeSession) : null) + updateBootstrapActiveSession( + input.activeSession ? mapSessionSnapshot(input.activeSession) : null + ) syncSelectedAgentToSession(nextActiveSessionId) } @@ -631,7 +664,7 @@ export const useSessionStore = defineStore('session', () => { } const nextSessions = result.items - .map(mapToUISession) + .map(mapSessionSnapshot) .filter((session) => !removedSessionIds.has(session.id)) sessions.value = options.preserveExisting ? mergeSessions(sessions.value, nextSessions) @@ -674,7 +707,7 @@ export const useSessionStore = defineStore('session', () => { return } - upsertSessions(result.items.map(mapToUISession)) + upsertSessions(result.items.map(mapSessionSnapshot)) hasMore.value = result.hasMore nextCursor.value = result.nextCursor console.info( @@ -760,7 +793,7 @@ export const useSessionStore = defineStore('session', () => { } const acceptedSessions = acceptedItems - .map(mapToUISession) + .map(mapSessionSnapshot) .filter((session) => !removedSessionIds.has(session.id)) upsertSessions(acceptedSessions) if (acceptedSessions.length > 0) { @@ -785,7 +818,7 @@ export const useSessionStore = defineStore('session', () => { if (activeId) { const activeItem = acceptedItems.find((item) => item.id === activeId) if (activeItem) { - updateBootstrapActiveSession(mapToUISession(activeItem)) + updateBootstrapActiveSession(mapSessionSnapshot(activeItem)) syncSelectedAgentToSession(activeId) } } @@ -812,7 +845,7 @@ export const useSessionStore = defineStore('session', () => { const session = result.session const hasInitialTurn = input.message.trim().length > 0 || (input.files?.length ?? 0) > 0 const lightweightSession = { - ...mapToUISession(session), + ...mapSessionSnapshot(session), ...(hasInitialTurn ? { status: 'working' as const } : {}) } // Creation is durable even if the user has navigated elsewhere while it was pending. @@ -824,7 +857,7 @@ export const useSessionStore = defineStore('session', () => { setActiveSessionId(session.id) bootstrapActiveSession.value = lightweightSession activeSessionSummary.value = { - ...mapToUIActiveSessionSummary(session), + ...mapActiveSessionSnapshot(session), ...(hasInitialTurn ? { status: 'working' as const } : {}) } syncSelectedAgentToSession(session.id) @@ -977,7 +1010,7 @@ export const useSessionStore = defineStore('session', () => { error.value = null try { const updated = await sessionClient.setSessionModel(sessionId, providerId, modelId) - upsertSessions([mapToUISession(updated)]) + upsertSessions([mapSessionSnapshot(updated)]) if (activeSessionId.value === sessionId) { applyRestoredSession(updated) } @@ -1007,7 +1040,7 @@ export const useSessionStore = defineStore('session', () => { error.value = null try { const updated = await sessionClient.setSessionProjectDir(sessionId, projectDir) - upsertSessions([mapToUISession(updated)]) + upsertSessions([mapSessionSnapshot(updated)]) if (activeSessionId.value === sessionId) { applyRestoredSession(updated) } @@ -1021,7 +1054,7 @@ export const useSessionStore = defineStore('session', () => { error.value = null try { const updated = await sessionClient.moveSessionToAgent(sessionId, toAgentId) - upsertSessions([mapToUISession(updated)]) + upsertSessions([mapSessionSnapshot(updated)]) if (activeSessionId.value === sessionId) { applyRestoredSession(updated) syncSelectedAgentToSession(sessionId) @@ -1200,8 +1233,8 @@ export const useSessionStore = defineStore('session', () => { setActiveSessionId(null) pageRouter.goToNewThread() }, - onStatusChanged: (sessionId, status) => { - applySessionStatus(sessionId, status) + onStatusChanged: (sessionId, status, version) => { + applySessionStatus(sessionId, status, version) } }) registerStoreCleanup(sessionIpcBinding.cleanup) diff --git a/src/renderer/src/stores/ui/sessionIpc.ts b/src/renderer/src/stores/ui/sessionIpc.ts index 9f78906a13..c45eb31c12 100644 --- a/src/renderer/src/stores/ui/sessionIpc.ts +++ b/src/renderer/src/stores/ui/sessionIpc.ts @@ -7,7 +7,7 @@ interface BindSessionStoreIpcOptions { removeSessions: (sessionIds: string[]) => void onActivated: (sessionId: string) => void | Promise onDeactivated: () => void - onStatusChanged: (sessionId: string, status: string) => void + onStatusChanged: (sessionId: string, status: string, version: number) => void } type TargetedSessionUpdate = { @@ -91,7 +91,7 @@ export function bindSessionStoreIpc(options: BindSessionStoreIpcOptions): Sessio } }), sessionClient.onStatusChanged((payload) => { - options.onStatusChanged(payload.sessionId, payload.status) + options.onStatusChanged(payload.sessionId, payload.status, payload.version) }) ] diff --git a/test/renderer/components/ChatInputToolbar.test.ts b/test/renderer/components/ChatInputToolbar.test.ts index 5b48f706e3..8cc42a033b 100644 --- a/test/renderer/components/ChatInputToolbar.test.ts +++ b/test/renderer/components/ChatInputToolbar.test.ts @@ -110,6 +110,60 @@ describe('ChatInputToolbar', () => { expect(wrapper.emitted('steer')).toEqual([[]]) }) + it('disables steer when the active turn cannot be interrupted', async () => { + const ChatInputToolbar = (await import('@/components/chat/ChatInputToolbar.vue')).default + const wrapper = mount(ChatInputToolbar, { + props: { + isGenerating: true, + hasInput: true, + steerDisabled: true + } + }) + + const steerButton = wrapper.get('[data-testid="chat-steer-button"]') + expect(steerButton.attributes('disabled')).toBeDefined() + expect(wrapper.text()).toContain('chat.pendingInput.steerUnavailable') + + await steerButton.trigger('click') + expect(wrapper.emitted('steer')).toBeUndefined() + }) + + it('renders progress and blocks repeated steer clicks while pending', async () => { + const ChatInputToolbar = (await import('@/components/chat/ChatInputToolbar.vue')).default + const wrapper = mount(ChatInputToolbar, { + props: { + isGenerating: true, + hasInput: true, + isSteering: true + } + }) + + const steerButton = wrapper.get('[data-testid="chat-steer-button"]') + expect(steerButton.attributes('disabled')).toBeDefined() + expect(steerButton.attributes('aria-busy')).toBe('true') + expect(steerButton.find('[role="status"]').exists()).toBe(true) + expect(wrapper.find('[data-icon="lucide:compass"]').exists()).toBe(false) + }) + + it('renders progress and blocks repeated stop clicks while pending', async () => { + const ChatInputToolbar = (await import('@/components/chat/ChatInputToolbar.vue')).default + const wrapper = mount(ChatInputToolbar, { + props: { + isGenerating: true, + hasInput: false, + isStopping: true + } + }) + + const stopButton = wrapper.get('[data-testid="chat-stop-button"]') + expect(stopButton.attributes('disabled')).toBeDefined() + expect(stopButton.attributes('aria-busy')).toBe('true') + expect(stopButton.find('[role="status"]').exists()).toBe(true) + + await stopButton.trigger('click') + expect(wrapper.emitted('stop')).toBeUndefined() + }) + it('emits voice-input and switches icon while listening', async () => { const ChatInputToolbar = (await import('@/components/chat/ChatInputToolbar.vue')).default const wrapper = mount(ChatInputToolbar, { diff --git a/test/renderer/components/ChatPage.test.ts b/test/renderer/components/ChatPage.test.ts index 4804dd81ae..cb5e564da5 100644 --- a/test/renderer/components/ChatPage.test.ts +++ b/test/renderer/components/ChatPage.test.ts @@ -463,11 +463,23 @@ const setup = async (options: SetupOptions = {}) => { queueDisabled: { type: Boolean, default: false + }, + steerDisabled: { + type: Boolean, + default: false + }, + isSteering: { + type: Boolean, + default: false + }, + isStopping: { + type: Boolean, + default: false } }, emits: ['attach', 'queue', 'send', 'steer', 'stop'], template: - '
' + '
' }) })) vi.doMock('@/components/chat/AgentProgressFloat.vue', () => ({ @@ -2805,11 +2817,80 @@ describe('ChatPage', () => { expect(inputBox.props('queueSubmitDisabled')).toBe(true) expect(toolbar.props('sendDisabled')).toBe(true) expect(toolbar.props('queueDisabled')).toBe(true) - // Steer button is always available when generating with input + expect(toolbar.props('steerDisabled')).toBe(false) const steerButton = toolbar.find('[data-testid="chat-steer-button"]') expect(steerButton.exists()).toBe(true) }) + it('disables composer steer whenever its submit guard would reject it', async () => { + const { wrapper, chatClient } = await setup({ + isStreaming: true, + activeSessionPatch: { + projectDir: '' + } + }) + const inputBox = wrapper.findComponent({ name: 'ChatInputBox' }) + inputBox.vm.$emit('update:modelValue', 'tighten the answer') + await flushPromises() + + const toolbar = wrapper.findComponent({ name: 'ChatInputToolbar' }) + expect(toolbar.props('steerDisabled')).toBe(true) + + toolbar.vm.$emit('steer') + await flushPromises() + expect(chatClient.steerActiveTurn).not.toHaveBeenCalled() + }) + + it('blocks duplicate stop requests while cancellation is pending', async () => { + const stopping = createDeferred<{ stopped: boolean }>() + const { wrapper, chatClient, agentPlanStore } = await setup({ isStreaming: true }) + chatClient.stopStream.mockReturnValueOnce(stopping.promise) + const toolbar = wrapper.findComponent({ name: 'ChatInputToolbar' }) + + toolbar.vm.$emit('stop') + await flushPromises() + expect(toolbar.props('isStopping')).toBe(true) + + toolbar.vm.$emit('stop') + await flushPromises() + expect(chatClient.stopStream).toHaveBeenCalledTimes(1) + + stopping.resolve({ stopped: true }) + await flushPromises() + + expect(agentPlanStore.freezeActive).toHaveBeenCalledWith('s1') + expect(toolbar.props('isStopping')).toBe(false) + }) + + it('reports stop responses that did not cancel generation', async () => { + const { wrapper, chatClient, agentPlanStore, toast } = await setup({ isStreaming: true }) + chatClient.stopStream.mockResolvedValueOnce({ stopped: false }) + + wrapper.findComponent({ name: 'ChatInputToolbar' }).vm.$emit('stop') + await flushPromises() + + expect(agentPlanStore.freezeActive).not.toHaveBeenCalled() + expect(toast).toHaveBeenCalledWith({ + title: 'chat.input.stop', + description: 'common.error.requestFailed', + variant: 'destructive' + }) + }) + + it('reports rejected stop requests', async () => { + const { wrapper, chatClient, toast } = await setup({ isStreaming: true }) + chatClient.stopStream.mockRejectedValueOnce(new Error('boom')) + + wrapper.findComponent({ name: 'ChatInputToolbar' }).vm.$emit('stop') + await flushPromises() + + expect(toast).toHaveBeenCalledWith({ + title: 'chat.input.stop', + description: 'common.error.requestFailed', + variant: 'destructive' + }) + }) + it('queues drafts explicitly while a generation is running', async () => { const { wrapper, pendingInputStore, chatClient } = await setup({ isStreaming: true diff --git a/test/renderer/features/chat-page/composables/useComposerSubmit.test.ts b/test/renderer/features/chat-page/composables/useComposerSubmit.test.ts new file mode 100644 index 0000000000..7c315942cc --- /dev/null +++ b/test/renderer/features/chat-page/composables/useComposerSubmit.test.ts @@ -0,0 +1,143 @@ +import { computed, ref } from 'vue' +import { describe, expect, it, vi } from 'vitest' +import { useComposerSubmit } from '@/features/chat-page/composables/useComposerSubmit' + +function createDeferred() { + let resolve!: (value: T) => void + const promise = new Promise((innerResolve) => { + resolve = innerResolve + }) + return { promise, resolve } +} + +function setup() { + const sessionId = ref('s1') + const restoreRequestId = ref(1) + const isAcpWorkdirMissing = ref(false) + const steerActiveTurn = vi.fn(async () => ({ accepted: true })) + const beginPlanTurn = vi.fn() + const toast = vi.fn() + + const composer = useComposerSubmit({ + sessionId: () => sessionId.value, + currentRestoreRequestId: () => restoreRequestId.value, + canWriteSessionView: (targetSessionId, targetRequestId) => + targetSessionId === sessionId.value && targetRequestId === restoreRequestId.value, + messageStore: { + addOptimisticUserMessage: vi.fn(), + removeOptimisticMessage: vi.fn() + } as never, + sessionStore: { + activeSession: { providerId: 'openai' } + } as never, + modelStore: { + findChatSelectableModel: vi.fn() + } as never, + pendingInputStore: { + isAtCapacity: false, + queueInput: vi.fn() + } as never, + chatClient: { + sendMessage: vi.fn(), + steerActiveTurn + }, + sessionClient: { + compactSession: vi.fn(async () => ({ compacted: true })) + }, + modelClient: { + getCapabilities: vi.fn(async () => ({ supportsAudioInput: true })) + }, + chatInputRef: ref(null), + isReadOnlySession: computed(() => false), + isSessionViewPreparing: computed(() => false), + isAcpWorkdirMissing: computed(() => isAcpWorkdirMissing.value), + isGenerating: computed(() => true), + hasBlockingInteraction: () => false, + getActiveModelSelection: () => ({ providerId: 'openai', modelId: 'gpt-4' }), + createPendingAssistantPlaceholder: vi.fn(() => 'pending-assistant'), + clearPendingAssistantPlaceholder: vi.fn(), + beginPlanTurn, + schedulePostSubmitScrollToBottom: vi.fn(), + loadMessagesForSession: vi.fn(async () => null), + applyRestoredSessionSummary: vi.fn(), + toast, + t: (key) => key + }) + + return { + composer, + sessionId, + restoreRequestId, + isAcpWorkdirMissing, + steerActiveTurn, + beginPlanTurn, + toast + } +} + +describe('useComposerSubmit steer', () => { + it('blocks duplicates and clears the draft only after acceptance', async () => { + const steering = createDeferred<{ accepted: boolean }>() + const { composer, steerActiveTurn, beginPlanTurn } = setup() + steerActiveTurn.mockReturnValueOnce(steering.promise) + composer.message.value = 'tighten the answer' + + const request = composer.onSteer() + await vi.waitFor(() => expect(steerActiveTurn).toHaveBeenCalledTimes(1)) + + expect(composer.isSteering.value).toBe(true) + expect(composer.disableQueueSteerAction.value).toBe(true) + expect(composer.isQueueSubmitDisabled.value).toBe(true) + + await composer.onSteer() + expect(steerActiveTurn).toHaveBeenCalledTimes(1) + + steering.resolve({ accepted: true }) + await request + + expect(steerActiveTurn).toHaveBeenCalledWith('s1', { + text: 'tighten the answer', + files: [] + }) + expect(beginPlanTurn).toHaveBeenCalledWith('s1') + expect(composer.message.value).toBe('') + expect(composer.isSteering.value).toBe(false) + }) + + it('retains the draft and reports a failed request', async () => { + const { composer, steerActiveTurn, beginPlanTurn, toast } = setup() + steerActiveTurn.mockRejectedValueOnce(new Error('boom')) + composer.message.value = 'keep this draft' + + await composer.onSteer() + + expect(beginPlanTurn).not.toHaveBeenCalled() + expect(composer.message.value).toBe('keep this draft') + expect(toast).toHaveBeenCalledWith({ + title: 'chat.pendingInput.steerFailed', + variant: 'destructive' + }) + }) + + it('does not clear a new draft when an old A-B-A request resolves', async () => { + const steering = createDeferred<{ accepted: boolean }>() + const { composer, sessionId, restoreRequestId, steerActiveTurn, beginPlanTurn } = setup() + steerActiveTurn.mockReturnValueOnce(steering.promise) + composer.message.value = 'old draft' + + const request = composer.onSteer() + await vi.waitFor(() => expect(steerActiveTurn).toHaveBeenCalledTimes(1)) + + sessionId.value = 's2' + restoreRequestId.value += 1 + sessionId.value = 's1' + restoreRequestId.value += 1 + composer.message.value = 'new draft' + + steering.resolve({ accepted: true }) + await request + + expect(beginPlanTurn).toHaveBeenCalledWith('s1') + expect(composer.message.value).toBe('new draft') + }) +}) diff --git a/test/renderer/stores/sessionStore.test.ts b/test/renderer/stores/sessionStore.test.ts index ee043ad1ba..6fc328e54c 100644 --- a/test/renderer/stores/sessionStore.test.ts +++ b/test/renderer/stores/sessionStore.test.ts @@ -1502,7 +1502,8 @@ describe('sessionStore streaming cleanup', () => { emitSessionStatusChange({ sessionId: 'session-status', - status: 'generating' + status: 'generating', + version: 1 }) expect(store.activeSession.value?.status).toBe('working') @@ -1510,12 +1511,67 @@ describe('sessionStore streaming cleanup', () => { emitSessionStatusChange({ sessionId: 'session-status', - status: 'idle' + status: 'idle', + version: 2 }) expect(store.activeSession.value?.status).toBe('none') }) + it('does not let a stale restore snapshot overwrite a newer idle event', async () => { + const { store, emitSessionStatusChange } = await setupStore() + store.activeSessionId.value = 'session-status' + store.sessions.value = [createSession({ id: 'session-status', status: 'working' })] + + emitSessionStatusChange({ + sessionId: 'session-status', + status: 'idle', + version: 2 + }) + store.applyRestoredSession( + createSession({ id: 'session-status', status: 'generating', updatedAt: 2 }) + ) + + expect(store.activeSession.value?.status).toBe('none') + }) + + it('does not let a stale restore snapshot overwrite a newer generating event', async () => { + const { store, emitSessionStatusChange } = await setupStore() + store.activeSessionId.value = 'session-status' + store.sessions.value = [createSession({ id: 'session-status', status: 'none' })] + + emitSessionStatusChange({ + sessionId: 'session-status', + status: 'generating', + version: 2 + }) + store.applyRestoredSession( + createSession({ id: 'session-status', status: 'idle', updatedAt: 2 }) + ) + + expect(store.activeSession.value?.status).toBe('working') + }) + + it('ignores status events older than the latest observed version', async () => { + const { store, emitSessionStatusChange, invalidateRecentSessionView } = await setupStore() + store.activeSessionId.value = 'session-status' + store.sessions.value = [createSession({ id: 'session-status', status: 'none' })] + + emitSessionStatusChange({ + sessionId: 'session-status', + status: 'generating', + version: 2 + }) + emitSessionStatusChange({ + sessionId: 'session-status', + status: 'idle', + version: 1 + }) + + expect(store.activeSession.value?.status).toBe('working') + expect(invalidateRecentSessionView).toHaveBeenCalledTimes(1) + }) + it('purges message tracking when a session is permanently removed', async () => { const { store, emitSessionUpdate, invalidateRecentSessionView, purgeSessionTracking } = await setupStore() From 1582106ae8b039390bcde0f339234925127edb63 Mon Sep 17 00:00:00 2001 From: zhangmo8 Date: Mon, 20 Jul 2026 20:38:17 +0800 Subject: [PATCH 05/12] refactor(renderer): apply review feedback Address coderabbit review comments on PR #2000: - ChatInputToolbar.test: trigger steer click and assert no emission while pending (was only checking static attributes) - session store: rename isNewerSessionUpdate -> isStaleOrSameSessionUpdate so the predicate name matches its "skip stale/equal update" semantics - extract shared createDeferred helper (with reject) to test/renderer/utils, replacing three local copies in useComposerSubmit/useMessageActions/ useToolInteraction tests - useMessageVirtualization.test: cover layoutSegments provided but returning null at runtime (inline-stream reinsertion fallback) - SettingsApp.test: reset document.documentElement lang/dir in afterEach to match sibling documentAppearance cleanup --- src/renderer/src/stores/ui/session.ts | 4 +-- .../components/ChatInputToolbar.test.ts | 3 +++ test/renderer/components/SettingsApp.test.ts | 2 ++ .../useMessageVirtualization.test.ts | 25 +++++++++++++++++++ .../composables/useComposerSubmit.test.ts | 9 +------ .../composables/useMessageActions.test.ts | 17 +++---------- .../composables/useToolInteraction.test.ts | 14 ++--------- test/renderer/utils/deferred.ts | 15 +++++++++++ 8 files changed, 54 insertions(+), 35 deletions(-) create mode 100644 test/renderer/utils/deferred.ts diff --git a/src/renderer/src/stores/ui/session.ts b/src/renderer/src/stores/ui/session.ts index f3e93356d0..1233760da7 100644 --- a/src/renderer/src/stores/ui/session.ts +++ b/src/renderer/src/stores/ui/session.ts @@ -247,7 +247,7 @@ function sortSessions(items: UISession[]): UISession[] { }) } -function isNewerSessionUpdate(existing: UISession, update: UISession): boolean { +function isStaleOrSameSessionUpdate(existing: UISession, update: UISession): boolean { const existingUpdatedAt = existing.updatedAt const updateUpdatedAt = update.updatedAt // `updatedAt` is the only cross-window ordering signal for lightweight session @@ -265,7 +265,7 @@ function mergeSessions(current: UISession[], updates: UISession[]): UISession[] for (const update of updates) { const existing = next.get(update.id) - if (existing && isNewerSessionUpdate(existing, update)) { + if (existing && isStaleOrSameSessionUpdate(existing, update)) { continue } next.set(update.id, existing ? { ...existing, ...update } : update) diff --git a/test/renderer/components/ChatInputToolbar.test.ts b/test/renderer/components/ChatInputToolbar.test.ts index 8cc42a033b..63733dcbc3 100644 --- a/test/renderer/components/ChatInputToolbar.test.ts +++ b/test/renderer/components/ChatInputToolbar.test.ts @@ -143,6 +143,9 @@ describe('ChatInputToolbar', () => { expect(steerButton.attributes('aria-busy')).toBe('true') expect(steerButton.find('[role="status"]').exists()).toBe(true) expect(wrapper.find('[data-icon="lucide:compass"]').exists()).toBe(false) + + await steerButton.trigger('click') + expect(wrapper.emitted('steer')).toBeUndefined() }) it('renders progress and blocks repeated stop clicks while pending', async () => { diff --git a/test/renderer/components/SettingsApp.test.ts b/test/renderer/components/SettingsApp.test.ts index 0e76119672..c8236f0b27 100644 --- a/test/renderer/components/SettingsApp.test.ts +++ b/test/renderer/components/SettingsApp.test.ts @@ -114,6 +114,8 @@ afterEach(() => { appRuntimeClientMock.cleanupMcpInstall.mockClear() appRuntimeClientMock.onMcpInstallRequested.mockClear() configClientMock.getLanguage.mockReset().mockResolvedValue('zh-CN') + document.documentElement.lang = '' + document.documentElement.dir = '' }) describe('Settings App', () => { diff --git a/test/renderer/composables/useMessageVirtualization.test.ts b/test/renderer/composables/useMessageVirtualization.test.ts index 6e3c6065cc..e6c5227ac3 100644 --- a/test/renderer/composables/useMessageVirtualization.test.ts +++ b/test/renderer/composables/useMessageVirtualization.test.ts @@ -182,4 +182,29 @@ describe('useMessageVirtualization', () => { expect(virtualization.visibleDisplayMessages.value).toEqual(messages.value) }) + + it('falls back to the complete display list when layoutSegments is provided but returns null', () => { + const messages = ref([createUserMessage('message-1', 1)]) + const layoutSegments = computed(() => null) + const displayMessages = computed(() => messages.value) + const messageWindow = useMessageWindow({ messages: displayMessages, layoutSegments }) + const virtualization = useMessageVirtualization({ + viewport: ref(null), + displayMessages, + layoutSegments, + messageWindow, + windowingThreshold: 160, + initialWindowCount: 90, + overscanPx: 2400, + getWindowOriginTop: () => null, + isListScrolling: ref(false), + isBottomFollowingMode: () => false, + scrollToBottom: () => undefined, + requestAnchorScroll: () => undefined, + currentScrollMode: () => 'idle' + }) + + expect(layoutSegments.value).toBeNull() + expect(virtualization.visibleDisplayMessages.value).toEqual(messages.value) + }) }) diff --git a/test/renderer/features/chat-page/composables/useComposerSubmit.test.ts b/test/renderer/features/chat-page/composables/useComposerSubmit.test.ts index 7c315942cc..d6d9f7f73b 100644 --- a/test/renderer/features/chat-page/composables/useComposerSubmit.test.ts +++ b/test/renderer/features/chat-page/composables/useComposerSubmit.test.ts @@ -1,14 +1,7 @@ import { computed, ref } from 'vue' import { describe, expect, it, vi } from 'vitest' import { useComposerSubmit } from '@/features/chat-page/composables/useComposerSubmit' - -function createDeferred() { - let resolve!: (value: T) => void - const promise = new Promise((innerResolve) => { - resolve = innerResolve - }) - return { promise, resolve } -} +import { createDeferred } from '../../../utils/deferred' function setup() { const sessionId = ref('s1') diff --git a/test/renderer/features/chat-page/composables/useMessageActions.test.ts b/test/renderer/features/chat-page/composables/useMessageActions.test.ts index f26be7c9fd..05965d9596 100644 --- a/test/renderer/features/chat-page/composables/useMessageActions.test.ts +++ b/test/renderer/features/chat-page/composables/useMessageActions.test.ts @@ -1,16 +1,7 @@ import { computed, effectScope, ref } from 'vue' import { beforeEach, describe, expect, it, vi } from 'vitest' import { useMessageActions } from '@/features/chat-page/composables/useMessageActions' - -function deferred() { - let resolve!: (value: T) => void - let reject!: (reason?: unknown) => void - const promise = new Promise((promiseResolve, promiseReject) => { - resolve = promiseResolve - reject = promiseReject - }) - return { promise, resolve, reject } -} +import { createDeferred } from '../../../utils/deferred' function createHarness() { const sessionId = ref('s1') @@ -104,9 +95,9 @@ describe('useMessageActions', () => { it('does not restore a stale session after retry or delete completes', async () => { const harness = createHarness() - const retry = deferred() - const deleteRequest = deferred() - const restore = deferred() + const retry = createDeferred() + const deleteRequest = createDeferred() + const restore = createDeferred() const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) harness.sessionClient.retryMessage.mockReturnValueOnce(retry.promise) harness.sessionClient.deleteMessage.mockReturnValueOnce(deleteRequest.promise) diff --git a/test/renderer/features/chat-page/composables/useToolInteraction.test.ts b/test/renderer/features/chat-page/composables/useToolInteraction.test.ts index 9a1cf53075..ef656e9a9e 100644 --- a/test/renderer/features/chat-page/composables/useToolInteraction.test.ts +++ b/test/renderer/features/chat-page/composables/useToolInteraction.test.ts @@ -1,17 +1,7 @@ import { computed, effectScope, ref } from 'vue' import { beforeEach, describe, expect, it, vi } from 'vitest' import { useToolInteraction } from '@/features/chat-page/composables/useToolInteraction' - -function deferred() { - let resolve!: (value: T) => void - let reject!: (reason?: unknown) => void - const promise = new Promise((promiseResolve, promiseReject) => { - resolve = promiseResolve - reject = promiseReject - }) - - return { promise, resolve, reject } -} +import { createDeferred } from '../../../utils/deferred' function createAssistantMessage(id: string, blocks: unknown[]) { return { @@ -148,7 +138,7 @@ describe('useToolInteraction', () => { }) it('submits one response at a time and refreshes the current page session afterwards', async () => { - const response = deferred<{ handledInline?: boolean }>() + const response = createDeferred<{ handledInline?: boolean }>() const harness = createHarness([ createAssistantMessage('m1', [ { diff --git a/test/renderer/utils/deferred.ts b/test/renderer/utils/deferred.ts new file mode 100644 index 0000000000..2e5d22d9b0 --- /dev/null +++ b/test/renderer/utils/deferred.ts @@ -0,0 +1,15 @@ +export interface Deferred { + promise: Promise + resolve: (value: T) => void + reject: (reason?: unknown) => void +} + +export function createDeferred(): Deferred { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} From 2c6c685881be45cb548eb42a29270d0f4ba8f516 Mon Sep 17 00:00:00 2001 From: zhangmo8 Date: Tue, 21 Jul 2026 11:15:24 +0800 Subject: [PATCH 06/12] refactor(renderer): harden renderer state races --- .../renderer-scope-optimization/spec.md | 10 ++ .../renderer-scope-optimization/tasks.md | 6 + .../src/apps/chat-main/ChatMainApp.vue | 27 +--- src/renderer/src/pages/NewThreadPage.vue | 13 +- src/renderer/src/stores/floatingButton.ts | 71 +++++++---- src/renderer/src/stores/language.ts | 9 +- test/renderer/pages/NewThreadPage.test.ts | 74 +++++++++-- .../stores/floatingButtonStore.test.ts | 119 ++++++++++++++++++ test/renderer/stores/languageStore.test.ts | 14 +++ test/renderer/stores/projectStore.test.ts | 19 +++ 10 files changed, 299 insertions(+), 63 deletions(-) create mode 100644 test/renderer/stores/floatingButtonStore.test.ts diff --git a/docs/architecture/renderer-scope-optimization/spec.md b/docs/architecture/renderer-scope-optimization/spec.md index 99de7db4de..e452e656bf 100644 --- a/docs/architecture/renderer-scope-optimization/spec.md +++ b/docs/architecture/renderer-scope-optimization/spec.md @@ -57,6 +57,16 @@ Renderer 已有独立的 main、settings、floating、splash 和 browser-overlay - 搜索防抖只延后计算,不能使 Escape、关闭、箭头导航使用旧索引。 - 外观 bootstrap 的初始读取和订阅顺序需兼容各 renderer 的 preload API 差异。 +## 最终复审补充 + +最终 renderer 复审确认并收敛了三个与本目标一致的状态边界: + +- start deeplink 仅保留异步边界后的 token 校验,移除无法引入交错的同步重复 guard; +- floating button 在读取初始 snapshot 前订阅跨窗口 IPC 更新,且本地写失败只回滚到实际的前值; +- DeepChat agent 默认配置异步返回时,不得覆盖等待期间用户手动选择的新项目。 + +同时修复了 language store 对显式 `ltr` direction 的丢失,以及 project snapshot 过期失败错误可能覆盖较新本地 mutation 的情况。 + ## GitHub Issue 本工作由用户直接要求提交 PR;未请求创建或同步 GitHub Issue。 diff --git a/docs/architecture/renderer-scope-optimization/tasks.md b/docs/architecture/renderer-scope-optimization/tasks.md index 87c18885e5..36b47efa9b 100644 --- a/docs/architecture/renderer-scope-optimization/tasks.md +++ b/docs/architecture/renderer-scope-optimization/tasks.md @@ -16,3 +16,9 @@ - [x] 对每个切片执行独立 review,修复发现的问题并继续寻找剩余优化。 - [x] 执行最终 format、i18n、lint、typecheck、renderer tests 与 architecture baseline check。 - [x] 审阅最终 diff 并提交 follow-up commit;已有 base 为 `dev` 的 PR #2000。 +- [x] 最终复审收敛 start deeplink 的冗余同步 token 守卫,保留所有异步边界后的因果校验。 +- [x] 修复浮窗按钮 snapshot/IPC 初始化竞态及写入失败时的精确回滚,并补充 scope cleanup 回归测试。 +- [x] 防止异步 DeepChat agent 默认配置覆盖请求期间用户手动选择的项目。 +- [x] 补齐显式 LTR 语言方向与过期项目快照错误抑制的回归覆盖。 +- [x] 再次执行 format、i18n、lint、typecheck、定向 renderer tests 与 architecture baseline check,并更新 PR #2000。 +- [x] 复跑完整 renderer suite:默认并发下出现广泛、非确定性超时;双 worker 串行化后仅 `ChatPage` 单测偶发超时,该文件单独复跑通过。保留结果供 CI 验证,不将其误报为通过。 diff --git a/src/renderer/src/apps/chat-main/ChatMainApp.vue b/src/renderer/src/apps/chat-main/ChatMainApp.vue index 10be2eeb7e..721919d59a 100644 --- a/src/renderer/src/apps/chat-main/ChatMainApp.vue +++ b/src/renderer/src/apps/chat-main/ChatMainApp.vue @@ -311,15 +311,11 @@ const activatePendingStartDeeplink = async () => { } const isCurrentPendingStartDeeplink = () => draftStore.pendingStartDeeplink?.token === token - if (!isCurrentPendingStartDeeplink()) { - return - } - processingStartDeeplinkToken.value = token try { const initComplete = Boolean(await configClient.getSetting('init_complete')) - if (!isCurrentPendingStartDeeplink() || !initComplete) { + if (!initComplete || !isCurrentPendingStartDeeplink()) { return } @@ -329,29 +325,15 @@ const activatePendingStartDeeplink = async () => { } if (router.currentRoute.value.name !== 'chat') { - if (!isCurrentPendingStartDeeplink()) { - return - } - await router.push({ name: 'chat' }) if (!isCurrentPendingStartDeeplink()) { return } } - if (!isCurrentPendingStartDeeplink()) { - return - } agentStore.setSelectedAgent('deepchat') - if (!isCurrentPendingStartDeeplink()) { - return - } if (sessionStore.hasActiveSession) { - if (!isCurrentPendingStartDeeplink()) { - return - } - await sessionStore.closeSession() if (!isCurrentPendingStartDeeplink()) { return @@ -361,14 +343,7 @@ const activatePendingStartDeeplink = async () => { return } - if (!isCurrentPendingStartDeeplink()) { - return - } pageRouterStore.goToNewThread({ refresh: true }) - if (!isCurrentPendingStartDeeplink()) { - return - } - processedStartDeeplinkToken.value = token } finally { if (processingStartDeeplinkToken.value === token) { diff --git a/src/renderer/src/pages/NewThreadPage.vue b/src/renderer/src/pages/NewThreadPage.vue index bf88827238..e9a60e7d77 100644 --- a/src/renderer/src/pages/NewThreadPage.vue +++ b/src/renderer/src/pages/NewThreadPage.vue @@ -937,6 +937,7 @@ const applyDraftDefaultsForSelectedAgent = async (requestSeq: number): Promise { @@ -8,6 +8,9 @@ export const useFloatingButtonStore = defineStore('floatingButton', () => { // 悬浮按钮是否启用的状态 const enabled = ref(false) let listenerRegistered = false + let stateRevision = 0 + let initialization: Promise | null = null + let removeFloatingButtonListener: (() => void) | null = null // 获取悬浮按钮启用状态 const getFloatingButtonEnabled = async (): Promise => { @@ -19,44 +22,68 @@ export const useFloatingButtonStore = defineStore('floatingButton', () => { } } + const setupFloatingButtonListener = () => { + if (listenerRegistered) { + return + } + + listenerRegistered = true + removeFloatingButtonListener = configClient.onFloatingButtonChanged((payload) => { + stateRevision += 1 + enabled.value = Boolean(payload.enabled) + }) + } + // 设置悬浮按钮启用状态 const setFloatingButtonEnabled = async (value: boolean) => { + const previousEnabled = enabled.value + const requestRevision = ++stateRevision + enabled.value = Boolean(value) try { - enabled.value = Boolean(value) await configClient.setFloatingButtonEnabled(value) } catch (error) { + if (requestRevision === stateRevision) { + enabled.value = previousEnabled + } console.error('Failed to set floating button enabled status:', error) - // 如果设置失败,回滚本地状态 - enabled.value = !value } } // 初始化状态 const initializeState = async () => { - try { - const currentEnabled = await getFloatingButtonEnabled() - enabled.value = currentEnabled - setupFloatingButtonListener() - } catch (error) { - console.error('Failed to initialize floating button state:', error) - enabled.value = false + if (initialization) { + return initialization } - } - const setupFloatingButtonListener = () => { - if (listenerRegistered) { - return - } + const task = (async () => { + try { + // Subscribe before reading the snapshot so an IPC update cannot be lost. + setupFloatingButtonListener() + const snapshotRevision = stateRevision + const currentEnabled = await getFloatingButtonEnabled() + if (snapshotRevision === stateRevision) { + enabled.value = currentEnabled + } + } catch (error) { + console.error('Failed to initialize floating button state:', error) + } + })() - listenerRegistered = true - configClient.onFloatingButtonChanged((payload) => { - enabled.value = Boolean(payload.enabled) - }) + initialization = task + return task } // 在组件挂载时初始化 - onMounted(async () => { - await initializeState() + onMounted(() => { + void initializeState() + }) + + onScopeDispose(() => { + removeFloatingButtonListener?.() + removeFloatingButtonListener = null + listenerRegistered = false + initialization = null + stateRevision += 1 }) return { diff --git a/src/renderer/src/stores/language.ts b/src/renderer/src/stores/language.ts index c629a1a930..60f4c55185 100644 --- a/src/renderer/src/stores/language.ts +++ b/src/renderer/src/stores/language.ts @@ -13,7 +13,7 @@ export const useLanguageStore = defineStore('language', () => { const language = shallowRef('system') const configClient = createConfigClient() const initialLocale = resolveSupportedLocale(locale.value) - const dir = shallowRef<'auto' | 'rtl'>(RTL_LIST.includes(initialLocale) ? 'rtl' : 'auto') + const dir = shallowRef<'auto' | 'rtl' | 'ltr'>(RTL_LIST.includes(initialLocale) ? 'rtl' : 'auto') let transitionRevision = 0 let updateRequestRevision = 0 let removeLanguageListener: (() => void) | undefined @@ -29,7 +29,12 @@ export const useLanguageStore = defineStore('language', () => { setLocaleMessage(resolvedLocale, messages) locale.value = resolvedLocale language.value = state.requestedLanguage || 'system' - dir.value = state.direction === 'rtl' || RTL_LIST.includes(resolvedLocale) ? 'rtl' : 'auto' + dir.value = + state.direction === 'rtl' || RTL_LIST.includes(resolvedLocale) + ? 'rtl' + : state.direction === 'ltr' + ? 'ltr' + : 'auto' return true } catch (error) { if (revision === transitionRevision) { diff --git a/test/renderer/pages/NewThreadPage.test.ts b/test/renderer/pages/NewThreadPage.test.ts index 8cc0a541a4..43c5a5f99c 100644 --- a/test/renderer/pages/NewThreadPage.test.ts +++ b/test/renderer/pages/NewThreadPage.test.ts @@ -10,6 +10,14 @@ const setup = async ( modelInitialized?: boolean initializeModel?: () => Promise createSession?: () => Promise + resolveDeepChatAgentConfig?: () => Promise<{ + defaultModelPreset?: { providerId: string; modelId: string } + defaultProjectPath?: string + systemPrompt: string + permissionMode: 'default' | 'full_access' + disabledAgentTools: string[] + }> + awaitReady?: boolean } ) => { vi.resetModules() @@ -122,15 +130,19 @@ const setup = async ( }) const configClient = { getSetting: vi.fn().mockResolvedValue(undefined), - resolveDeepChatAgentConfig: vi.fn().mockResolvedValue({ - defaultModelPreset: { - providerId: 'openai', - modelId: 'gpt-4o-mini' - }, - systemPrompt: 'Default system prompt', - permissionMode: 'full_access', - disabledAgentTools: [] - }) + resolveDeepChatAgentConfig: vi.fn().mockImplementation( + options?.resolveDeepChatAgentConfig ?? + (() => + Promise.resolve({ + defaultModelPreset: { + providerId: 'openai', + modelId: 'gpt-4o-mini' + }, + systemPrompt: 'Default system prompt', + permissionMode: 'full_access' as const, + disabledAgentTools: [] + })) + ) } const sessionClient = { ensureAcpDraftSession: vi.fn() @@ -236,7 +248,9 @@ const setup = async ( } }) - await flushPromises() + if (options?.awaitReady !== false) { + await flushPromises() + } return { wrapper, @@ -267,6 +281,46 @@ describe('NewThreadPage start deeplink prefill', () => { expect(draftStore.modelId).toBe('deepseek-chat') }, 20000) + it('does not replace a project selected while agent defaults are loading', async () => { + let resolveAgentConfig!: (value: { + defaultModelPreset?: { providerId: string; modelId: string } + defaultProjectPath?: string + systemPrompt: string + permissionMode: 'default' | 'full_access' + disabledAgentTools: string[] + }) => void + const agentConfig = new Promise<{ + defaultModelPreset?: { providerId: string; modelId: string } + defaultProjectPath?: string + systemPrompt: string + permissionMode: 'default' | 'full_access' + disabledAgentTools: string[] + }>((resolve) => { + resolveAgentConfig = resolve + }) + const { projectStore } = await setup('deepseek-chat', { + resolveDeepChatAgentConfig: () => agentConfig, + awaitReady: false + }) + await vi.waitFor(() => expect(resolveAgentConfig).toBeTypeOf('function')) + + projectStore.selectProject('/workspace/user-choice', 'manual') + resolveAgentConfig({ + defaultModelPreset: { providerId: 'openai', modelId: 'gpt-4o-mini' }, + defaultProjectPath: '/workspace/agent-default', + systemPrompt: 'Default system prompt', + permissionMode: 'full_access', + disabledAgentTools: [] + }) + await flushPromises() + + expect(projectStore.selectedProject?.path).toBe('/workspace/user-choice') + expect(projectStore.selectProject).not.toHaveBeenCalledWith( + '/workspace/agent-default', + 'manual' + ) + }, 20000) + it('allows clearing the selected project from the new thread dropdown', async () => { const { wrapper, projectStore } = await setup('deepseek-chat') diff --git a/test/renderer/stores/floatingButtonStore.test.ts b/test/renderer/stores/floatingButtonStore.test.ts new file mode 100644 index 0000000000..5c057ae0a9 --- /dev/null +++ b/test/renderer/stores/floatingButtonStore.test.ts @@ -0,0 +1,119 @@ +import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' +import { defineComponent, h } from 'vue' +import { createPinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useFloatingButtonStore } from '@/stores/floatingButton' + +vi.mock('pinia', async () => vi.importActual('pinia')) + +const floatingButtonMocks = vi.hoisted(() => ({ + getFloatingButtonEnabled: vi.fn(), + setFloatingButtonEnabled: vi.fn(), + onFloatingButtonChanged: vi.fn(), + listener: undefined as ((payload: { enabled: boolean; version: number }) => void) | undefined, + removeListener: vi.fn() +})) + +vi.mock('@api/ConfigClient', () => ({ + createConfigClient: () => ({ + getFloatingButtonEnabled: floatingButtonMocks.getFloatingButtonEnabled, + setFloatingButtonEnabled: floatingButtonMocks.setFloatingButtonEnabled, + onFloatingButtonChanged: floatingButtonMocks.onFloatingButtonChanged + }) +})) + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + return { promise, resolve } +} + +function mountFloatingButtonStore() { + const pinia = createPinia() + let store!: ReturnType + + const Harness = defineComponent({ + setup() { + store = useFloatingButtonStore() + return () => h('div') + } + }) + + const wrapper = mount(Harness, { + global: { + plugins: [pinia] + } + }) + + mountedStores.push({ store, wrapper }) + return { store, wrapper } +} + +let mountedStores: Array<{ + store: ReturnType + wrapper: VueWrapper +}> = [] + +describe('floating button store', () => { + beforeEach(() => { + vi.clearAllMocks() + floatingButtonMocks.listener = undefined + floatingButtonMocks.getFloatingButtonEnabled.mockResolvedValue(false) + floatingButtonMocks.setFloatingButtonEnabled.mockResolvedValue(undefined) + floatingButtonMocks.onFloatingButtonChanged.mockImplementation( + (listener: (payload: { enabled: boolean; version: number }) => void) => { + floatingButtonMocks.listener = listener + return floatingButtonMocks.removeListener + } + ) + }) + + afterEach(() => { + for (const { store, wrapper } of mountedStores) { + wrapper.unmount() + store.$dispose() + } + mountedStores = [] + }) + + it('keeps a listener update that arrives before the initial snapshot resolves', async () => { + const snapshot = deferred() + floatingButtonMocks.getFloatingButtonEnabled.mockReturnValueOnce(snapshot.promise) + + const { store } = await mountFloatingButtonStore() + await vi.waitFor(() => + expect(floatingButtonMocks.onFloatingButtonChanged).toHaveBeenCalledOnce() + ) + + floatingButtonMocks.listener?.({ enabled: true, version: 1 }) + snapshot.resolve(false) + await flushPromises() + + expect(store.enabled).toBe(true) + }) + + it('restores the actual previous value when the newest local update fails', async () => { + const { store } = await mountFloatingButtonStore() + await flushPromises() + store.enabled = true + floatingButtonMocks.setFloatingButtonEnabled.mockRejectedValueOnce(new Error('write failed')) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + + await store.setFloatingButtonEnabled(false) + + expect(store.enabled).toBe(true) + consoleError.mockRestore() + }) + + it('cleans up the IPC listener with its owning scope', async () => { + const { store, wrapper } = await mountFloatingButtonStore() + await flushPromises() + + wrapper.unmount() + store.$dispose() + + expect(floatingButtonMocks.removeListener).toHaveBeenCalledOnce() + }) +}) diff --git a/test/renderer/stores/languageStore.test.ts b/test/renderer/stores/languageStore.test.ts index 9e2f9cdde4..22cd237f8f 100644 --- a/test/renderer/stores/languageStore.test.ts +++ b/test/renderer/stores/languageStore.test.ts @@ -192,6 +192,20 @@ describe('language store', () => { expect(store.dir).toBe('rtl') }) + it('preserves an explicit ltr direction from the language state', async () => { + const { store } = mountLanguageStore() + await flushPromises() + + languageMocks.listener?.({ + requestedLanguage: 'en-US', + locale: 'en-US', + direction: 'ltr' + }) + await flushPromises() + + expect(store.dir).toBe('ltr') + }) + it('keeps the active locale when a new locale chunk fails', async () => { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) const { i18n, store } = mountLanguageStore() diff --git a/test/renderer/stores/projectStore.test.ts b/test/renderer/stores/projectStore.test.ts index 48f09b5590..15154d19cb 100644 --- a/test/renderer/stores/projectStore.test.ts +++ b/test/renderer/stores/projectStore.test.ts @@ -459,6 +459,25 @@ describe('projectStore default project handling', () => { expect(store.selectedProject.value?.path).toBe('/work/current') }) + it('does not show an error from a stale projects snapshot after a local default update', async () => { + const { store, projectPresenter, configClient } = await setupStore() + const staleProjects = + deferred>() + const staleDefault = deferred() + projectPresenter.getRecentProjects.mockReturnValueOnce(staleProjects.promise) + configClient.getDefaultProjectPath.mockReturnValueOnce(staleDefault.promise) + + const staleRequest = store.fetchProjects() + await store.setDefaultProject('/work/current') + + staleDefault.resolve('/work/stale') + staleProjects.reject(new Error('stale projects request failed')) + await staleRequest + + expect(store.error.value).toBeNull() + expect(store.defaultProjectPath.value).toBe('/work/current') + }) + it('refreshes project data when environments change in another window', async () => { const { store, projectPresenter, emitProjectEnvironmentsChanged } = await setupStore({ recentProjects: [ From 7c43dc0bf3bcd994837c62d3fa0cec3e5b9d6be1 Mon Sep 17 00:00:00 2001 From: zhangmo8 Date: Tue, 21 Jul 2026 18:27:41 +0800 Subject: [PATCH 07/12] chore(resources): refresh provider registries --- resources/acp-registry/registry.json | 116 +- resources/model-db/providers.json | 6802 +++++++++++++------------- 2 files changed, 3478 insertions(+), 3440 deletions(-) diff --git a/resources/acp-registry/registry.json b/resources/acp-registry/registry.json index bc291798c0..ad62f32840 100644 --- a/resources/acp-registry/registry.json +++ b/resources/acp-registry/registry.json @@ -103,7 +103,7 @@ { "id": "claude-acp", "name": "Claude Agent", - "version": "0.59.0", + "version": "0.60.0", "description": "ACP wrapper for Anthropic's Claude", "repository": "https://github.com/agentclientprotocol/claude-agent-acp", "authors": [ @@ -114,7 +114,7 @@ "license": "proprietary", "distribution": { "npx": { - "package": "@agentclientprotocol/claude-agent-acp@0.59.0" + "package": "@agentclientprotocol/claude-agent-acp@0.60.0" } }, "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/claude-acp.svg" @@ -331,7 +331,7 @@ { "id": "cursor", "name": "Cursor", - "version": "2026.07.09", + "version": "2026.07.16", "description": "Cursor's coding agent", "website": "https://cursor.com/docs/cli/acp", "authors": [ @@ -341,42 +341,42 @@ "distribution": { "binary": { "darwin-aarch64": { - "archive": "https://downloads.cursor.com/lab/2026.07.09-a3815c0/darwin/arm64/agent-cli-package.tar.gz", + "archive": "https://downloads.cursor.com/lab/2026.07.16-899851b/darwin/arm64/agent-cli-package.tar.gz", "cmd": "./dist-package/cursor-agent", "args": [ "acp" ] }, "darwin-x86_64": { - "archive": "https://downloads.cursor.com/lab/2026.07.09-a3815c0/darwin/x64/agent-cli-package.tar.gz", + "archive": "https://downloads.cursor.com/lab/2026.07.16-899851b/darwin/x64/agent-cli-package.tar.gz", "cmd": "./dist-package/cursor-agent", "args": [ "acp" ] }, "linux-aarch64": { - "archive": "https://downloads.cursor.com/lab/2026.07.09-a3815c0/linux/arm64/agent-cli-package.tar.gz", + "archive": "https://downloads.cursor.com/lab/2026.07.16-899851b/linux/arm64/agent-cli-package.tar.gz", "cmd": "./dist-package/cursor-agent", "args": [ "acp" ] }, "linux-x86_64": { - "archive": "https://downloads.cursor.com/lab/2026.07.09-a3815c0/linux/x64/agent-cli-package.tar.gz", + "archive": "https://downloads.cursor.com/lab/2026.07.16-899851b/linux/x64/agent-cli-package.tar.gz", "cmd": "./dist-package/cursor-agent", "args": [ "acp" ] }, "windows-aarch64": { - "archive": "https://downloads.cursor.com/lab/2026.07.09-a3815c0/windows/arm64/agent-cli-package.zip", + "archive": "https://downloads.cursor.com/lab/2026.07.16-899851b/windows/arm64/agent-cli-package.zip", "cmd": "./dist-package\\cursor-agent.cmd", "args": [ "acp" ] }, "windows-x86_64": { - "archive": "https://downloads.cursor.com/lab/2026.07.09-a3815c0/windows/x64/agent-cli-package.zip", + "archive": "https://downloads.cursor.com/lab/2026.07.16-899851b/windows/x64/agent-cli-package.zip", "cmd": "./dist-package\\cursor-agent.cmd", "args": [ "acp" @@ -467,7 +467,7 @@ { "id": "dimcode", "name": "DimCode", - "version": "0.2.31", + "version": "0.2.35", "description": "A coding agent that puts leading models at your command.", "website": "https://dimcode.dev/docs/acp.html", "authors": [ @@ -476,7 +476,7 @@ "license": "proprietary", "distribution": { "npx": { - "package": "dimcode@0.2.31", + "package": "dimcode@0.2.35", "args": [ "acp" ] @@ -508,7 +508,7 @@ { "id": "factory-droid", "name": "Factory Droid", - "version": "0.175.1", + "version": "0.176.0", "description": "Factory Droid - AI coding agent powered by Factory AI", "website": "https://factory.ai/product/cli", "authors": [ @@ -517,7 +517,7 @@ "license": "proprietary", "distribution": { "npx": { - "package": "droid@0.175.1", + "package": "droid@0.176.0", "args": [ "exec", "--output-format", @@ -534,7 +534,7 @@ { "id": "fast-agent", "name": "fast-agent", - "version": "0.9.17", + "version": "0.9.18", "description": "Code and build agents with comprehensive multi-provider support", "repository": "https://github.com/evalstate/fast-agent", "website": "https://fast-agent.ai", @@ -544,7 +544,7 @@ "license": "Apache 2.0", "distribution": { "uvx": { - "package": "fast-agent-acp==0.9.17", + "package": "fast-agent-acp==0.9.18", "args": [ "-x" ] @@ -576,7 +576,7 @@ { "id": "github-copilot-cli", "name": "GitHub Copilot", - "version": "1.0.71", + "version": "1.0.73", "description": "GitHub's AI pair programmer", "repository": "https://github.com/github/copilot-cli", "website": "https://github.com/features/copilot/cli/", @@ -586,7 +586,7 @@ "license": "proprietary", "distribution": { "npx": { - "package": "@github/copilot@1.0.71", + "package": "@github/copilot@1.0.73", "args": [ "--acp" ] @@ -597,7 +597,7 @@ { "id": "glm-acp-agent", "name": "GLM Agent", - "version": "1.2.0", + "version": "1.3.0", "description": "ACP agent powered by Zhipu AI's GLM Coding Plan models (glm-5.1, glm-5-turbo, glm-4.7, glm-4.5-air). Supports streaming, tool calls, mid-session model switching, image input via Z.AI Coding Plan Vision MCP, and session load/fork/resume with on-disk persistence.", "repository": "https://github.com/stefandevo/glm-acp-agent", "authors": [ @@ -607,7 +607,7 @@ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/glm-acp-agent.svg", "distribution": { "npx": { - "package": "glm-acp-agent@1.2.0" + "package": "glm-acp-agent@1.3.0" } } }, @@ -671,7 +671,7 @@ { "id": "grok-build", "name": "Grok Build", - "version": "0.2.106", + "version": "0.2.108", "description": "xAI's coding agent and CLI", "website": "https://x.ai/cli", "authors": [ @@ -680,7 +680,7 @@ "license": "proprietary", "distribution": { "npx": { - "package": "@xai-official/grok@0.2.106", + "package": "@xai-official/grok@0.2.108", "args": [ "agent", "stdio" @@ -754,7 +754,7 @@ { "id": "junie", "name": "Junie", - "version": "2144.9.0", + "version": "2144.11.0", "description": "AI Coding Agent by JetBrains", "repository": "https://github.com/JetBrains/junie", "website": "https://junie.jetbrains.com", @@ -765,35 +765,35 @@ "distribution": { "binary": { "darwin-aarch64": { - "archive": "https://github.com/JetBrains/junie/releases/download/2144.9/junie-release-2144.9-macos-aarch64.zip", + "archive": "https://github.com/JetBrains/junie/releases/download/2144.11/junie-release-2144.11-macos-aarch64.zip", "cmd": "./Applications/junie.app/Contents/MacOS/junie", "args": [ "--acp=true" ] }, "darwin-x86_64": { - "archive": "https://github.com/JetBrains/junie/releases/download/2144.9/junie-release-2144.9-macos-amd64.zip", + "archive": "https://github.com/JetBrains/junie/releases/download/2144.11/junie-release-2144.11-macos-amd64.zip", "cmd": "./Applications/junie.app/Contents/MacOS/junie", "args": [ "--acp=true" ] }, "linux-aarch64": { - "archive": "https://github.com/JetBrains/junie/releases/download/2144.9/junie-release-2144.9-linux-aarch64.zip", + "archive": "https://github.com/JetBrains/junie/releases/download/2144.11/junie-release-2144.11-linux-aarch64.zip", "cmd": "./junie-app/bin/junie", "args": [ "--acp=true" ] }, "linux-x86_64": { - "archive": "https://github.com/JetBrains/junie/releases/download/2144.9/junie-release-2144.9-linux-amd64.zip", + "archive": "https://github.com/JetBrains/junie/releases/download/2144.11/junie-release-2144.11-linux-amd64.zip", "cmd": "./junie-app/bin/junie", "args": [ "--acp=true" ] }, "windows-x86_64": { - "archive": "https://github.com/JetBrains/junie/releases/download/2144.9/junie-release-2144.9-windows-amd64.zip", + "archive": "https://github.com/JetBrains/junie/releases/download/2144.11/junie-release-2144.11-windows-amd64.zip", "cmd": "./junie/junie.exe", "args": [ "--acp=true" @@ -988,7 +988,7 @@ { "id": "nova", "name": "Nova", - "version": "1.1.28", + "version": "1.1.29", "description": "Nova by Compass AI - a fully-fledged software engineer at your command", "repository": "https://github.com/Compass-Agentic-Platform/nova", "website": "https://www.compassap.ai/portfolio/nova.html", @@ -999,7 +999,7 @@ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/nova.svg", "distribution": { "npx": { - "package": "@compass-ai/nova@1.1.28", + "package": "@compass-ai/nova@1.1.29", "args": [ "acp" ] @@ -1009,7 +1009,7 @@ { "id": "opencode", "name": "OpenCode", - "version": "1.18.3", + "version": "1.18.4", "description": "The open source coding agent", "repository": "https://github.com/anomalyco/opencode", "website": "https://opencode.ai", @@ -1021,52 +1021,52 @@ "distribution": { "binary": { "darwin-aarch64": { - "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.3/opencode-darwin-arm64.zip", + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.4/opencode-darwin-arm64.zip", "cmd": "./opencode", "args": [ "acp" ], - "sha256": "946f62b155638b911144b7bef520ee4a6442f696297907873463bca3524e40ef" + "sha256": "04fb881b632b323c712dfda6dcbbc6fce736394f07ba76176e52d6665925d4e6" }, "darwin-x86_64": { - "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.3/opencode-darwin-x64.zip", + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.4/opencode-darwin-x64.zip", "cmd": "./opencode", "args": [ "acp" ], - "sha256": "4ea147867ba19e4ec03559df557811f1674f40788aea4d10326dc563b7667c6d" + "sha256": "e177c532654572079981db1dce464a78adbaed9654a142848b2e81beb8c9f5c6" }, "linux-aarch64": { - "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.3/opencode-linux-arm64.tar.gz", + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.4/opencode-linux-arm64.tar.gz", "cmd": "./opencode", "args": [ "acp" ], - "sha256": "da0a631174eba380b2a1d51f9d364fa3812da433e72743c72471d4b5da59c69d" + "sha256": "eba87efba3976d533a24cca0316f8ef375b5f8e797c0a95c25ee919700b7ba35" }, "linux-x86_64": { - "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.3/opencode-linux-x64.tar.gz", + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.4/opencode-linux-x64.tar.gz", "cmd": "./opencode", "args": [ "acp" ], - "sha256": "60f27b2679f00a511b6539f97e02448afaf58d9c66e2448285ea0c517ca84583" + "sha256": "bab463c3fb3224d388bb7cfad63f38703df9cf0be2cfd2ce8cb49d886b53a174" }, "windows-aarch64": { - "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.3/opencode-windows-arm64.zip", + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.4/opencode-windows-arm64.zip", "cmd": "./opencode", "args": [ "acp" ], - "sha256": "a549fb2e9041db9438bcd9b77bfa0a4b2476caf2d550f37479aabfec1b079bfb" + "sha256": "4b4d2b48afdf1432a697bccabe230c3d614cf8ed34f5bc0acf9ffd89bb9cfb25" }, "windows-x86_64": { - "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.3/opencode-windows-x64.zip", + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.4/opencode-windows-x64.zip", "cmd": "./opencode.exe", "args": [ "acp" ], - "sha256": "68bc62930f6cb5755e0409aa9de0bb270a66ed2b8c9cf0c029e9f2287ed5486e" + "sha256": "814dae5724dfa396a43b6408703d0929625483e2fac135623f10f0fa8db04a96" } } } @@ -1091,7 +1091,7 @@ { "id": "poolside", "name": "Poolside", - "version": "1.0.11", + "version": "1.0.13", "description": "Poolside's coding agent", "repository": "https://github.com/poolsideai/pool", "website": "https://poolside.ai", @@ -1102,46 +1102,52 @@ "distribution": { "binary": { "darwin-aarch64": { - "archive": "https://downloads.poolside.ai/pool/v1.0.11/pool-darwin-arm64.tar.gz", + "archive": "https://downloads.poolside.ai/pool/v1.0.13/pool-darwin-arm64.tar.gz", "cmd": "./pool-darwin-arm64", "args": [ "acp" - ] + ], + "sha256": "19634a50986899eb139bc62d77d1917743563edaab365637f6579167bde0d8d5" }, "darwin-x86_64": { - "archive": "https://downloads.poolside.ai/pool/v1.0.11/pool-darwin-amd64.tar.gz", + "archive": "https://downloads.poolside.ai/pool/v1.0.13/pool-darwin-amd64.tar.gz", "cmd": "./pool-darwin-amd64", "args": [ "acp" - ] + ], + "sha256": "1efc0f27173522c37c300ce5563df264270ea916676865f4e7d00e42cde48046" }, "linux-aarch64": { - "archive": "https://downloads.poolside.ai/pool/v1.0.11/pool-linux-arm64.tar.gz", + "archive": "https://downloads.poolside.ai/pool/v1.0.13/pool-linux-arm64.tar.gz", "cmd": "./pool-linux-arm64", "args": [ "acp" - ] + ], + "sha256": "8c41ff475a9e0a2209ffee0c3c706d52ae1fd765ffe13d6857ab88b804d5a954" }, "linux-x86_64": { - "archive": "https://downloads.poolside.ai/pool/v1.0.11/pool-linux-amd64.tar.gz", + "archive": "https://downloads.poolside.ai/pool/v1.0.13/pool-linux-amd64.tar.gz", "cmd": "./pool-linux-amd64", "args": [ "acp" - ] + ], + "sha256": "01fe682e3d8ae982418e8d0614b740024aeb91ba145cf8d9be4d1ec195c0b7d0" }, "windows-aarch64": { - "archive": "https://downloads.poolside.ai/pool/v1.0.11/pool-windows-arm64.tar.gz", + "archive": "https://downloads.poolside.ai/pool/v1.0.13/pool-windows-arm64.tar.gz", "cmd": "./pool-windows-arm64.exe", "args": [ "acp" - ] + ], + "sha256": "88072012c18ade11c944d8463a35044e50f7586ba2db4daa3878a00a38118ca9" }, "windows-x86_64": { - "archive": "https://downloads.poolside.ai/pool/v1.0.11/pool-windows-amd64.tar.gz", + "archive": "https://downloads.poolside.ai/pool/v1.0.13/pool-windows-amd64.tar.gz", "cmd": "./pool-windows-amd64.exe", "args": [ "acp" - ] + ], + "sha256": "68e57bcf4b5ee4737065b8f77c1fed25ed5f8993bb90357be7e24af1440cb595" } } }, diff --git a/resources/model-db/providers.json b/resources/model-db/providers.json index 3f41575edc..4883cac051 100644 --- a/resources/model-db/providers.json +++ b/resources/model-db/providers.json @@ -104492,6 +104492,53 @@ }, "type": "imageGeneration" }, + { + "id": "qwen3.8-max-preview", + "name": "Qwen3.8 Max Preview", + "display_name": "Qwen3.8 Max Preview", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": true, + "open_weights": false, + "release_date": "2026-07-19", + "last_updated": "2026-07-19", + "cost": { + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 + }, + "type": "chat" + }, { "id": "kimi-k2.6", "name": "Kimi K2.6", @@ -136793,6 +136840,53 @@ }, "type": "imageGeneration" }, + { + "id": "qwen3.8-max-preview", + "name": "Qwen3.8 Max Preview", + "display_name": "Qwen3.8 Max Preview", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": true, + "open_weights": false, + "release_date": "2026-07-19", + "last_updated": "2026-07-19", + "cost": { + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 + }, + "type": "chat" + }, { "id": "kimi-k2.6", "name": "Kimi K2.6", @@ -174967,7 +175061,7 @@ "cost": { "input": 2, "output": 6, - "cache_read": 0.5 + "cache_read": 0.3 }, "type": "chat" }, @@ -190173,6 +190267,87 @@ }, "type": "chat" }, + { + "id": "moonshotai/kimi-k2.7-code", + "name": "Kimi K2.7 Code", + "display_name": "Kimi K2.7 Code", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 262144 + }, + "temperature": false, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": true, + "open_weights": true, + "knowledge": "2025-01", + "release_date": "2026-06-12", + "last_updated": "2026-06-12", + "cost": { + "input": 0.95, + "output": 4, + "cache_read": 0.19 + }, + "type": "chat" + }, + { + "id": "moonshotai/kimi-k3", + "name": "Kimi K3", + "display_name": "Kimi K3", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1048576, + "output": 1048576 + }, + "temperature": false, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": true, + "open_weights": true, + "release_date": "2026-07-16", + "last_updated": "2026-07-16", + "cost": { + "input": 3, + "output": 15, + "cache_read": 0.3 + }, + "type": "chat" + }, { "id": "moonshotai/kimi-k2-thinking", "name": "Kimi K2 Thinking", @@ -226750,7 +226925,7 @@ "default": true }, "attachment": false, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -226775,7 +226950,7 @@ "default": true }, "attachment": false, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -226800,7 +226975,7 @@ "default": true }, "attachment": false, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -226824,7 +226999,7 @@ "supported": false }, "attachment": false, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -226848,7 +227023,7 @@ "supported": false }, "attachment": false, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -226872,7 +227047,7 @@ "supported": false }, "attachment": false, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -226897,7 +227072,7 @@ "supported": false }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -226922,7 +227097,7 @@ "supported": false }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "embedding" }, { @@ -226947,7 +227122,7 @@ "supported": false }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "embedding" }, { @@ -226973,7 +227148,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -226999,7 +227174,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -227025,7 +227200,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -227051,7 +227226,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -227077,7 +227252,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -227103,7 +227278,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -227129,7 +227304,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -227155,7 +227330,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -227181,7 +227356,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -227207,7 +227382,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -227233,7 +227408,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -227259,7 +227434,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -227285,7 +227460,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -227311,7 +227486,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -227335,7 +227510,7 @@ "supported": false }, "attachment": false, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -227361,7 +227536,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -227387,7 +227562,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -227413,7 +227588,7 @@ "default": true }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -227437,7 +227612,7 @@ "supported": false }, "attachment": false, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -227458,7 +227633,7 @@ "supported": false }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z" + "last_updated": "2026-07-20T09:28:20Z" }, { "id": "doubao-seedance-1-0-pro-fast-251015", @@ -227478,7 +227653,7 @@ "supported": false }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z" + "last_updated": "2026-07-20T09:28:20Z" }, { "id": "doubao-seedance-1-5-pro-251215", @@ -227499,7 +227674,7 @@ "supported": false }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z" + "last_updated": "2026-07-20T09:28:20Z" }, { "id": "doubao-seedance-2-0-260128", @@ -227521,7 +227696,7 @@ "supported": false }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z" + "last_updated": "2026-07-20T09:28:20Z" }, { "id": "doubao-seedance-2-0-fast-260128", @@ -227543,7 +227718,7 @@ "supported": false }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z" + "last_updated": "2026-07-20T09:28:20Z" }, { "id": "doubao-seedance-2-0-mini-260615", @@ -227565,7 +227740,7 @@ "supported": false }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z" + "last_updated": "2026-07-20T09:28:20Z" }, { "id": "doubao-seedream-4-0-250828", @@ -227585,7 +227760,7 @@ "supported": false }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "imageGeneration" }, { @@ -227606,7 +227781,7 @@ "supported": false }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "imageGeneration" }, { @@ -227627,7 +227802,7 @@ "supported": false }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "imageGeneration" }, { @@ -227648,7 +227823,7 @@ "supported": false }, "attachment": true, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "imageGeneration" }, { @@ -227673,7 +227848,7 @@ "default": true }, "attachment": false, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" }, { @@ -227698,7 +227873,7 @@ "default": true }, "attachment": false, - "last_updated": "2026-07-14T15:12:52Z", + "last_updated": "2026-07-20T09:28:20Z", "type": "chat" } ] @@ -236122,6 +236297,95 @@ }, "type": "chat" }, + { + "id": "hy3-preview", + "name": "hy3-preview", + "display_name": "hy3-preview", + "modalities": { + "input": [ + "text" + ] + }, + "limit": { + "context": 256000, + "output": 256000 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "cost": { + "input": 0.17, + "output": 0.566661, + "cache_read": 0.051 + }, + "type": "chat" + }, + { + "id": "minimax-m3", + "name": "minimax-m3", + "display_name": "minimax-m3", + "modalities": { + "input": [ + "text" + ] + }, + "limit": { + "context": 204800, + "output": 204800 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "cost": { + "input": 0.288, + "output": 1.152 + }, + "type": "chat" + }, + { + "id": "qwen3.7-plus", + "name": "qwen3.7-plus", + "display_name": "qwen3.7-plus", + "modalities": { + "input": [ + "text" + ] + }, + "limit": { + "context": 991000, + "output": 991000 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "cost": { + "input": 0.282, + "output": 1.128, + "cache_read": 0.0564 + }, + "type": "chat" + }, { "id": "step-3.7-flash", "name": "step-3.7-flash", @@ -236147,95 +236411,6 @@ }, "type": "chat" }, - { - "id": "hy3-preview", - "name": "hy3-preview", - "display_name": "hy3-preview", - "modalities": { - "input": [ - "text" - ] - }, - "limit": { - "context": 256000, - "output": 256000 - }, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } - }, - "cost": { - "input": 0.17, - "output": 0.566661, - "cache_read": 0.051 - }, - "type": "chat" - }, - { - "id": "minimax-m3", - "name": "minimax-m3", - "display_name": "minimax-m3", - "modalities": { - "input": [ - "text" - ] - }, - "limit": { - "context": 204800, - "output": 204800 - }, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } - }, - "cost": { - "input": 0.288, - "output": 1.152 - }, - "type": "chat" - }, - { - "id": "qwen3.7-plus", - "name": "qwen3.7-plus", - "display_name": "qwen3.7-plus", - "modalities": { - "input": [ - "text" - ] - }, - "limit": { - "context": 991000, - "output": 991000 - }, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } - }, - "cost": { - "input": 0.282, - "output": 1.128, - "cache_read": 0.0564 - }, - "type": "chat" - }, { "id": "claude-opus-4-8-think", "name": "claude-opus-4-8-think", @@ -236439,6 +236614,35 @@ }, "type": "chat" }, + { + "id": "coding-glm-5.2", + "name": "coding-glm-5.2", + "display_name": "coding-glm-5.2", + "modalities": { + "input": [ + "text" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "cost": { + "input": 0.06, + "output": 0.22 + }, + "type": "chat" + }, { "id": "grok-4.3", "name": "grok-4.3", @@ -236562,64 +236766,6 @@ }, "type": "chat" }, - { - "id": "coding-glm-5.2", - "name": "coding-glm-5.2", - "display_name": "coding-glm-5.2", - "modalities": { - "input": [ - "text" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } - }, - "cost": { - "input": 0.06, - "output": 0.22 - }, - "type": "chat" - }, - { - "id": "coding-minimax-m3-free", - "name": "coding-minimax-m3-free", - "display_name": "coding-minimax-m3-free", - "modalities": { - "input": [ - "text" - ] - }, - "limit": { - "context": 204800, - "output": 204800 - }, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } - }, - "cost": { - "input": 0, - "output": 0 - }, - "type": "chat" - }, { "id": "gpt-5.5", "name": "gpt-5.5", @@ -236698,6 +236844,35 @@ }, "type": "chat" }, + { + "id": "coding-minimax-m3-free", + "name": "coding-minimax-m3-free", + "display_name": "coding-minimax-m3-free", + "modalities": { + "input": [ + "text" + ] + }, + "limit": { + "context": 204800, + "output": 204800 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "cost": { + "input": 0, + "output": 0 + }, + "type": "chat" + }, { "id": "deepseek-v4-flash", "name": "deepseek-v4-flash", @@ -236828,36 +237003,6 @@ }, "type": "chat" }, - { - "id": "command-a-plus-05-2026", - "name": "command-a-plus-05-2026", - "display_name": "command-a-plus-05-2026", - "modalities": { - "input": [ - "text", - "image" - ] - }, - "limit": { - "context": 128000, - "output": 128000 - }, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } - }, - "cost": { - "input": 2.5, - "output": 10 - }, - "type": "chat" - }, { "id": "kimi-k2.6", "name": "kimi-k2.6", @@ -236977,6 +237122,36 @@ }, "type": "chat" }, + { + "id": "command-a-plus-05-2026", + "name": "command-a-plus-05-2026", + "display_name": "command-a-plus-05-2026", + "modalities": { + "input": [ + "text", + "image" + ] + }, + "limit": { + "context": 128000, + "output": 128000 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "cost": { + "input": 2.5, + "output": 10 + }, + "type": "chat" + }, { "id": "claude-opus-4-7", "name": "claude-opus-4-7", @@ -237081,37 +237256,6 @@ }, "type": "chat" }, - { - "id": "gpt-chat-latest", - "name": "gpt-chat-latest", - "display_name": "gpt-chat-latest", - "modalities": { - "input": [ - "text", - "image" - ] - }, - "limit": { - "context": 1050000, - "output": 1050000 - }, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } - }, - "cost": { - "input": 5, - "output": 30, - "cache_read": 0.5 - }, - "type": "chat" - }, { "id": "qwen3.6-27b", "name": "qwen3.6-27b", @@ -237225,6 +237369,99 @@ }, "type": "chat" }, + { + "id": "gpt-chat-latest", + "name": "gpt-chat-latest", + "display_name": "gpt-chat-latest", + "modalities": { + "input": [ + "text", + "image" + ] + }, + "limit": { + "context": 1050000, + "output": 1050000 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "cost": { + "input": 5, + "output": 30, + "cache_read": 0.5 + }, + "type": "chat" + }, + { + "id": "grok-4-20-non-reasoning", + "name": "grok-4-20-non-reasoning", + "display_name": "grok-4-20-non-reasoning", + "modalities": { + "input": [ + "text", + "image" + ] + }, + "limit": { + "context": 2000000, + "output": 2000000 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "cost": { + "input": 2, + "output": 6, + "cache_read": 0.2 + }, + "type": "chat" + }, + { + "id": "grok-4-20-reasoning", + "name": "grok-4-20-reasoning", + "display_name": "grok-4-20-reasoning", + "modalities": { + "input": [ + "text", + "image" + ] + }, + "limit": { + "context": 2000000, + "output": 2000000 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "cost": { + "input": 2, + "output": 6, + "cache_read": 0.2 + }, + "type": "chat" + }, { "id": "mai-image-2e", "name": "mai-image-2e", @@ -237350,49 +237587,19 @@ "type": "rerank" }, { - "id": "grok-4-20-non-reasoning", - "name": "grok-4-20-non-reasoning", - "display_name": "grok-4-20-non-reasoning", - "modalities": { - "input": [ - "text", - "image" - ] - }, - "limit": { - "context": 2000000, - "output": 2000000 - }, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } - }, - "cost": { - "input": 2, - "output": 6, - "cache_read": 0.2 - }, - "type": "chat" - }, - { - "id": "grok-4-20-reasoning", - "name": "grok-4-20-reasoning", - "display_name": "grok-4-20-reasoning", + "id": "qwen3.6-plus", + "name": "qwen3.6-plus", + "display_name": "qwen3.6-plus", "modalities": { "input": [ "text", - "image" + "image", + "video" ] }, "limit": { - "context": 2000000, - "output": 2000000 + "context": 991000, + "output": 991000 }, "tool_call": true, "reasoning": { @@ -237401,13 +237608,19 @@ }, "extra_capabilities": { "reasoning": { - "supported": true + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] } }, "cost": { - "input": 2, - "output": 6, - "cache_read": 0.2 + "input": 0.282, + "output": 1.692, + "cache_read": 0.0282 }, "type": "chat" }, @@ -237537,44 +237750,6 @@ }, "type": "imageGeneration" }, - { - "id": "qwen3.6-plus", - "name": "qwen3.6-plus", - "display_name": "qwen3.6-plus", - "modalities": { - "input": [ - "text", - "image", - "video" - ] - }, - "limit": { - "context": 991000, - "output": 991000 - }, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ] - } - }, - "cost": { - "input": 0.282, - "output": 1.692, - "cache_read": 0.0282 - }, - "type": "chat" - }, { "id": "wan2.7-videoedit", "name": "wan2.7-videoedit", @@ -237720,6 +237895,25 @@ }, "type": "imageGeneration" }, + { + "id": "cc-k2.6-code-preview", + "name": "cc-k2.6-code-preview", + "display_name": "cc-k2.6-code-preview", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 0.2, + "output": 0.2, + "cache_read": 0.02 + }, + "type": "chat" + }, { "id": "gemma-4-26b-a4b-it", "name": "gemma-4-26b-a4b-it", @@ -237806,177 +238000,6 @@ }, "type": "chat" }, - { - "id": "cc-k2.6-code-preview", - "name": "cc-k2.6-code-preview", - "display_name": "cc-k2.6-code-preview", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 0.2, - "output": 0.2, - "cache_read": 0.02 - }, - "type": "chat" - }, - { - "id": "claude-sonnet-4-6", - "name": "claude-sonnet-4-6", - "display_name": "claude-sonnet-4-6", - "modalities": { - "input": [ - "text", - "image" - ] - }, - "limit": { - "context": 1000000, - "output": 1000000 - }, - "tool_call": true, - "reasoning": { - "supported": true, - "default": false - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "default_enabled": false, - "mode": "mixed", - "budget": { - "min": 1024, - "unit": "tokens" - }, - "effort": "high", - "effort_options": [ - "low", - "medium", - "high", - "max" - ], - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ], - "notes": [ - "Anthropic recommends adaptive thinking with effort for Claude 4.6; budget_tokens remains a deprecated compatibility path.", - "Anthropic API defaults effort to high; lower effort levels should be chosen per workload." - ] - } - }, - "cost": { - "input": 3, - "output": 15, - "cache_read": 0.3 - }, - "type": "chat" - }, - { - "id": "coding-xiaomi-mimo-v2.5", - "name": "coding-xiaomi-mimo-v2.5", - "display_name": "coding-xiaomi-mimo-v2.5", - "modalities": { - "input": [ - "text" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } - }, - "cost": { - "input": 0.08, - "output": 0.16, - "cache_read": 0.0016 - }, - "type": "chat" - }, - { - "id": "coding-xiaomi-mimo-v2.5-pro", - "name": "coding-xiaomi-mimo-v2.5-pro", - "display_name": "coding-xiaomi-mimo-v2.5-pro", - "modalities": { - "input": [ - "text" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } - }, - "cost": { - "input": 0.2, - "output": 0.4, - "cache_read": 0.0016 - }, - "type": "chat" - }, - { - "id": "qwen3.5-plus", - "name": "qwen3.5-plus", - "display_name": "qwen3.5-plus", - "modalities": { - "input": [ - "text", - "image", - "video" - ] - }, - "limit": { - "context": 991000, - "output": 991000 - }, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ] - } - }, - "cost": { - "input": 0.1096, - "output": 0.6576, - "cache_read": 0.01096 - }, - "type": "chat" - }, { "id": "doubao-seed-2-0-lite-260428", "name": "doubao-seed-2-0-lite-260428", @@ -238369,34 +238392,47 @@ "type": "chat" }, { - "id": "gpt-5.3-chat-latest", - "name": "gpt-5.3-chat-latest", - "display_name": "gpt-5.3-chat-latest", + "id": "qwen3.5-plus", + "name": "qwen3.5-plus", + "display_name": "qwen3.5-plus", "modalities": { "input": [ "text", - "image" + "image", + "video" ] }, "limit": { - "context": 128000, - "output": 128000 + "context": 991000, + "output": 991000 }, "tool_call": true, "reasoning": { - "supported": false + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } }, "cost": { - "input": 1.75, - "output": 14, - "cache_read": 0.175 + "input": 0.1096, + "output": 0.6576, + "cache_read": 0.01096 }, "type": "chat" }, { - "id": "gpt-5.3-codex", - "name": "gpt-5.3-codex", - "display_name": "gpt-5.3-codex", + "id": "claude-sonnet-4-6", + "name": "claude-sonnet-4-6", + "display_name": "claude-sonnet-4-6", "modalities": { "input": [ "text", @@ -238404,66 +238440,108 @@ ] }, "limit": { - "context": 400000, - "output": 400000 + "context": 1000000, + "output": 1000000 }, "tool_call": true, "reasoning": { "supported": true, - "default": true + "default": false }, "extra_capabilities": { "reasoning": { "supported": true, - "default_enabled": true, - "mode": "effort", - "effort": "medium", + "default_enabled": false, + "mode": "mixed", + "budget": { + "min": 1024, + "unit": "tokens" + }, + "effort": "high", "effort_options": [ "low", "medium", "high", - "xhigh" + "max" ], - "verbosity": "medium", - "verbosity_options": [ - "low", - "medium", - "high" + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" ], - "visibility": "hidden" + "notes": [ + "Anthropic recommends adaptive thinking with effort for Claude 4.6; budget_tokens remains a deprecated compatibility path.", + "Anthropic API defaults effort to high; lower effort levels should be chosen per workload." + ] } }, "cost": { - "input": 1.75, - "output": 14, - "cache_read": 0.175 + "input": 3, + "output": 15, + "cache_read": 0.3 }, "type": "chat" }, { - "id": "gpt-image-2-free", - "name": "gpt-image-2-free", - "display_name": "gpt-image-2-free", + "id": "coding-xiaomi-mimo-v2.5", + "name": "coding-xiaomi-mimo-v2.5", + "display_name": "coding-xiaomi-mimo-v2.5", "modalities": { "input": [ - "text", - "image" + "text" ] }, "limit": { "context": 8192, "output": 8192 }, - "tool_call": false, + "tool_call": true, "reasoning": { - "supported": false + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } }, "cost": { - "input": 0, - "output": 0, - "cache_read": 0 + "input": 0.08, + "output": 0.16, + "cache_read": 0.0016 }, - "type": "imageGeneration" + "type": "chat" + }, + { + "id": "coding-xiaomi-mimo-v2.5-pro", + "name": "coding-xiaomi-mimo-v2.5-pro", + "display_name": "coding-xiaomi-mimo-v2.5-pro", + "modalities": { + "input": [ + "text" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "cost": { + "input": 0.2, + "output": 0.4, + "cache_read": 0.0016 + }, + "type": "chat" }, { "id": "claude-sonnet-4-6-think", @@ -238579,6 +238657,103 @@ }, "type": "chat" }, + { + "id": "gpt-5.3-chat-latest", + "name": "gpt-5.3-chat-latest", + "display_name": "gpt-5.3-chat-latest", + "modalities": { + "input": [ + "text", + "image" + ] + }, + "limit": { + "context": 128000, + "output": 128000 + }, + "tool_call": true, + "reasoning": { + "supported": false + }, + "cost": { + "input": 1.75, + "output": 14, + "cache_read": 0.175 + }, + "type": "chat" + }, + { + "id": "gpt-5.3-codex", + "name": "gpt-5.3-codex", + "display_name": "gpt-5.3-codex", + "modalities": { + "input": [ + "text", + "image" + ] + }, + "limit": { + "context": 400000, + "output": 400000 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "effort", + "effort": "medium", + "effort_options": [ + "low", + "medium", + "high", + "xhigh" + ], + "verbosity": "medium", + "verbosity_options": [ + "low", + "medium", + "high" + ], + "visibility": "hidden" + } + }, + "cost": { + "input": 1.75, + "output": 14, + "cache_read": 0.175 + }, + "type": "chat" + }, + { + "id": "gpt-image-2-free", + "name": "gpt-image-2-free", + "display_name": "gpt-image-2-free", + "modalities": { + "input": [ + "text", + "image" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 0, + "output": 0, + "cache_read": 0 + }, + "type": "imageGeneration" + }, { "id": "qwen3.5-122b-a10b", "name": "qwen3.5-122b-a10b", @@ -238793,6 +238968,143 @@ }, "type": "chat" }, + { + "id": "coding-glm-5.1", + "name": "coding-glm-5.1", + "display_name": "coding-glm-5.1", + "modalities": { + "input": [ + "text" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "cost": { + "input": 0.06, + "output": 0.22 + }, + "type": "chat" + }, + { + "id": "xiaomi-mimo-v2.5-pro-free", + "name": "xiaomi-mimo-v2.5-pro-free", + "display_name": "xiaomi-mimo-v2.5-pro-free", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio" + ] + }, + "limit": { + "context": 256000, + "output": 256000 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 0, + "output": 0, + "cache_read": 0 + }, + "type": "chat" + }, + { + "id": "xiaomi-mimo-v2.5-free", + "name": "xiaomi-mimo-v2.5-free", + "display_name": "xiaomi-mimo-v2.5-free", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio" + ] + }, + "limit": { + "context": 256000, + "output": 256000 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 0, + "output": 0, + "cache_read": 0 + }, + "type": "chat" + }, + { + "id": "xiaomi-mimo-v2-pro-free", + "name": "xiaomi-mimo-v2-pro-free", + "display_name": "xiaomi-mimo-v2-pro-free", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio" + ] + }, + "limit": { + "context": 256000, + "output": 256000 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 0, + "output": 0, + "cache_read": 0 + }, + "type": "chat" + }, + { + "id": "xiaomi-mimo-v2-omni-free", + "name": "xiaomi-mimo-v2-omni-free", + "display_name": "xiaomi-mimo-v2-omni-free", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio" + ] + }, + "limit": { + "context": 256000, + "output": 256000 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 0, + "output": 0, + "cache_read": 0 + }, + "type": "chat" + }, { "id": "doubao-seed-2-0-pro", "name": "doubao-seed-2-0-pro", @@ -238968,139 +239280,64 @@ "type": "chat" }, { - "id": "xiaomi-mimo-v2.5-pro-free", - "name": "xiaomi-mimo-v2.5-pro-free", - "display_name": "xiaomi-mimo-v2.5-pro-free", - "modalities": { - "input": [ - "text", - "image", - "video", - "audio" - ] - }, - "limit": { - "context": 256000, - "output": 256000 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 0, - "output": 0, - "cache_read": 0 - }, - "type": "chat" - }, - { - "id": "xiaomi-mimo-v2.5-free", - "name": "xiaomi-mimo-v2.5-free", - "display_name": "xiaomi-mimo-v2.5-free", + "id": "glm-5", + "name": "glm-5", + "display_name": "glm-5", "modalities": { "input": [ - "text", - "image", - "video", - "audio" + "text" ] }, "limit": { - "context": 256000, - "output": 256000 + "context": 202752, + "output": 202752 }, - "tool_call": false, + "tool_call": true, "reasoning": { - "supported": false - }, - "cost": { - "input": 0, - "output": 0, - "cache_read": 0 - }, - "type": "chat" - }, - { - "id": "xiaomi-mimo-v2-pro-free", - "name": "xiaomi-mimo-v2-pro-free", - "display_name": "xiaomi-mimo-v2-pro-free", - "modalities": { - "input": [ - "text", - "image", - "video", - "audio" - ] - }, - "limit": { - "context": 256000, - "output": 256000 + "supported": true, + "default": true }, - "tool_call": false, - "reasoning": { - "supported": false + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } }, "cost": { - "input": 0, - "output": 0, - "cache_read": 0 + "input": 0.88, + "output": 2.816, + "cache_read": 0.176 }, "type": "chat" }, { - "id": "xiaomi-mimo-v2-omni-free", - "name": "xiaomi-mimo-v2-omni-free", - "display_name": "xiaomi-mimo-v2-omni-free", + "id": "glm-5v-turbo", + "name": "glm-5v-turbo", + "display_name": "glm-5v-turbo", "modalities": { "input": [ "text", "image", - "video", - "audio" + "video" ] }, "limit": { - "context": 256000, - "output": 256000 + "context": 200000, + "output": 200000 }, "tool_call": false, "reasoning": { "supported": false }, "cost": { - "input": 0, - "output": 0, - "cache_read": 0 - }, - "type": "chat" - }, - { - "id": "coding-glm-5.1", - "name": "coding-glm-5.1", - "display_name": "coding-glm-5.1", - "modalities": { - "input": [ - "text" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } - }, - "cost": { - "input": 0.06, - "output": 0.22 + "input": 0.7042, + "output": 3.09848, + "cache_read": 0.169008 }, "type": "chat" }, @@ -239218,17 +239455,17 @@ "type": "chat" }, { - "id": "glm-5", - "name": "glm-5", - "display_name": "glm-5", + "id": "minimax-m2.7", + "name": "minimax-m2.7", + "display_name": "minimax-m2.7", "modalities": { "input": [ "text" ] }, "limit": { - "context": 202752, - "output": 202752 + "context": 200000, + "output": 200000 }, "tool_call": true, "reasoning": { @@ -239247,50 +239484,78 @@ } }, "cost": { - "input": 0.88, - "output": 2.816, - "cache_read": 0.176 + "input": 0.2958, + "output": 1.1832, + "cache_read": 0.05916 }, "type": "chat" }, { - "id": "glm-5v-turbo", - "name": "glm-5v-turbo", - "display_name": "glm-5v-turbo", + "id": "claude-opus-4-6-think", + "name": "claude-opus-4-6-think", + "display_name": "claude-opus-4-6-think", "modalities": { "input": [ - "text", "image", - "video" + "text" ] }, "limit": { "context": 200000, "output": 200000 }, - "tool_call": false, + "tool_call": true, "reasoning": { - "supported": false + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "mixed", + "budget": { + "min": 1024, + "unit": "tokens" + }, + "effort": "high", + "effort_options": [ + "low", + "medium", + "high", + "max" + ], + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ], + "notes": [ + "Anthropic recommends adaptive thinking with effort for Claude 4.6; budget_tokens remains a deprecated compatibility path.", + "Anthropic API defaults effort to high; lower effort levels should be chosen per workload." + ] + } }, "cost": { - "input": 0.7042, - "output": 3.09848, - "cache_read": 0.169008 + "input": 5, + "output": 25, + "cache_read": 0.5 }, "type": "chat" }, { - "id": "minimax-m2.7", - "name": "minimax-m2.7", - "display_name": "minimax-m2.7", + "id": "coding-glm-5-free", + "name": "coding-glm-5-free", + "display_name": "coding-glm-5-free", "modalities": { "input": [ "text" ] }, "limit": { - "context": 200000, - "output": 200000 + "context": 8192, + "output": 8192 }, "tool_call": true, "reasoning": { @@ -239299,19 +239564,72 @@ }, "extra_capabilities": { "reasoning": { - "supported": true, - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ] + "supported": true } }, "cost": { - "input": 0.2958, - "output": 1.1832, - "cache_read": 0.05916 + "input": 0, + "output": 0, + "cache_read": 0 + }, + "type": "chat" + }, + { + "id": "coding-glm-5-turbo-free", + "name": "coding-glm-5-turbo-free", + "display_name": "coding-glm-5-turbo-free", + "modalities": { + "input": [ + "text" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "cost": { + "input": 0, + "output": 0, + "cache_read": 0 + }, + "type": "chat" + }, + { + "id": "coding-minimax-m2.5-free", + "name": "coding-minimax-m2.5-free", + "display_name": "coding-minimax-m2.5-free", + "modalities": { + "input": [ + "text" + ] + }, + "limit": { + "context": 204800, + "output": 204800 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "cost": { + "input": 0, + "output": 0 }, "type": "chat" }, @@ -239534,106 +239852,66 @@ "type": "chat" }, { - "id": "claude-opus-4-6-think", - "name": "claude-opus-4-6-think", - "display_name": "claude-opus-4-6-think", + "id": "embed-v-4-0", + "name": "embed-v-4-0", + "display_name": "embed-v-4-0", "modalities": { "input": [ - "image", - "text" + "text", + "image" ] }, "limit": { - "context": 200000, - "output": 200000 + "context": 128000, + "output": 128000 }, - "tool_call": true, + "tool_call": false, "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "default_enabled": true, - "mode": "mixed", - "budget": { - "min": 1024, - "unit": "tokens" - }, - "effort": "high", - "effort_options": [ - "low", - "medium", - "high", - "max" - ], - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ], - "notes": [ - "Anthropic recommends adaptive thinking with effort for Claude 4.6; budget_tokens remains a deprecated compatibility path.", - "Anthropic API defaults effort to high; lower effort levels should be chosen per workload." - ] - } + "supported": false }, "cost": { - "input": 5, - "output": 25, - "cache_read": 0.5 + "input": 0.12, + "output": 0, + "cache_read": 0.12 }, - "type": "chat" + "type": "embedding" }, { - "id": "coding-glm-5-free", - "name": "coding-glm-5-free", - "display_name": "coding-glm-5-free", - "modalities": { - "input": [ - "text" - ] - }, + "id": "ernie-image-turbo", + "name": "ernie-image-turbo", + "display_name": "ernie-image-turbo", "limit": { "context": 8192, "output": 8192 }, - "tool_call": true, + "tool_call": false, "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } + "supported": false }, "cost": { - "input": 0, + "input": 2, "output": 0, "cache_read": 0 }, - "type": "chat" + "type": "imageGeneration" }, { - "id": "coding-glm-5-turbo-free", - "name": "coding-glm-5-turbo-free", - "display_name": "coding-glm-5-turbo-free", + "id": "gemini-3.1-flash-image-preview-free", + "name": "gemini-3.1-flash-image-preview-free", + "display_name": "gemini-3.1-flash-image-preview-free", "modalities": { "input": [ - "text" + "text", + "image" ] }, "limit": { "context": 8192, "output": 8192 }, - "tool_call": true, + "tool_call": false, "reasoning": { - "supported": true, - "default": true + "supported": true }, "extra_capabilities": { "reasoning": { @@ -239645,36 +239923,7 @@ "output": 0, "cache_read": 0 }, - "type": "chat" - }, - { - "id": "coding-minimax-m2.5-free", - "name": "coding-minimax-m2.5-free", - "display_name": "coding-minimax-m2.5-free", - "modalities": { - "input": [ - "text" - ] - }, - "limit": { - "context": 204800, - "output": 204800 - }, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } - }, - "cost": { - "input": 0, - "output": 0 - }, - "type": "chat" + "type": "imageGeneration" }, { "id": "cc-glm-5.1", @@ -239811,80 +240060,6 @@ }, "type": "chat" }, - { - "id": "embed-v-4-0", - "name": "embed-v-4-0", - "display_name": "embed-v-4-0", - "modalities": { - "input": [ - "text", - "image" - ] - }, - "limit": { - "context": 128000, - "output": 128000 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 0.12, - "output": 0, - "cache_read": 0.12 - }, - "type": "embedding" - }, - { - "id": "ernie-image-turbo", - "name": "ernie-image-turbo", - "display_name": "ernie-image-turbo", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 2, - "output": 0, - "cache_read": 0 - }, - "type": "imageGeneration" - }, - { - "id": "gemini-3.1-flash-image-preview-free", - "name": "gemini-3.1-flash-image-preview-free", - "display_name": "gemini-3.1-flash-image-preview-free", - "modalities": { - "input": [ - "text", - "image" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } - }, - "cost": { - "input": 0, - "output": 0, - "cache_read": 0 - }, - "type": "imageGeneration" - }, { "id": "mimo-v2-omni", "name": "mimo-v2-omni", @@ -242878,30 +243053,6 @@ }, "type": "chat" }, - { - "id": "step-3.5-flash", - "name": "step-3.5-flash", - "display_name": "step-3.5-flash", - "modalities": { - "input": [ - "text", - "image" - ] - }, - "limit": { - "context": 256000, - "output": 256000 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 0.11, - "output": 0.33 - }, - "type": "chat" - }, { "id": "wan2.5-t2v-preview", "name": "wan2.5-t2v-preview", @@ -243091,6 +243242,30 @@ }, "type": "chat" }, + { + "id": "step-3.5-flash", + "name": "step-3.5-flash", + "display_name": "step-3.5-flash", + "modalities": { + "input": [ + "text", + "image" + ] + }, + "limit": { + "context": 256000, + "output": 256000 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 0.11, + "output": 0.33 + }, + "type": "chat" + }, { "id": "flux-2-flex", "name": "flux-2-flex", @@ -244063,30 +244238,6 @@ }, "type": "imageGeneration" }, - { - "id": "imagen-4.0-fast-generate-preview-06-06", - "name": "imagen-4.0-fast-generate-preview-06-06", - "display_name": "imagen-4.0-fast-generate-preview-06-06", - "modalities": { - "input": [ - "text", - "image" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 2, - "output": 2 - }, - "type": "imageGeneration" - }, { "id": "imagen-4.0-generate-001", "name": "imagen-4.0-generate-001", @@ -244523,9 +244674,9 @@ "type": "chat" }, { - "id": "veo-3.1-fast-generate-preview", - "name": "veo-3.1-fast-generate-preview", - "display_name": "veo-3.1-fast-generate-preview", + "id": "veo-3.0-generate-preview", + "name": "veo-3.0-generate-preview", + "display_name": "veo-3.0-generate-preview", "modalities": { "input": [ "text", @@ -244543,14 +244694,15 @@ }, "cost": { "input": 2, - "output": 0 + "output": 2, + "cache_read": 0 }, "type": "chat" }, { - "id": "veo-3.0-generate-preview", - "name": "veo-3.0-generate-preview", - "display_name": "veo-3.0-generate-preview", + "id": "veo-3.1-fast-generate-preview", + "name": "veo-3.1-fast-generate-preview", + "display_name": "veo-3.1-fast-generate-preview", "modalities": { "input": [ "text", @@ -244568,8 +244720,7 @@ }, "cost": { "input": 2, - "output": 2, - "cache_read": 0 + "output": 0 }, "type": "chat" }, @@ -245334,31 +245485,6 @@ }, "type": "chat" }, - { - "id": "imagen-4.0-ultra-generate-exp-05-20", - "name": "imagen-4.0-ultra-generate-exp-05-20", - "display_name": "imagen-4.0-ultra-generate-exp-05-20", - "modalities": { - "input": [ - "text", - "image" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 2, - "output": 2, - "cache_read": 0 - }, - "type": "imageGeneration" - }, { "id": "jina-embeddings-v5-text-nano", "name": "jina-embeddings-v5-text-nano", @@ -246195,6 +246321,24 @@ }, "type": "rerank" }, + { + "id": "tao-8k", + "name": "tao-8k", + "display_name": "tao-8k", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 0.068, + "output": 0.068 + }, + "type": "embedding" + }, { "id": "doubao-seedream-4-0", "name": "doubao-seedream-4-0", @@ -246365,24 +246509,6 @@ }, "type": "chat" }, - { - "id": "tao-8k", - "name": "tao-8k", - "display_name": "tao-8k", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 0.068, - "output": 0.068 - }, - "type": "embedding" - }, { "id": "jina-clip-v2", "name": "jina-clip-v2", @@ -246454,31 +246580,6 @@ }, "type": "embedding" }, - { - "id": "gpt-4o-search-preview", - "name": "gpt-4o-search-preview", - "display_name": "gpt-4o-search-preview", - "modalities": { - "input": [ - "text", - "image" - ] - }, - "limit": { - "context": 128000, - "output": 128000 - }, - "tool_call": true, - "reasoning": { - "supported": false - }, - "cost": { - "input": 2.5, - "output": 10, - "cache_read": 1.25 - }, - "type": "chat" - }, { "id": "DeepSeek-R1", "name": "DeepSeek-R1", @@ -246513,6 +246614,31 @@ }, "type": "chat" }, + { + "id": "gpt-4o-search-preview", + "name": "gpt-4o-search-preview", + "display_name": "gpt-4o-search-preview", + "modalities": { + "input": [ + "text", + "image" + ] + }, + "limit": { + "context": 128000, + "output": 128000 + }, + "tool_call": true, + "reasoning": { + "supported": false + }, + "cost": { + "input": 2.5, + "output": 10, + "cache_read": 1.25 + }, + "type": "chat" + }, { "id": "gpt-4o-mini-search-preview", "name": "gpt-4o-mini-search-preview", @@ -246937,75 +247063,6 @@ }, "type": "chat" }, - { - "id": "Qwen2-VL-72B-Instruct", - "name": "Qwen2-VL-72B-Instruct", - "display_name": "Qwen2-VL-72B-Instruct", - "modalities": { - "input": [ - "text", - "image", - "video" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 2.18, - "output": 6.54 - }, - "type": "chat" - }, - { - "id": "Qwen2-VL-7B-Instruct", - "name": "Qwen2-VL-7B-Instruct", - "display_name": "Qwen2-VL-7B-Instruct", - "modalities": { - "input": [ - "text", - "image", - "video" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 0.28, - "output": 0.7 - }, - "type": "chat" - }, - { - "id": "cc-kimi-for-coding", - "name": "cc-kimi-for-coding", - "display_name": "cc-kimi-for-coding", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 0.2, - "output": 0.2, - "cache_read": 0.02 - }, - "type": "chat" - }, { "id": "gemini-embedding-001", "name": "gemini-embedding-001", @@ -247058,6 +247115,75 @@ }, "type": "chat" }, + { + "id": "Qwen2-VL-72B-Instruct", + "name": "Qwen2-VL-72B-Instruct", + "display_name": "Qwen2-VL-72B-Instruct", + "modalities": { + "input": [ + "text", + "image", + "video" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 2.18, + "output": 6.54 + }, + "type": "chat" + }, + { + "id": "Qwen2-VL-7B-Instruct", + "name": "Qwen2-VL-7B-Instruct", + "display_name": "Qwen2-VL-7B-Instruct", + "modalities": { + "input": [ + "text", + "image", + "video" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 0.28, + "output": 0.7 + }, + "type": "chat" + }, + { + "id": "cc-kimi-for-coding", + "name": "cc-kimi-for-coding", + "display_name": "cc-kimi-for-coding", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 0.2, + "output": 0.2, + "cache_read": 0.02 + }, + "type": "chat" + }, { "id": "Qwen/Qwen3-30B-A3B", "name": "Qwen/Qwen3-30B-A3B", @@ -248064,6 +248190,29 @@ }, "type": "chat" }, + { + "id": "ernie-x1.1-preview", + "name": "ernie-x1.1-preview", + "display_name": "ernie-x1.1-preview", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "cost": { + "input": 0.136, + "output": 0.544 + }, + "type": "chat" + }, { "id": "minimax-m2", "name": "minimax-m2", @@ -248094,88 +248243,96 @@ "type": "chat" }, { - "id": "ernie-x1.1-preview", - "name": "ernie-x1.1-preview", - "display_name": "ernie-x1.1-preview", + "id": "kat-dev", + "name": "kat-dev", + "display_name": "kat-dev", + "modalities": { + "input": [ + "text" + ] + }, "limit": { - "context": 8192, - "output": 8192 + "context": 128000, + "output": 128000 }, - "tool_call": false, + "tool_call": true, "reasoning": { - "supported": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } + "supported": false }, "cost": { - "input": 0.136, - "output": 0.544 + "input": 0.137, + "output": 0.548 }, "type": "chat" }, { - "id": "ernie-4.5-0.3b", - "name": "ernie-4.5-0.3b", - "display_name": "ernie-4.5-0.3b", - "modalities": { - "input": [ - "text", - "image" - ] + "id": "llama-3.3-70b", + "name": "llama-3.3-70b", + "display_name": "llama-3.3-70b", + "limit": { + "context": 65536, + "output": 65536 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 0.6, + "output": 0.6 }, + "type": "chat" + }, + { + "id": "moonshotai/Kimi-Dev-72B", + "name": "moonshotai/Kimi-Dev-72B", + "display_name": "moonshotai/Kimi-Dev-72B", "limit": { "context": 8192, "output": 8192 }, - "tool_call": true, + "tool_call": false, "reasoning": { "supported": false }, "cost": { - "input": 0.0136, - "output": 0.0544 + "input": 0.32, + "output": 1.28, + "cache_read": 0 }, "type": "chat" }, { - "id": "ernie-4.5-turbo-128k-preview", - "name": "ernie-4.5-turbo-128k-preview", - "display_name": "ernie-4.5-turbo-128k-preview", - "modalities": { - "input": [ - "text", - "image" - ] - }, + "id": "moonshotai/Moonlight-16B-A3B-Instruct", + "name": "moonshotai/Moonlight-16B-A3B-Instruct", + "display_name": "moonshotai/Moonlight-16B-A3B-Instruct", "limit": { "context": 8192, "output": 8192 }, - "tool_call": true, + "tool_call": false, "reasoning": { "supported": false }, "cost": { - "input": 0.108, - "output": 0.432 + "input": 0.2, + "output": 0.2, + "cache_read": 0 }, "type": "chat" }, { - "id": "ernie-x1-turbo", - "name": "ernie-x1-turbo", - "display_name": "ernie-x1-turbo", + "id": "nvidia-nemotron-3-super-120b-a12b", + "name": "nvidia-nemotron-3-super-120b-a12b", + "display_name": "nvidia-nemotron-3-super-120b-a12b", "modalities": { "input": [ "text" ] }, "limit": { - "context": 50500, - "output": 50500 + "context": 1000000, + "output": 1000000 }, "tool_call": true, "reasoning": { @@ -248188,8 +248345,85 @@ } }, "cost": { - "input": 0.136, - "output": 0.544 + "input": 0.11, + "output": 0.55, + "cache_read": 0.0275 + }, + "type": "chat" + }, + { + "id": "o1-global", + "name": "o1-global", + "display_name": "o1-global", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "effort", + "effort": "medium", + "effort_options": [ + "low", + "medium", + "high" + ], + "visibility": "hidden" + } + }, + "cost": { + "input": 15, + "output": 60, + "cache_read": 7.5 + }, + "type": "chat" + }, + { + "id": "qianfan-qi-vl", + "name": "qianfan-qi-vl", + "display_name": "qianfan-qi-vl", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 0.2, + "output": 0.6 + }, + "type": "chat" + }, + { + "id": "qwen2.5-vl-72b-instruct", + "name": "qwen2.5-vl-72b-instruct", + "display_name": "qwen2.5-vl-72b-instruct", + "modalities": { + "input": [ + "text", + "image" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 2.4, + "output": 7.2 }, "type": "chat" }, @@ -248365,96 +248599,65 @@ "type": "chat" }, { - "id": "kat-dev", - "name": "kat-dev", - "display_name": "kat-dev", + "id": "ernie-4.5-0.3b", + "name": "ernie-4.5-0.3b", + "display_name": "ernie-4.5-0.3b", "modalities": { "input": [ - "text" + "text", + "image" ] }, - "limit": { - "context": 128000, - "output": 128000 - }, - "tool_call": true, - "reasoning": { - "supported": false - }, - "cost": { - "input": 0.137, - "output": 0.548 - }, - "type": "chat" - }, - { - "id": "llama-3.3-70b", - "name": "llama-3.3-70b", - "display_name": "llama-3.3-70b", - "limit": { - "context": 65536, - "output": 65536 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 0.6, - "output": 0.6 - }, - "type": "chat" - }, - { - "id": "moonshotai/Kimi-Dev-72B", - "name": "moonshotai/Kimi-Dev-72B", - "display_name": "moonshotai/Kimi-Dev-72B", "limit": { "context": 8192, "output": 8192 }, - "tool_call": false, + "tool_call": true, "reasoning": { "supported": false }, "cost": { - "input": 0.32, - "output": 1.28, - "cache_read": 0 + "input": 0.0136, + "output": 0.0544 }, "type": "chat" }, { - "id": "moonshotai/Moonlight-16B-A3B-Instruct", - "name": "moonshotai/Moonlight-16B-A3B-Instruct", - "display_name": "moonshotai/Moonlight-16B-A3B-Instruct", + "id": "ernie-4.5-turbo-128k-preview", + "name": "ernie-4.5-turbo-128k-preview", + "display_name": "ernie-4.5-turbo-128k-preview", + "modalities": { + "input": [ + "text", + "image" + ] + }, "limit": { "context": 8192, "output": 8192 }, - "tool_call": false, + "tool_call": true, "reasoning": { "supported": false }, "cost": { - "input": 0.2, - "output": 0.2, - "cache_read": 0 + "input": 0.108, + "output": 0.432 }, "type": "chat" }, { - "id": "nvidia-nemotron-3-super-120b-a12b", - "name": "nvidia-nemotron-3-super-120b-a12b", - "display_name": "nvidia-nemotron-3-super-120b-a12b", + "id": "ernie-x1-turbo", + "name": "ernie-x1-turbo", + "display_name": "ernie-x1-turbo", "modalities": { "input": [ "text" ] }, "limit": { - "context": 1000000, - "output": 1000000 + "context": 50500, + "output": 50500 }, "tool_call": true, "reasoning": { @@ -248467,50 +248670,15 @@ } }, "cost": { - "input": 0.11, - "output": 0.55, - "cache_read": 0.0275 - }, - "type": "chat" - }, - { - "id": "o1-global", - "name": "o1-global", - "display_name": "o1-global", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "default_enabled": true, - "mode": "effort", - "effort": "medium", - "effort_options": [ - "low", - "medium", - "high" - ], - "visibility": "hidden" - } - }, - "cost": { - "input": 15, - "output": 60, - "cache_read": 7.5 + "input": 0.136, + "output": 0.544 }, "type": "chat" }, { - "id": "qianfan-qi-vl", - "name": "qianfan-qi-vl", - "display_name": "qianfan-qi-vl", + "id": "gemini-exp-1206", + "name": "gemini-exp-1206", + "display_name": "gemini-exp-1206", "limit": { "context": 8192, "output": 8192 @@ -248520,15 +248688,15 @@ "supported": false }, "cost": { - "input": 0.2, - "output": 0.6 + "input": 1.25, + "output": 5 }, "type": "chat" }, { - "id": "qwen2.5-vl-72b-instruct", - "name": "qwen2.5-vl-72b-instruct", - "display_name": "qwen2.5-vl-72b-instruct", + "id": "gpt-4o-zh", + "name": "gpt-4o-zh", + "display_name": "gpt-4o-zh", "modalities": { "input": [ "text", @@ -248544,8 +248712,8 @@ "supported": false }, "cost": { - "input": 2.4, - "output": 7.2 + "input": 2.5, + "output": 10 }, "type": "chat" }, @@ -248567,48 +248735,6 @@ }, "type": "chat" }, - { - "id": "gemini-exp-1206", - "name": "gemini-exp-1206", - "display_name": "gemini-exp-1206", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 1.25, - "output": 5 - }, - "type": "chat" - }, - { - "id": "gpt-4o-zh", - "name": "gpt-4o-zh", - "display_name": "gpt-4o-zh", - "modalities": { - "input": [ - "text", - "image" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 2.5, - "output": 10 - }, - "type": "chat" - }, { "id": "unsloth/gemma-3-12b-it", "name": "unsloth/gemma-3-12b-it", @@ -248646,24 +248772,6 @@ }, "type": "chat" }, - { - "id": "tencent/Hunyuan-MT-7B", - "name": "tencent/Hunyuan-MT-7B", - "display_name": "tencent/Hunyuan-MT-7B", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 0.2, - "output": 0.2 - }, - "type": "chat" - }, { "id": "BAAI/bge-large-en-v1.5", "name": "BAAI/bge-large-en-v1.5", @@ -248736,6 +248844,24 @@ }, "type": "rerank" }, + { + "id": "tencent/Hunyuan-MT-7B", + "name": "tencent/Hunyuan-MT-7B", + "display_name": "tencent/Hunyuan-MT-7B", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 0.2, + "output": 0.2 + }, + "type": "chat" + }, { "id": "V3", "name": "V3", @@ -248971,47 +249097,6 @@ }, "type": "chat" }, - { - "id": "stepfun-ai/step3", - "name": "stepfun-ai/step3", - "display_name": "stepfun-ai/step3", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 1.1, - "output": 2.75 - }, - "type": "chat" - }, - { - "id": "text-embedding-v4", - "name": "text-embedding-v4", - "display_name": "text-embedding-v4", - "modalities": { - "input": [ - "text" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 0.08, - "output": 0.08 - }, - "type": "embedding" - }, { "id": "qwen-plus-2025-07-28", "name": "qwen-plus-2025-07-28", @@ -249091,35 +249176,46 @@ "type": "chat" }, { - "id": "qwen-turbo-latest", - "name": "qwen-turbo-latest", - "display_name": "qwen-turbo-latest", + "id": "stepfun-ai/step3", + "name": "stepfun-ai/step3", + "display_name": "stepfun-ai/step3", "limit": { "context": 8192, "output": 8192 }, "tool_call": false, "reasoning": { - "supported": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ] - } + "supported": false }, "cost": { - "input": 0.046, - "output": 0.092, - "cache_read": 0.0092 + "input": 1.1, + "output": 2.75 }, "type": "chat" }, + { + "id": "text-embedding-v4", + "name": "text-embedding-v4", + "display_name": "text-embedding-v4", + "modalities": { + "input": [ + "text" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 0.08, + "output": 0.08 + }, + "type": "embedding" + }, { "id": "AiHubmix-Phi-4-mini-reasoning", "name": "AiHubmix-Phi-4-mini-reasoning", @@ -249143,6 +249239,36 @@ }, "type": "chat" }, + { + "id": "qwen-turbo-latest", + "name": "qwen-turbo-latest", + "display_name": "qwen-turbo-latest", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "cost": { + "input": 0.046, + "output": 0.092, + "cache_read": 0.0092 + }, + "type": "chat" + }, { "id": "aihub-Phi-4-multimodal-instruct", "name": "aihub-Phi-4-multimodal-instruct", @@ -249198,29 +249324,6 @@ }, "type": "chat" }, - { - "id": "aihub-Phi-4-mini-instruct", - "name": "aihub-Phi-4-mini-instruct", - "display_name": "aihub-Phi-4-mini-instruct", - "modalities": { - "input": [ - "text" - ] - }, - "limit": { - "context": 128000, - "output": 128000 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 0.12, - "output": 0.48 - }, - "type": "chat" - }, { "id": "grok-3", "name": "grok-3", @@ -249240,44 +249343,25 @@ "type": "chat" }, { - "id": "doubao-embedding-text-240715", - "name": "doubao-embedding-text-240715", - "display_name": "doubao-embedding-text-240715", + "id": "aihub-Phi-4-mini-instruct", + "name": "aihub-Phi-4-mini-instruct", + "display_name": "aihub-Phi-4-mini-instruct", "modalities": { "input": [ "text" ] }, "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 0.7, - "output": 0.7 - }, - "type": "embedding" - }, - { - "id": "grok-3-beta", - "name": "grok-3-beta", - "display_name": "grok-3-beta", - "limit": { - "context": 8192, - "output": 8192 + "context": 128000, + "output": 128000 }, "tool_call": false, "reasoning": { "supported": false }, "cost": { - "input": 3, - "output": 15, - "cache_read": 0 + "input": 0.12, + "output": 0.48 }, "type": "chat" }, @@ -249377,39 +249461,32 @@ "type": "chat" }, { - "id": "qwen3-8b", - "name": "qwen3-8b", - "display_name": "qwen3-8b", + "id": "doubao-embedding-text-240715", + "name": "doubao-embedding-text-240715", + "display_name": "doubao-embedding-text-240715", + "modalities": { + "input": [ + "text" + ] + }, "limit": { "context": 8192, "output": 8192 }, "tool_call": false, "reasoning": { - "supported": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ] - } + "supported": false }, "cost": { - "input": 0.08, - "output": 0.8, - "cache_read": 0 + "input": 0.7, + "output": 0.7 }, - "type": "chat" + "type": "embedding" }, { - "id": "grok-3-fast", - "name": "grok-3-fast", - "display_name": "grok-3-fast", + "id": "grok-3-beta", + "name": "grok-3-beta", + "display_name": "grok-3-beta", "limit": { "context": 8192, "output": 8192 @@ -249419,16 +249496,16 @@ "supported": false }, "cost": { - "input": 5.5, - "output": 27.5, + "input": 3, + "output": 15, "cache_read": 0 }, "type": "chat" }, { - "id": "grok-3-fast-beta", - "name": "grok-3-fast-beta", - "display_name": "grok-3-fast-beta", + "id": "grok-3-fast", + "name": "grok-3-fast", + "display_name": "grok-3-fast", "limit": { "context": 8192, "output": 8192 @@ -249445,20 +249522,31 @@ "type": "chat" }, { - "id": "grok-3-mini", - "name": "grok-3-mini", - "display_name": "grok-3-mini", + "id": "qwen3-8b", + "name": "qwen3-8b", + "display_name": "qwen3-8b", "limit": { "context": 8192, "output": 8192 }, "tool_call": false, "reasoning": { - "supported": false + "supported": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } }, "cost": { - "input": 0.3, - "output": 0.501, + "input": 0.08, + "output": 0.8, "cache_read": 0 }, "type": "chat" @@ -249512,31 +249600,39 @@ "type": "chat" }, { - "id": "qwen3-1.7b", - "name": "qwen3-1.7b", - "display_name": "qwen3-1.7b", + "id": "grok-3-fast-beta", + "name": "grok-3-fast-beta", + "display_name": "grok-3-fast-beta", "limit": { "context": 8192, "output": 8192 }, "tool_call": false, "reasoning": { - "supported": true + "supported": false }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ] - } + "cost": { + "input": 5.5, + "output": 27.5, + "cache_read": 0 + }, + "type": "chat" + }, + { + "id": "grok-3-mini", + "name": "grok-3-mini", + "display_name": "grok-3-mini", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false }, "cost": { - "input": 0.046, - "output": 0.46, + "input": 0.3, + "output": 0.501, "cache_read": 0 }, "type": "chat" @@ -249560,6 +249656,36 @@ }, "type": "chat" }, + { + "id": "qwen3-1.7b", + "name": "qwen3-1.7b", + "display_name": "qwen3-1.7b", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "cost": { + "input": 0.046, + "output": 0.46, + "cache_read": 0 + }, + "type": "chat" + }, { "id": "qwen3-0.6b", "name": "qwen3-0.6b", @@ -249911,6 +250037,24 @@ }, "type": "chat" }, + { + "id": "qwen-3-235b-a22b-instruct-2507", + "name": "qwen-3-235b-a22b-instruct-2507", + "display_name": "qwen-3-235b-a22b-instruct-2507", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 0.28, + "output": 1.4 + }, + "type": "chat" + }, { "id": "deepseek-ai/Janus-Pro-7B", "name": "deepseek-ai/Janus-Pro-7B", @@ -249930,9 +250074,14 @@ "type": "chat" }, { - "id": "qwen-3-235b-a22b-instruct-2507", - "name": "qwen-3-235b-a22b-instruct-2507", - "display_name": "qwen-3-235b-a22b-instruct-2507", + "id": "coding-glm-4.5-air", + "name": "coding-glm-4.5-air", + "display_name": "coding-glm-4.5-air", + "modalities": { + "input": [ + "text" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -249942,8 +250091,67 @@ "supported": false }, "cost": { - "input": 0.28, - "output": 1.4 + "input": 0.014, + "output": 0.084 + }, + "type": "chat" + }, + { + "id": "deepinfra-nvidia-nemotron-3-nano-30b-a3b2", + "name": "deepinfra-nvidia-nemotron-3-nano-30b-a3b2", + "display_name": "deepinfra-nvidia-nemotron-3-nano-30b-a3b2", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 0.066, + "output": 0.264 + }, + "type": "chat" + }, + { + "id": "glm-4.5-air", + "name": "glm-4.5-air", + "display_name": "glm-4.5-air", + "modalities": { + "input": [ + "text" + ] + }, + "limit": { + "context": 131072, + "output": 131072 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 0.14, + "output": 0.84 + }, + "type": "chat" + }, + { + "id": "gpt-4-32k", + "name": "gpt-4-32k", + "display_name": "gpt-4-32k", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 60, + "output": 120 }, "type": "chat" }, @@ -250071,88 +250279,6 @@ }, "type": "chat" }, - { - "id": "coding-glm-4.5-air", - "name": "coding-glm-4.5-air", - "display_name": "coding-glm-4.5-air", - "modalities": { - "input": [ - "text" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 0.014, - "output": 0.084 - }, - "type": "chat" - }, - { - "id": "deepinfra-nvidia-nemotron-3-nano-30b-a3b2", - "name": "deepinfra-nvidia-nemotron-3-nano-30b-a3b2", - "display_name": "deepinfra-nvidia-nemotron-3-nano-30b-a3b2", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 0.066, - "output": 0.264 - }, - "type": "chat" - }, - { - "id": "glm-4.5-air", - "name": "glm-4.5-air", - "display_name": "glm-4.5-air", - "modalities": { - "input": [ - "text" - ] - }, - "limit": { - "context": 131072, - "output": 131072 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 0.14, - "output": 0.84 - }, - "type": "chat" - }, - { - "id": "gpt-4-32k", - "name": "gpt-4-32k", - "display_name": "gpt-4-32k", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 60, - "output": 120 - }, - "type": "chat" - }, { "id": "Qwen/QVQ-72B-Preview", "name": "Qwen/QVQ-72B-Preview", @@ -250290,6 +250416,30 @@ }, "type": "chat" }, + { + "id": "wan2.6-t2i", + "name": "wan2.6-t2i", + "display_name": "wan2.6-t2i", + "modalities": { + "input": [ + "image", + "text" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 2, + "output": 0 + }, + "type": "imageGeneration" + }, { "id": "grok-2-1212", "name": "grok-2-1212", @@ -250309,15 +250459,9 @@ "type": "chat" }, { - "id": "wan2.6-t2i", - "name": "wan2.6-t2i", - "display_name": "wan2.6-t2i", - "modalities": { - "input": [ - "image", - "text" - ] - }, + "id": "gpt-image-test", + "name": "gpt-image-test", + "display_name": "gpt-image-test", "limit": { "context": 8192, "output": 8192 @@ -250326,16 +250470,48 @@ "reasoning": { "supported": false }, + "cost": { + "input": 5, + "output": 40, + "cache_read": 0 + }, + "type": "chat" + }, + { + "id": "grok-4.20-beta-0309-non-reasoning", + "name": "grok-4.20-beta-0309-non-reasoning", + "display_name": "grok-4.20-beta-0309-non-reasoning", + "modalities": { + "input": [ + "text", + "image" + ] + }, + "limit": { + "context": 2000000, + "output": 2000000 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, "cost": { "input": 2, - "output": 0 + "output": 6, + "cache_read": 0.2 }, - "type": "imageGeneration" + "type": "chat" }, { - "id": "grok-4.20-multi-agent-beta-0309", - "name": "grok-4.20-multi-agent-beta-0309", - "display_name": "grok-4.20-multi-agent-beta-0309", + "id": "grok-4.20-beta-0309-reasoning", + "name": "grok-4.20-beta-0309-reasoning", + "display_name": "grok-4.20-beta-0309-reasoning", "modalities": { "input": [ "text", @@ -250364,9 +250540,9 @@ "type": "chat" }, { - "id": "imagen-3.0-generate-002", - "name": "imagen-3.0-generate-002", - "display_name": "imagen-3.0-generate-002", + "id": "grok-4.20-multi-agent-beta-0309", + "name": "grok-4.20-multi-agent-beta-0309", + "display_name": "grok-4.20-multi-agent-beta-0309", "modalities": { "input": [ "text", @@ -250374,19 +250550,25 @@ ] }, "limit": { - "context": 8192, - "output": 8192 + "context": 2000000, + "output": 2000000 }, - "tool_call": false, + "tool_call": true, "reasoning": { - "supported": false + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } }, "cost": { "input": 2, - "output": 2, - "cache_read": 0 + "output": 6, + "cache_read": 0.2 }, - "type": "imageGeneration" + "type": "chat" }, { "id": "jina-reader", @@ -250759,9 +250941,9 @@ "type": "chat" }, { - "id": "gpt-image-test", - "name": "gpt-image-test", - "display_name": "gpt-image-test", + "id": "Baichuan3-Turbo", + "name": "Baichuan3-Turbo", + "display_name": "Baichuan3-Turbo", "limit": { "context": 8192, "output": 8192 @@ -250771,78 +250953,51 @@ "supported": false }, "cost": { - "input": 5, - "output": 40, - "cache_read": 0 + "input": 1.9, + "output": 1.9 }, "type": "chat" }, { - "id": "grok-4.20-beta-0309-non-reasoning", - "name": "grok-4.20-beta-0309-non-reasoning", - "display_name": "grok-4.20-beta-0309-non-reasoning", - "modalities": { - "input": [ - "text", - "image" - ] - }, + "id": "Baichuan3-Turbo-128k", + "name": "Baichuan3-Turbo-128k", + "display_name": "Baichuan3-Turbo-128k", "limit": { - "context": 2000000, - "output": 2000000 + "context": 8192, + "output": 8192 }, - "tool_call": true, + "tool_call": false, "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } + "supported": false }, "cost": { - "input": 2, - "output": 6, - "cache_read": 0.2 + "input": 3.8, + "output": 3.8 }, "type": "chat" }, { - "id": "grok-4.20-beta-0309-reasoning", - "name": "grok-4.20-beta-0309-reasoning", - "display_name": "grok-4.20-beta-0309-reasoning", - "modalities": { - "input": [ - "text", - "image" - ] - }, + "id": "Baichuan4", + "name": "Baichuan4", + "display_name": "Baichuan4", "limit": { - "context": 2000000, - "output": 2000000 + "context": 8192, + "output": 8192 }, - "tool_call": true, + "tool_call": false, "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } + "supported": false }, "cost": { - "input": 2, - "output": 6, - "cache_read": 0.2 + "input": 16, + "output": 16 }, "type": "chat" }, { - "id": "deepseek-ai/deepseek-llm-67b-chat", - "name": "deepseek-ai/deepseek-llm-67b-chat", - "display_name": "deepseek-ai/deepseek-llm-67b-chat", + "id": "Baichuan4-Air", + "name": "Baichuan4-Air", + "display_name": "Baichuan4-Air", "limit": { "context": 8192, "output": 8192 @@ -250858,9 +251013,9 @@ "type": "chat" }, { - "id": "deepseek-ai/deepseek-vl2", - "name": "deepseek-ai/deepseek-vl2", - "display_name": "deepseek-ai/deepseek-vl2", + "id": "Baichuan4-Turbo", + "name": "Baichuan4-Turbo", + "display_name": "Baichuan4-Turbo", "limit": { "context": 8192, "output": 8192 @@ -250870,15 +251025,15 @@ "supported": false }, "cost": { - "input": 0.16, - "output": 0.16 + "input": 2.4, + "output": 2.4 }, "type": "chat" }, { - "id": "deepseek-v3", - "name": "deepseek-v3", - "display_name": "deepseek-v3", + "id": "DeepSeek-v3", + "name": "DeepSeek-v3", + "display_name": "DeepSeek-v3", "limit": { "context": 8192, "output": 8192 @@ -250889,20 +251044,14 @@ }, "cost": { "input": 0.272, - "output": 1.088, - "cache_read": 0 + "output": 1.088 }, "type": "chat" }, { - "id": "distil-whisper-large-v3-en", - "name": "distil-whisper-large-v3-en", - "display_name": "distil-whisper-large-v3-en", - "modalities": { - "input": [ - "audio" - ] - }, + "id": "Doubao-1.5-lite-32k", + "name": "Doubao-1.5-lite-32k", + "display_name": "Doubao-1.5-lite-32k", "limit": { "context": 8192, "output": 8192 @@ -250912,15 +251061,16 @@ "supported": false }, "cost": { - "input": 5.556, - "output": 5.556 + "input": 0.05, + "output": 0.1, + "cache_read": 0.01 }, "type": "chat" }, { - "id": "doubao-1-5-thinking-vision-pro-250428", - "name": "doubao-1-5-thinking-vision-pro-250428", - "display_name": "doubao-1-5-thinking-vision-pro-250428", + "id": "Doubao-1.5-pro-256k", + "name": "Doubao-1.5-pro-256k", + "display_name": "Doubao-1.5-pro-256k", "limit": { "context": 8192, "output": 8192 @@ -250930,16 +251080,16 @@ "supported": false }, "cost": { - "input": 2, - "output": 2, - "cache_read": 2 + "input": 0.8, + "output": 1.44, + "cache_read": 0.8 }, "type": "chat" }, { - "id": "fx-flux-2-pro", - "name": "fx-flux-2-pro", - "display_name": "fx-flux-2-pro", + "id": "Doubao-1.5-pro-32k", + "name": "Doubao-1.5-pro-32k", + "display_name": "Doubao-1.5-pro-32k", "limit": { "context": 8192, "output": 8192 @@ -250949,68 +251099,34 @@ "supported": false }, "cost": { - "input": 2, - "output": 0, - "cache_read": 0 + "input": 0.134, + "output": 0.335, + "cache_read": 0.0268 }, "type": "chat" }, { - "id": "gemini-2.5-pro-exp-03-25", - "name": "gemini-2.5-pro-exp-03-25", - "display_name": "gemini-2.5-pro-exp-03-25", - "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ] - }, + "id": "Doubao-1.5-vision-pro-32k", + "name": "Doubao-1.5-vision-pro-32k", + "display_name": "Doubao-1.5-vision-pro-32k", "limit": { "context": 8192, "output": 8192 }, - "tool_call": true, + "tool_call": false, "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "default_enabled": true, - "mode": "budget", - "budget": { - "default": -1, - "min": 128, - "max": 32768, - "auto": -1, - "unit": "tokens" - }, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thought_signatures" - ] - } + "supported": false }, "cost": { - "input": 1.25, - "output": 5, - "cache_read": 0.125 + "input": 0.46, + "output": 1.38 }, "type": "chat" }, { - "id": "gemini-embedding-exp-03-07", - "name": "gemini-embedding-exp-03-07", - "display_name": "gemini-embedding-exp-03-07", - "modalities": { - "input": [ - "text" - ] - }, + "id": "Doubao-lite-128k", + "name": "Doubao-lite-128k", + "display_name": "Doubao-lite-128k", "limit": { "context": 8192, "output": 8192 @@ -251020,15 +251136,16 @@ "supported": false }, "cost": { - "input": 0.02, - "output": 0.02 + "input": 0.14, + "output": 0.28, + "cache_read": 0.14 }, - "type": "embedding" + "type": "chat" }, { - "id": "gemini-exp-1114", - "name": "gemini-exp-1114", - "display_name": "gemini-exp-1114", + "id": "Doubao-lite-32k", + "name": "Doubao-lite-32k", + "display_name": "Doubao-lite-32k", "limit": { "context": 8192, "output": 8192 @@ -251038,15 +251155,16 @@ "supported": false }, "cost": { - "input": 1.25, - "output": 5 + "input": 0.06, + "output": 0.12, + "cache_read": 0.012 }, "type": "chat" }, { - "id": "gemini-exp-1121", - "name": "gemini-exp-1121", - "display_name": "gemini-exp-1121", + "id": "Doubao-lite-4k", + "name": "Doubao-lite-4k", + "display_name": "Doubao-lite-4k", "limit": { "context": 8192, "output": 8192 @@ -251056,15 +251174,16 @@ "supported": false }, "cost": { - "input": 1.25, - "output": 5 + "input": 0.06, + "output": 0.12, + "cache_read": 0.06 }, "type": "chat" }, { - "id": "gemini-pro", - "name": "gemini-pro", - "display_name": "gemini-pro", + "id": "Doubao-pro-128k", + "name": "Doubao-pro-128k", + "display_name": "Doubao-pro-128k", "limit": { "context": 8192, "output": 8192 @@ -251074,15 +251193,15 @@ "supported": false }, "cost": { - "input": 0.2, - "output": 0.6 + "input": 0.8, + "output": 1.44 }, "type": "chat" }, { - "id": "gemini-pro-vision", - "name": "gemini-pro-vision", - "display_name": "gemini-pro-vision", + "id": "Doubao-pro-256k", + "name": "Doubao-pro-256k", + "display_name": "Doubao-pro-256k", "limit": { "context": 8192, "output": 8192 @@ -251092,15 +251211,16 @@ "supported": false }, "cost": { - "input": 1, - "output": 1 + "input": 0.8, + "output": 1.44, + "cache_read": 0.8 }, "type": "chat" }, { - "id": "gemma-7b-it", - "name": "gemma-7b-it", - "display_name": "gemma-7b-it", + "id": "Doubao-pro-32k", + "name": "Doubao-pro-32k", + "display_name": "Doubao-pro-32k", "limit": { "context": 8192, "output": 8192 @@ -251110,15 +251230,16 @@ "supported": false }, "cost": { - "input": 0.1, - "output": 0.1 + "input": 0.14, + "output": 0.35, + "cache_read": 0.028 }, "type": "chat" }, { - "id": "glm-3-turbo", - "name": "glm-3-turbo", - "display_name": "glm-3-turbo", + "id": "Doubao-pro-4k", + "name": "Doubao-pro-4k", + "display_name": "Doubao-pro-4k", "limit": { "context": 8192, "output": 8192 @@ -251128,33 +251249,38 @@ "supported": false }, "cost": { - "input": 0.71, - "output": 0.71 + "input": 0.14, + "output": 0.35 }, "type": "chat" }, { - "id": "glm-4", - "name": "glm-4", - "display_name": "glm-4", + "id": "GPT-OSS-20B", + "name": "GPT-OSS-20B", + "display_name": "GPT-OSS-20B", "limit": { "context": 8192, "output": 8192 }, "tool_call": false, "reasoning": { - "supported": false + "supported": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } }, "cost": { - "input": 14.2, - "output": 14.2 + "input": 0.11, + "output": 0.55 }, "type": "chat" }, { - "id": "glm-4-flash", - "name": "glm-4-flash", - "display_name": "glm-4-flash", + "id": "Gryphe/MythoMax-L2-13b", + "name": "Gryphe/MythoMax-L2-13b", + "display_name": "Gryphe/MythoMax-L2-13b", "limit": { "context": 8192, "output": 8192 @@ -251164,15 +251290,20 @@ "supported": false }, "cost": { - "input": 0.1, - "output": 0.1 + "input": 0.4, + "output": 0.4 }, "type": "chat" }, { - "id": "glm-4-plus", - "name": "glm-4-plus", - "display_name": "glm-4-plus", + "id": "MiniMax-Text-01", + "name": "MiniMax-Text-01", + "display_name": "MiniMax-Text-01", + "modalities": { + "input": [ + "text" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -251182,20 +251313,15 @@ "supported": false }, "cost": { - "input": 8, - "output": 8 + "input": 0.14, + "output": 1.12 }, "type": "chat" }, { - "id": "glm-4.5-airx", - "name": "glm-4.5-airx", - "display_name": "glm-4.5-airx", - "modalities": { - "input": [ - "text" - ] - }, + "id": "Mistral-large-2407", + "name": "Mistral-large-2407", + "display_name": "Mistral-large-2407", "limit": { "context": 8192, "output": 8192 @@ -251205,16 +251331,15 @@ "supported": false }, "cost": { - "input": 1.1, - "output": 4.51, - "cache_read": 0.22 + "input": 3, + "output": 9 }, "type": "chat" }, { - "id": "glm-4v", - "name": "glm-4v", - "display_name": "glm-4v", + "id": "Qwen/Qwen2-1.5B-Instruct", + "name": "Qwen/Qwen2-1.5B-Instruct", + "display_name": "Qwen/Qwen2-1.5B-Instruct", "limit": { "context": 8192, "output": 8192 @@ -251224,15 +251349,15 @@ "supported": false }, "cost": { - "input": 14.2, - "output": 14.2 + "input": 0.2, + "output": 0.2 }, "type": "chat" }, { - "id": "glm-4v-plus", - "name": "glm-4v-plus", - "display_name": "glm-4v-plus", + "id": "Qwen/Qwen2-57B-A14B-Instruct", + "name": "Qwen/Qwen2-57B-A14B-Instruct", + "display_name": "Qwen/Qwen2-57B-A14B-Instruct", "limit": { "context": 8192, "output": 8192 @@ -251242,15 +251367,15 @@ "supported": false }, "cost": { - "input": 2, - "output": 2 + "input": 0.24, + "output": 0.24 }, "type": "chat" }, { - "id": "google-gemma-3-12b-it", - "name": "google-gemma-3-12b-it", - "display_name": "google-gemma-3-12b-it", + "id": "Qwen/Qwen2-72B-Instruct", + "name": "Qwen/Qwen2-72B-Instruct", + "display_name": "Qwen/Qwen2-72B-Instruct", "limit": { "context": 8192, "output": 8192 @@ -251260,15 +251385,15 @@ "supported": false }, "cost": { - "input": 0.2, - "output": 0.2 + "input": 0.8, + "output": 0.8 }, "type": "chat" }, { - "id": "google-gemma-3-27b-it", - "name": "google-gemma-3-27b-it", - "display_name": "google-gemma-3-27b-it", + "id": "Qwen/Qwen2-7B-Instruct", + "name": "Qwen/Qwen2-7B-Instruct", + "display_name": "Qwen/Qwen2-7B-Instruct", "limit": { "context": 8192, "output": 8192 @@ -251278,16 +251403,15 @@ "supported": false }, "cost": { - "input": 0.2, - "output": 0.2, - "cache_read": 0 + "input": 0.08, + "output": 0.08 }, "type": "chat" }, { - "id": "google-gemma-3-4b-it", - "name": "google-gemma-3-4b-it", - "display_name": "google-gemma-3-4b-it", + "id": "Qwen/Qwen2.5-32B-Instruct", + "name": "Qwen/Qwen2.5-32B-Instruct", + "display_name": "Qwen/Qwen2.5-32B-Instruct", "limit": { "context": 8192, "output": 8192 @@ -251297,16 +251421,15 @@ "supported": false }, "cost": { - "input": 0.2, - "output": 0.2, - "cache_read": 0 + "input": 0.6, + "output": 0.6 }, "type": "chat" }, { - "id": "google/gemini-exp-1114", - "name": "google/gemini-exp-1114", - "display_name": "google/gemini-exp-1114", + "id": "Qwen/Qwen2.5-72B-Instruct", + "name": "Qwen/Qwen2.5-72B-Instruct", + "display_name": "Qwen/Qwen2.5-72B-Instruct", "limit": { "context": 8192, "output": 8192 @@ -251316,15 +251439,15 @@ "supported": false }, "cost": { - "input": 1.25, - "output": 5 + "input": 0.8, + "output": 0.8 }, "type": "chat" }, { - "id": "google/gemma-2-27b-it", - "name": "google/gemma-2-27b-it", - "display_name": "google/gemma-2-27b-it", + "id": "Qwen/Qwen2.5-72B-Instruct-128K", + "name": "Qwen/Qwen2.5-72B-Instruct-128K", + "display_name": "Qwen/Qwen2.5-72B-Instruct-128K", "limit": { "context": 8192, "output": 8192 @@ -251340,9 +251463,9 @@ "type": "chat" }, { - "id": "google/gemma-2-9b-it:free", - "name": "google/gemma-2-9b-it:free", - "display_name": "google/gemma-2-9b-it:free", + "id": "Qwen/Qwen2.5-7B-Instruct", + "name": "Qwen/Qwen2.5-7B-Instruct", + "display_name": "Qwen/Qwen2.5-7B-Instruct", "limit": { "context": 8192, "output": 8192 @@ -251352,15 +251475,15 @@ "supported": false }, "cost": { - "input": 0.02, - "output": 0.02 + "input": 0.4, + "output": 0.4 }, "type": "chat" }, { - "id": "gpt-3.5-turbo", - "name": "gpt-3.5-turbo", - "display_name": "gpt-3.5-turbo", + "id": "Qwen/Qwen2.5-Coder-32B-Instruct", + "name": "Qwen/Qwen2.5-Coder-32B-Instruct", + "display_name": "Qwen/Qwen2.5-Coder-32B-Instruct", "limit": { "context": 8192, "output": 8192 @@ -251370,33 +251493,50 @@ "supported": false }, "cost": { - "input": 0.5, - "output": 1.5 + "input": 0.16, + "output": 0.16 }, "type": "chat" }, { - "id": "gpt-3.5-turbo-0301", - "name": "gpt-3.5-turbo-0301", - "display_name": "gpt-3.5-turbo-0301", + "id": "Qwen3-235B-A22B-Thinking-2507", + "name": "Qwen3-235B-A22B-Thinking-2507", + "display_name": "Qwen3-235B-A22B-Thinking-2507", "limit": { "context": 8192, "output": 8192 }, "tool_call": false, "reasoning": { - "supported": false + "supported": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } }, "cost": { - "input": 1.5, - "output": 1.5 + "input": 0.28, + "output": 2.8 }, "type": "chat" }, { - "id": "gpt-3.5-turbo-0613", - "name": "gpt-3.5-turbo-0613", - "display_name": "gpt-3.5-turbo-0613", + "id": "Stable-Diffusion-3-5-Large", + "name": "Stable-Diffusion-3-5-Large", + "display_name": "Stable-Diffusion-3-5-Large", + "modalities": { + "input": [ + "text", + "image" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -251406,15 +251546,16 @@ "supported": false }, "cost": { - "input": 1.5, - "output": 2 + "input": 4, + "output": 4, + "cache_read": 0 }, - "type": "chat" + "type": "imageGeneration" }, { - "id": "gpt-3.5-turbo-1106", - "name": "gpt-3.5-turbo-1106", - "display_name": "gpt-3.5-turbo-1106", + "id": "WizardLM/WizardCoder-Python-34B-V1.0", + "name": "WizardLM/WizardCoder-Python-34B-V1.0", + "display_name": "WizardLM/WizardCoder-Python-34B-V1.0", "limit": { "context": 8192, "output": 8192 @@ -251424,15 +251565,15 @@ "supported": false }, "cost": { - "input": 1, - "output": 2 + "input": 0.9, + "output": 0.9 }, "type": "chat" }, { - "id": "gpt-3.5-turbo-16k", - "name": "gpt-3.5-turbo-16k", - "display_name": "gpt-3.5-turbo-16k", + "id": "ahm-Phi-3-5-MoE-instruct", + "name": "ahm-Phi-3-5-MoE-instruct", + "display_name": "ahm-Phi-3-5-MoE-instruct", "limit": { "context": 8192, "output": 8192 @@ -251442,15 +251583,15 @@ "supported": false }, "cost": { - "input": 3, - "output": 4 + "input": 0.4, + "output": 1.6 }, "type": "chat" }, { - "id": "gpt-3.5-turbo-16k-0613", - "name": "gpt-3.5-turbo-16k-0613", - "display_name": "gpt-3.5-turbo-16k-0613", + "id": "ahm-Phi-3-5-mini-instruct", + "name": "ahm-Phi-3-5-mini-instruct", + "display_name": "ahm-Phi-3-5-mini-instruct", "limit": { "context": 8192, "output": 8192 @@ -251460,15 +251601,21 @@ "supported": false }, "cost": { - "input": 3, - "output": 4 + "input": 1, + "output": 3 }, "type": "chat" }, { - "id": "gpt-3.5-turbo-instruct", - "name": "gpt-3.5-turbo-instruct", - "display_name": "gpt-3.5-turbo-instruct", + "id": "ahm-Phi-3-5-vision-instruct", + "name": "ahm-Phi-3-5-vision-instruct", + "display_name": "ahm-Phi-3-5-vision-instruct", + "modalities": { + "input": [ + "text", + "image" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -251478,15 +251625,15 @@ "supported": false }, "cost": { - "input": 1.5, - "output": 2 + "input": 0.4, + "output": 1.6 }, "type": "chat" }, { - "id": "gpt-4", - "name": "gpt-4", - "display_name": "gpt-4", + "id": "ahm-Phi-3-medium-128k", + "name": "ahm-Phi-3-medium-128k", + "display_name": "ahm-Phi-3-medium-128k", "limit": { "context": 8192, "output": 8192 @@ -251496,15 +251643,15 @@ "supported": false }, "cost": { - "input": 30, - "output": 60 + "input": 6, + "output": 18 }, "type": "chat" }, { - "id": "gpt-4-0125-preview", - "name": "gpt-4-0125-preview", - "display_name": "gpt-4-0125-preview", + "id": "ahm-Phi-3-medium-4k", + "name": "ahm-Phi-3-medium-4k", + "display_name": "ahm-Phi-3-medium-4k", "limit": { "context": 8192, "output": 8192 @@ -251514,15 +251661,15 @@ "supported": false }, "cost": { - "input": 10, - "output": 30 + "input": 1, + "output": 3 }, "type": "chat" }, { - "id": "gpt-4-0314", - "name": "gpt-4-0314", - "display_name": "gpt-4-0314", + "id": "ahm-Phi-3-small-128k", + "name": "ahm-Phi-3-small-128k", + "display_name": "ahm-Phi-3-small-128k", "limit": { "context": 8192, "output": 8192 @@ -251532,15 +251679,15 @@ "supported": false }, "cost": { - "input": 30, - "output": 60 + "input": 1, + "output": 3 }, "type": "chat" }, { - "id": "gpt-4-0613", - "name": "gpt-4-0613", - "display_name": "gpt-4-0613", + "id": "aihubmix-Codestral-2501", + "name": "aihubmix-Codestral-2501", + "display_name": "aihubmix-Codestral-2501", "limit": { "context": 8192, "output": 8192 @@ -251550,15 +251697,20 @@ "supported": false }, "cost": { - "input": 30, - "output": 60 + "input": 0.4, + "output": 1.2 }, "type": "chat" }, { - "id": "gpt-4-1106-preview", - "name": "gpt-4-1106-preview", - "display_name": "gpt-4-1106-preview", + "id": "aihubmix-Cohere-command-r", + "name": "aihubmix-Cohere-command-r", + "display_name": "aihubmix-Cohere-command-r", + "modalities": { + "input": [ + "text" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -251568,15 +251720,15 @@ "supported": false }, "cost": { - "input": 10, - "output": 30 + "input": 0.64, + "output": 1.92 }, "type": "chat" }, { - "id": "gpt-4-32k-0314", - "name": "gpt-4-32k-0314", - "display_name": "gpt-4-32k-0314", + "id": "aihubmix-Jamba-1-5-Large", + "name": "aihubmix-Jamba-1-5-Large", + "display_name": "aihubmix-Jamba-1-5-Large", "limit": { "context": 8192, "output": 8192 @@ -251586,15 +251738,15 @@ "supported": false }, "cost": { - "input": 60, - "output": 120 + "input": 2.2, + "output": 8.8 }, "type": "chat" }, { - "id": "gpt-4-32k-0613", - "name": "gpt-4-32k-0613", - "display_name": "gpt-4-32k-0613", + "id": "aihubmix-Llama-3-1-405B-Instruct", + "name": "aihubmix-Llama-3-1-405B-Instruct", + "display_name": "aihubmix-Llama-3-1-405B-Instruct", "limit": { "context": 8192, "output": 8192 @@ -251604,15 +251756,15 @@ "supported": false }, "cost": { - "input": 60, - "output": 120 + "input": 5, + "output": 15 }, "type": "chat" }, { - "id": "gpt-4-turbo", - "name": "gpt-4-turbo", - "display_name": "gpt-4-turbo", + "id": "aihubmix-Llama-3-1-70B-Instruct", + "name": "aihubmix-Llama-3-1-70B-Instruct", + "display_name": "aihubmix-Llama-3-1-70B-Instruct", "limit": { "context": 8192, "output": 8192 @@ -251622,15 +251774,15 @@ "supported": false }, "cost": { - "input": 10, - "output": 30 + "input": 0.6, + "output": 0.78 }, "type": "chat" }, { - "id": "gpt-4-turbo-2024-04-09", - "name": "gpt-4-turbo-2024-04-09", - "display_name": "gpt-4-turbo-2024-04-09", + "id": "aihubmix-Llama-3-1-8B-Instruct", + "name": "aihubmix-Llama-3-1-8B-Instruct", + "display_name": "aihubmix-Llama-3-1-8B-Instruct", "limit": { "context": 8192, "output": 8192 @@ -251640,15 +251792,15 @@ "supported": false }, "cost": { - "input": 10, - "output": 30 + "input": 0.3, + "output": 0.6 }, "type": "chat" }, { - "id": "gpt-4-turbo-preview", - "name": "gpt-4-turbo-preview", - "display_name": "gpt-4-turbo-preview", + "id": "aihubmix-Llama-3-2-11B-Vision", + "name": "aihubmix-Llama-3-2-11B-Vision", + "display_name": "aihubmix-Llama-3-2-11B-Vision", "limit": { "context": 8192, "output": 8192 @@ -251658,15 +251810,15 @@ "supported": false }, "cost": { - "input": 10, - "output": 30 + "input": 0.4, + "output": 0.4 }, "type": "chat" }, { - "id": "gpt-4-vision-preview", - "name": "gpt-4-vision-preview", - "display_name": "gpt-4-vision-preview", + "id": "aihubmix-Llama-3-2-90B-Vision", + "name": "aihubmix-Llama-3-2-90B-Vision", + "display_name": "aihubmix-Llama-3-2-90B-Vision", "limit": { "context": 8192, "output": 8192 @@ -251676,40 +251828,33 @@ "supported": false }, "cost": { - "input": 10, - "output": 30 + "input": 2.4, + "output": 2.4 }, "type": "chat" }, { - "id": "gpt-4o-2024-05-13", - "name": "gpt-4o-2024-05-13", - "display_name": "gpt-4o-2024-05-13", + "id": "aihubmix-Llama-3-70B-Instruct", + "name": "aihubmix-Llama-3-70B-Instruct", + "display_name": "aihubmix-Llama-3-70B-Instruct", "limit": { - "context": 128000, - "output": 128000 + "context": 8192, + "output": 8192 }, "tool_call": false, "reasoning": { "supported": false }, "cost": { - "input": 5, - "output": 15, - "cache_read": 5 + "input": 0.7, + "output": 0.7 }, "type": "chat" }, { - "id": "gpt-4o-mini-2024-07-18", - "name": "gpt-4o-mini-2024-07-18", - "display_name": "gpt-4o-mini-2024-07-18", - "modalities": { - "input": [ - "text", - "image" - ] - }, + "id": "aihubmix-Mistral-large", + "name": "aihubmix-Mistral-large", + "display_name": "aihubmix-Mistral-large", "limit": { "context": 8192, "output": 8192 @@ -251719,49 +251864,41 @@ "supported": false }, "cost": { - "input": 0.15, - "output": 0.6, - "cache_read": 0.075 + "input": 4, + "output": 12 }, "type": "chat" }, { - "id": "gpt-oss-20b", - "name": "gpt-oss-20b", - "display_name": "gpt-oss-20b", + "id": "aihubmix-command-r-08-2024", + "name": "aihubmix-command-r-08-2024", + "display_name": "aihubmix-command-r-08-2024", "modalities": { "input": [ "text" ] }, "limit": { - "context": 128000, - "output": 128000 + "context": 8192, + "output": 8192 }, - "tool_call": true, + "tool_call": false, "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } + "supported": false }, "cost": { - "input": 0.11, - "output": 0.55 + "input": 0.2, + "output": 0.8 }, "type": "chat" }, { - "id": "grok-2-vision-1212", - "name": "grok-2-vision-1212", - "display_name": "grok-2-vision-1212", + "id": "aihubmix-command-r-plus", + "name": "aihubmix-command-r-plus", + "display_name": "aihubmix-command-r-plus", "modalities": { "input": [ - "text", - "image" + "text" ] }, "limit": { @@ -251773,19 +251910,18 @@ "supported": false }, "cost": { - "input": 1.8, - "output": 9 + "input": 3.84, + "output": 19.2 }, "type": "chat" }, { - "id": "grok-vision-beta", - "name": "grok-vision-beta", - "display_name": "grok-vision-beta", + "id": "aihubmix-command-r-plus-08-2024", + "name": "aihubmix-command-r-plus-08-2024", + "display_name": "aihubmix-command-r-plus-08-2024", "modalities": { "input": [ - "text", - "image" + "text" ] }, "limit": { @@ -251797,15 +251933,15 @@ "supported": false }, "cost": { - "input": 5.6, - "output": 16.8 + "input": 2.8, + "output": 11.2 }, "type": "chat" }, { - "id": "groq-llama-3.1-8b-instant", - "name": "groq-llama-3.1-8b-instant", - "display_name": "groq-llama-3.1-8b-instant", + "id": "alicloud-deepseek-v3.2", + "name": "alicloud-deepseek-v3.2", + "display_name": "alicloud-deepseek-v3.2", "limit": { "context": 8192, "output": 8192 @@ -251815,15 +251951,16 @@ "supported": false }, "cost": { - "input": 0.055, - "output": 0.088 + "input": 0.274, + "output": 0.411, + "cache_read": 0.0548 }, "type": "chat" }, { - "id": "groq-llama-3.3-70b-versatile", - "name": "groq-llama-3.3-70b-versatile", - "display_name": "groq-llama-3.3-70b-versatile", + "id": "alicloud-glm-4.7", + "name": "alicloud-glm-4.7", + "display_name": "alicloud-glm-4.7", "limit": { "context": 8192, "output": 8192 @@ -251833,15 +251970,16 @@ "supported": false }, "cost": { - "input": 0.649, - "output": 0.869011 + "input": 0.41096, + "output": 1.917786, + "cache_read": 0.41096 }, "type": "chat" }, { - "id": "groq-llama-4-maverick-17b-128e-instruct", - "name": "groq-llama-4-maverick-17b-128e-instruct", - "display_name": "groq-llama-4-maverick-17b-128e-instruct", + "id": "alicloud-kimi-k2-thinking", + "name": "alicloud-kimi-k2-thinking", + "display_name": "alicloud-kimi-k2-thinking", "limit": { "context": 8192, "output": 8192 @@ -251851,39 +251989,34 @@ "supported": false }, "cost": { - "input": 0.22, - "output": 0.66 + "input": 0.548, + "output": 2.192 }, "type": "chat" }, { - "id": "groq-llama-4-scout-17b-16e-instruct", - "name": "groq-llama-4-scout-17b-16e-instruct", - "display_name": "groq-llama-4-scout-17b-16e-instruct", + "id": "alicloud-kimi-k2.5", + "name": "alicloud-kimi-k2.5", + "display_name": "alicloud-kimi-k2.5", "limit": { - "context": 8192, - "output": 8192 + "context": 256000, + "output": 256000 }, "tool_call": false, "reasoning": { "supported": false }, "cost": { - "input": 0.122, - "output": 0.366 + "input": 0.548, + "output": 2.877, + "cache_read": 0.0959 }, "type": "chat" }, { - "id": "imagen-4.0-generate-preview-05-20", - "name": "imagen-4.0-generate-preview-05-20", - "display_name": "imagen-4.0-generate-preview-05-20", - "modalities": { - "input": [ - "text", - "image" - ] - }, + "id": "alicloud-minimax-m2.5", + "name": "alicloud-minimax-m2.5", + "display_name": "alicloud-minimax-m2.5", "limit": { "context": 8192, "output": 8192 @@ -251893,39 +252026,47 @@ "supported": false }, "cost": { - "input": 2, - "output": 2, - "cache_read": 0 + "input": 0.2876, + "output": 1.1504, + "cache_read": 0.05752 }, - "type": "imageGeneration" + "type": "chat" }, { - "id": "jina-embeddings-v2-base-code", - "name": "jina-embeddings-v2-base-code", - "display_name": "jina-embeddings-v2-base-code", + "id": "anthropic-opus-4-6", + "name": "anthropic-opus-4-6", + "display_name": "anthropic-opus-4-6", "modalities": { "input": [ - "text" + "text", + "image" ] }, "limit": { - "context": 8192, - "output": 8192 + "context": 200000, + "output": 200000 }, - "tool_call": false, + "tool_call": true, "reasoning": { - "supported": false + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } }, "cost": { - "input": 0.05, - "output": 0.05 + "input": 5, + "output": 25, + "cache_read": 0.5 }, - "type": "embedding" + "type": "chat" }, { - "id": "learnlm-1.5-pro-experimental", - "name": "learnlm-1.5-pro-experimental", - "display_name": "learnlm-1.5-pro-experimental", + "id": "azure-deepseek-v3.2", + "name": "azure-deepseek-v3.2", + "display_name": "azure-deepseek-v3.2", "limit": { "context": 8192, "output": 8192 @@ -251935,15 +252076,15 @@ "supported": false }, "cost": { - "input": 1.25, - "output": 5 + "input": 0.58, + "output": 1.680028 }, "type": "chat" }, { - "id": "llama-3.1-405b-instruct", - "name": "llama-3.1-405b-instruct", - "display_name": "llama-3.1-405b-instruct", + "id": "azure-deepseek-v3.2-speciale", + "name": "azure-deepseek-v3.2-speciale", + "display_name": "azure-deepseek-v3.2-speciale", "limit": { "context": 8192, "output": 8192 @@ -251953,33 +252094,33 @@ "supported": false }, "cost": { - "input": 4, - "output": 4 + "input": 0.58, + "output": 1.680028 }, "type": "chat" }, { - "id": "llama-3.1-405b-reasoning", - "name": "llama-3.1-405b-reasoning", - "display_name": "llama-3.1-405b-reasoning", + "id": "azure-kimi-k2.5", + "name": "azure-kimi-k2.5", + "display_name": "azure-kimi-k2.5", "limit": { - "context": 8192, - "output": 8192 + "context": 256000, + "output": 256000 }, "tool_call": false, "reasoning": { "supported": false }, "cost": { - "input": 4, - "output": 4 + "input": 0.6, + "output": 3 }, "type": "chat" }, { - "id": "llama-3.1-70b-versatile", - "name": "llama-3.1-70b-versatile", - "display_name": "llama-3.1-70b-versatile", + "id": "cbs-glm-4.7", + "name": "cbs-glm-4.7", + "display_name": "cbs-glm-4.7", "limit": { "context": 8192, "output": 8192 @@ -251989,15 +252130,15 @@ "supported": false }, "cost": { - "input": 0.6, - "output": 0.6 + "input": 2.25, + "output": 2.749995 }, "type": "chat" }, { - "id": "llama-3.1-8b-instant", - "name": "llama-3.1-8b-instant", - "display_name": "llama-3.1-8b-instant", + "id": "cerebras-llama-3.3-70b", + "name": "cerebras-llama-3.3-70b", + "display_name": "cerebras-llama-3.3-70b", "limit": { "context": 8192, "output": 8192 @@ -252007,15 +252148,15 @@ "supported": false }, "cost": { - "input": 0.3, + "input": 0.6, "output": 0.6 }, "type": "chat" }, { - "id": "llama-3.1-sonar-small-128k-online", - "name": "llama-3.1-sonar-small-128k-online", - "display_name": "llama-3.1-sonar-small-128k-online", + "id": "chatglm_lite", + "name": "chatglm_lite", + "display_name": "chatglm_lite", "limit": { "context": 8192, "output": 8192 @@ -252025,15 +252166,15 @@ "supported": false }, "cost": { - "input": 0.3, - "output": 0.3 + "input": 0.2858, + "output": 0.2858 }, "type": "chat" }, { - "id": "llama-3.2-11b-vision-preview", - "name": "llama-3.2-11b-vision-preview", - "display_name": "llama-3.2-11b-vision-preview", + "id": "chatglm_pro", + "name": "chatglm_pro", + "display_name": "chatglm_pro", "limit": { "context": 8192, "output": 8192 @@ -252043,15 +252184,15 @@ "supported": false }, "cost": { - "input": 0.2, - "output": 0.2 + "input": 1.4286, + "output": 1.4286 }, "type": "chat" }, { - "id": "llama-3.2-1b-preview", - "name": "llama-3.2-1b-preview", - "display_name": "llama-3.2-1b-preview", + "id": "chatglm_std", + "name": "chatglm_std", + "display_name": "chatglm_std", "limit": { "context": 8192, "output": 8192 @@ -252061,15 +252202,15 @@ "supported": false }, "cost": { - "input": 0.2, - "output": 0.2 + "input": 0.7144, + "output": 0.7144 }, "type": "chat" }, { - "id": "llama-3.2-3b-preview", - "name": "llama-3.2-3b-preview", - "display_name": "llama-3.2-3b-preview", + "id": "chatglm_turbo", + "name": "chatglm_turbo", + "display_name": "chatglm_turbo", "limit": { "context": 8192, "output": 8192 @@ -252079,15 +252220,15 @@ "supported": false }, "cost": { - "input": 0.2, - "output": 0.2 + "input": 0.7144, + "output": 0.7144 }, "type": "chat" }, { - "id": "llama-3.2-90b-vision-preview", - "name": "llama-3.2-90b-vision-preview", - "display_name": "llama-3.2-90b-vision-preview", + "id": "claude-2", + "name": "claude-2", + "display_name": "claude-2", "limit": { "context": 8192, "output": 8192 @@ -252097,15 +252238,15 @@ "supported": false }, "cost": { - "input": 2.4, - "output": 2.4 + "input": 8.8, + "output": 8.8 }, "type": "chat" }, { - "id": "llama2-70b-4096", - "name": "llama2-70b-4096", - "display_name": "llama2-70b-4096", + "id": "claude-2.0", + "name": "claude-2.0", + "display_name": "claude-2.0", "limit": { "context": 8192, "output": 8192 @@ -252115,15 +252256,15 @@ "supported": false }, "cost": { - "input": 0.5, - "output": 0.5 + "input": 8.8, + "output": 39.6 }, "type": "chat" }, { - "id": "llama2-70b-40960", - "name": "llama2-70b-40960", - "display_name": "llama2-70b-40960", + "id": "claude-2.1", + "name": "claude-2.1", + "display_name": "claude-2.1", "limit": { "context": 8192, "output": 8192 @@ -252133,15 +252274,21 @@ "supported": false }, "cost": { - "input": 0.5, - "output": 0.5 + "input": 8.8, + "output": 39.6 }, "type": "chat" }, { - "id": "llama2-7b-2048", - "name": "llama2-7b-2048", - "display_name": "llama2-7b-2048", + "id": "claude-3-haiku-20240229", + "name": "claude-3-haiku-20240229", + "display_name": "claude-3-haiku-20240229", + "modalities": { + "input": [ + "text", + "image" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -252151,15 +252298,21 @@ "supported": false }, "cost": { - "input": 0.1, - "output": 0.1 + "input": 0.275, + "output": 0.275 }, "type": "chat" }, { - "id": "llama3-70b-8192", - "name": "llama3-70b-8192", - "display_name": "llama3-70b-8192", + "id": "claude-3-haiku-20240307", + "name": "claude-3-haiku-20240307", + "display_name": "claude-3-haiku-20240307", + "modalities": { + "input": [ + "text", + "image" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -252169,15 +252322,21 @@ "supported": false }, "cost": { - "input": 0.7, - "output": 0.937288 + "input": 0.275, + "output": 1.375 }, "type": "chat" }, { - "id": "llama3-8b-8192", - "name": "llama3-8b-8192", - "display_name": "llama3-8b-8192", + "id": "claude-3-sonnet-20240229", + "name": "claude-3-sonnet-20240229", + "display_name": "claude-3-sonnet-20240229", + "modalities": { + "input": [ + "text", + "image" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -252187,15 +252346,15 @@ "supported": false }, "cost": { - "input": 0.06, - "output": 0.12 + "input": 3.3, + "output": 16.5 }, "type": "chat" }, { - "id": "llama3-groq-70b-8192-tool-use-preview", - "name": "llama3-groq-70b-8192-tool-use-preview", - "display_name": "llama3-groq-70b-8192-tool-use-preview", + "id": "claude-instant-1", + "name": "claude-instant-1", + "display_name": "claude-instant-1", "limit": { "context": 8192, "output": 8192 @@ -252205,15 +252364,15 @@ "supported": false }, "cost": { - "input": 0.00089, - "output": 0.00089 + "input": 1.793, + "output": 1.793 }, "type": "chat" }, { - "id": "llama3-groq-8b-8192-tool-use-preview", - "name": "llama3-groq-8b-8192-tool-use-preview", - "display_name": "llama3-groq-8b-8192-tool-use-preview", + "id": "claude-instant-1.2", + "name": "claude-instant-1.2", + "display_name": "claude-instant-1.2", "limit": { "context": 8192, "output": 8192 @@ -252223,15 +252382,15 @@ "supported": false }, "cost": { - "input": 0.00019, - "output": 0.00019 + "input": 0.88, + "output": 3.96 }, "type": "chat" }, { - "id": "mai-image-2", - "name": "mai-image-2", - "display_name": "mai-image-2", + "id": "code-davinci-edit-001", + "name": "code-davinci-edit-001", + "display_name": "code-davinci-edit-001", "limit": { "context": 8192, "output": 8192 @@ -252241,16 +252400,15 @@ "supported": false }, "cost": { - "input": 2, - "output": 2, - "cache_read": 0 + "input": 20, + "output": 20 }, - "type": "imageGeneration" + "type": "chat" }, { - "id": "meta-llama/Llama-3.2-90B-Vision-Instruct", - "name": "meta-llama/Llama-3.2-90B-Vision-Instruct", - "display_name": "meta-llama/Llama-3.2-90B-Vision-Instruct", + "id": "cogview-3", + "name": "cogview-3", + "display_name": "cogview-3", "limit": { "context": 8192, "output": 8192 @@ -252260,15 +252418,15 @@ "supported": false }, "cost": { - "input": 0.5, - "output": 0.5 + "input": 35.5, + "output": 35.5 }, "type": "chat" }, { - "id": "meta-llama/llama-3.1-405b-instruct:free", - "name": "meta-llama/llama-3.1-405b-instruct:free", - "display_name": "meta-llama/llama-3.1-405b-instruct:free", + "id": "cogview-3-plus", + "name": "cogview-3-plus", + "display_name": "cogview-3-plus", "limit": { "context": 8192, "output": 8192 @@ -252278,15 +252436,20 @@ "supported": false }, "cost": { - "input": 0.02, - "output": 0.02 + "input": 10, + "output": 10 }, "type": "chat" }, { - "id": "meta-llama/llama-3.1-70b-instruct:free", - "name": "meta-llama/llama-3.1-70b-instruct:free", - "display_name": "meta-llama/llama-3.1-70b-instruct:free", + "id": "command", + "name": "command", + "display_name": "command", + "modalities": { + "input": [ + "text" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -252296,15 +252459,15 @@ "supported": false }, "cost": { - "input": 0.02, - "output": 0.02 + "input": 1, + "output": 2 }, "type": "chat" }, { - "id": "meta-llama/llama-3.1-8b-instruct:free", - "name": "meta-llama/llama-3.1-8b-instruct:free", - "display_name": "meta-llama/llama-3.1-8b-instruct:free", + "id": "command-light", + "name": "command-light", + "display_name": "command-light", "limit": { "context": 8192, "output": 8192 @@ -252314,15 +252477,15 @@ "supported": false }, "cost": { - "input": 0.02, - "output": 0.02 + "input": 1, + "output": 2 }, "type": "chat" }, { - "id": "meta-llama/llama-3.2-11b-vision-instruct:free", - "name": "meta-llama/llama-3.2-11b-vision-instruct:free", - "display_name": "meta-llama/llama-3.2-11b-vision-instruct:free", + "id": "command-light-nightly", + "name": "command-light-nightly", + "display_name": "command-light-nightly", "limit": { "context": 8192, "output": 8192 @@ -252332,15 +252495,15 @@ "supported": false }, "cost": { - "input": 0.02, - "output": 0.02 + "input": 1, + "output": 2 }, "type": "chat" }, { - "id": "meta-llama/llama-3.2-3b-instruct:free", - "name": "meta-llama/llama-3.2-3b-instruct:free", - "display_name": "meta-llama/llama-3.2-3b-instruct:free", + "id": "command-nightly", + "name": "command-nightly", + "display_name": "command-nightly", "limit": { "context": 8192, "output": 8192 @@ -252350,15 +252513,20 @@ "supported": false }, "cost": { - "input": 0.02, - "output": 0.02 + "input": 1, + "output": 2 }, "type": "chat" }, { - "id": "meta/llama-3.1-405b-instruct", - "name": "meta/llama-3.1-405b-instruct", - "display_name": "meta/llama-3.1-405b-instruct", + "id": "command-r", + "name": "command-r", + "display_name": "command-r", + "modalities": { + "input": [ + "text" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -252368,15 +252536,20 @@ "supported": false }, "cost": { - "input": 5, - "output": 5 + "input": 0.64, + "output": 1.92 }, "type": "chat" }, { - "id": "meta/llama3-8B-chat", - "name": "meta/llama3-8B-chat", - "display_name": "meta/llama3-8B-chat", + "id": "command-r-08-2024", + "name": "command-r-08-2024", + "display_name": "command-r-08-2024", + "modalities": { + "input": [ + "text" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -252386,15 +252559,20 @@ "supported": false }, "cost": { - "input": 0.3, - "output": 0.3 + "input": 0.2, + "output": 0.8 }, "type": "chat" }, { - "id": "mistralai/mistral-7b-instruct:free", - "name": "mistralai/mistral-7b-instruct:free", - "display_name": "mistralai/mistral-7b-instruct:free", + "id": "command-r-plus", + "name": "command-r-plus", + "display_name": "command-r-plus", + "modalities": { + "input": [ + "text" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -252404,15 +252582,20 @@ "supported": false }, "cost": { - "input": 0.002, - "output": 0.002 + "input": 3.84, + "output": 19.2 }, "type": "chat" }, { - "id": "mm-minimax-m3", - "name": "mm-minimax-m3", - "display_name": "mm-minimax-m3", + "id": "command-r-plus-08-2024", + "name": "command-r-plus-08-2024", + "display_name": "command-r-plus-08-2024", + "modalities": { + "input": [ + "text" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -252422,15 +252605,21 @@ "supported": false }, "cost": { - "input": 0.288, - "output": 1.152 + "input": 2.8, + "output": 11.2 }, "type": "chat" }, { - "id": "moonshot-kimi-k2.5", - "name": "moonshot-kimi-k2.5", - "display_name": "moonshot-kimi-k2.5", + "id": "dall-e-2", + "name": "dall-e-2", + "display_name": "dall-e-2", + "modalities": { + "input": [ + "text", + "image" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -252440,16 +252629,15 @@ "supported": false }, "cost": { - "input": 0.6, - "output": 3, - "cache_read": 0.105 + "input": 16, + "output": 16 }, - "type": "chat" + "type": "imageGeneration" }, { - "id": "moonshot-v1-128k", - "name": "moonshot-v1-128k", - "display_name": "moonshot-v1-128k", + "id": "davinci", + "name": "davinci", + "display_name": "davinci", "limit": { "context": 8192, "output": 8192 @@ -252459,15 +252647,15 @@ "supported": false }, "cost": { - "input": 10, - "output": 10 + "input": 20, + "output": 20 }, "type": "chat" }, { - "id": "moonshot-v1-128k-vision-preview", - "name": "moonshot-v1-128k-vision-preview", - "display_name": "moonshot-v1-128k-vision-preview", + "id": "davinci-002", + "name": "davinci-002", + "display_name": "davinci-002", "limit": { "context": 8192, "output": 8192 @@ -252477,15 +252665,15 @@ "supported": false }, "cost": { - "input": 10, - "output": 10 + "input": 2, + "output": 2 }, "type": "chat" }, { - "id": "moonshot-v1-32k", - "name": "moonshot-v1-32k", - "display_name": "moonshot-v1-32k", + "id": "deepinfra-llama-3.1-8b-instant", + "name": "deepinfra-llama-3.1-8b-instant", + "display_name": "deepinfra-llama-3.1-8b-instant", "limit": { "context": 8192, "output": 8192 @@ -252495,15 +252683,15 @@ "supported": false }, "cost": { - "input": 4, - "output": 4 + "input": 0.033, + "output": 0.054978 }, "type": "chat" }, { - "id": "moonshot-v1-32k-vision-preview", - "name": "moonshot-v1-32k-vision-preview", - "display_name": "moonshot-v1-32k-vision-preview", + "id": "deepinfra-llama-3.3-70b-instant-turbo", + "name": "deepinfra-llama-3.3-70b-instant-turbo", + "display_name": "deepinfra-llama-3.3-70b-instant-turbo", "limit": { "context": 8192, "output": 8192 @@ -252513,15 +252701,15 @@ "supported": false }, "cost": { - "input": 4, - "output": 4 + "input": 0.11, + "output": 0.352 }, "type": "chat" }, { - "id": "moonshot-v1-8k", - "name": "moonshot-v1-8k", - "display_name": "moonshot-v1-8k", + "id": "deepinfra-llama-4-maverick-17b-128e-instruct", + "name": "deepinfra-llama-4-maverick-17b-128e-instruct", + "display_name": "deepinfra-llama-4-maverick-17b-128e-instruct", "limit": { "context": 8192, "output": 8192 @@ -252531,15 +252719,15 @@ "supported": false }, "cost": { - "input": 2, - "output": 2 + "input": 0.33, + "output": 1.32 }, "type": "chat" }, { - "id": "moonshot-v1-8k-vision-preview", - "name": "moonshot-v1-8k-vision-preview", - "display_name": "moonshot-v1-8k-vision-preview", + "id": "deepinfra-llama-4-scout-17b-16e-instruct", + "name": "deepinfra-llama-4-scout-17b-16e-instruct", + "display_name": "deepinfra-llama-4-scout-17b-16e-instruct", "limit": { "context": 8192, "output": 8192 @@ -252549,15 +252737,16 @@ "supported": false }, "cost": { - "input": 2, - "output": 2 + "input": 0.088, + "output": 0.33, + "cache_read": 0 }, "type": "chat" }, { - "id": "nvidia/Llama-3_1-Nemotron-Ultra-253B-v1", - "name": "nvidia/Llama-3_1-Nemotron-Ultra-253B-v1", - "display_name": "nvidia/Llama-3_1-Nemotron-Ultra-253B-v1", + "id": "deepseek-ai/DeepSeek-Coder-V2-Instruct", + "name": "deepseek-ai/DeepSeek-Coder-V2-Instruct", + "display_name": "deepseek-ai/DeepSeek-Coder-V2-Instruct", "limit": { "context": 8192, "output": 8192 @@ -252567,50 +252756,38 @@ "supported": false }, "cost": { - "input": 0.5, - "output": 0.5, - "cache_read": 0 + "input": 0.16, + "output": 0.32 }, "type": "chat" }, { - "id": "o1-mini-2024-09-12", - "name": "o1-mini-2024-09-12", - "display_name": "o1-mini-2024-09-12", + "id": "deepseek-ai/DeepSeek-R1-Distill-Llama-70B", + "name": "deepseek-ai/DeepSeek-R1-Distill-Llama-70B", + "display_name": "deepseek-ai/DeepSeek-R1-Distill-Llama-70B", "limit": { "context": 8192, "output": 8192 }, "tool_call": false, "reasoning": { - "supported": true, - "default": true + "supported": true }, "extra_capabilities": { "reasoning": { - "supported": true, - "default_enabled": true, - "mode": "effort", - "effort": "medium", - "effort_options": [ - "low", - "medium", - "high" - ], - "visibility": "hidden" + "supported": true } }, "cost": { - "input": 3, - "output": 12, - "cache_read": 1.5 + "input": 0.6, + "output": 0.6 }, "type": "chat" }, { - "id": "omni-moderation-latest", - "name": "omni-moderation-latest", - "display_name": "omni-moderation-latest", + "id": "deepseek-ai/DeepSeek-R1-Distill-Llama-8B", + "name": "deepseek-ai/DeepSeek-R1-Distill-Llama-8B", + "display_name": "deepseek-ai/DeepSeek-R1-Distill-Llama-8B", "limit": { "context": 8192, "output": 8192 @@ -252620,75 +252797,51 @@ "supported": false }, "cost": { - "input": 0.02, - "output": 0.02 + "input": 0.01, + "output": 0.01 }, "type": "chat" }, { - "id": "qwen-flash", - "name": "qwen-flash", - "display_name": "qwen-flash", + "id": "deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B", + "name": "deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B", + "display_name": "deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B", "limit": { "context": 8192, "output": 8192 }, "tool_call": false, "reasoning": { - "supported": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ] - } + "supported": false }, "cost": { - "input": 0.02, - "output": 0.2, - "cache_read": 0.02 + "input": 0.01, + "output": 0.01 }, "type": "chat" }, { - "id": "qwen-flash-2025-07-28", - "name": "qwen-flash-2025-07-28", - "display_name": "qwen-flash-2025-07-28", + "id": "deepseek-ai/DeepSeek-R1-Distill-Qwen-14B", + "name": "deepseek-ai/DeepSeek-R1-Distill-Qwen-14B", + "display_name": "deepseek-ai/DeepSeek-R1-Distill-Qwen-14B", "limit": { "context": 8192, "output": 8192 }, "tool_call": false, "reasoning": { - "supported": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ] - } + "supported": false }, "cost": { - "input": 0.02, - "output": 0.2, - "cache_read": 0.02 + "input": 0.1, + "output": 0.1 }, "type": "chat" }, { - "id": "qwen-long", - "name": "qwen-long", - "display_name": "qwen-long", + "id": "deepseek-ai/DeepSeek-R1-Distill-Qwen-32B", + "name": "deepseek-ai/DeepSeek-R1-Distill-Qwen-32B", + "display_name": "deepseek-ai/DeepSeek-R1-Distill-Qwen-32B", "limit": { "context": 8192, "output": 8192 @@ -252698,15 +252851,15 @@ "supported": false }, "cost": { - "input": 0.1, - "output": 0.4 + "input": 0.2, + "output": 0.2 }, "type": "chat" }, { - "id": "qwen-max", - "name": "qwen-max", - "display_name": "qwen-max", + "id": "deepseek-ai/DeepSeek-R1-Distill-Qwen-7B", + "name": "deepseek-ai/DeepSeek-R1-Distill-Qwen-7B", + "display_name": "deepseek-ai/DeepSeek-R1-Distill-Qwen-7B", "limit": { "context": 8192, "output": 8192 @@ -252716,15 +252869,15 @@ "supported": false }, "cost": { - "input": 0.38, - "output": 1.52 + "input": 0.01, + "output": 0.01 }, "type": "chat" }, { - "id": "qwen-max-longcontext", - "name": "qwen-max-longcontext", - "display_name": "qwen-max-longcontext", + "id": "deepseek-ai/DeepSeek-V2-Chat", + "name": "deepseek-ai/DeepSeek-V2-Chat", + "display_name": "deepseek-ai/DeepSeek-V2-Chat", "limit": { "context": 8192, "output": 8192 @@ -252734,85 +252887,51 @@ "supported": false }, "cost": { - "input": 7, - "output": 21 + "input": 0.16, + "output": 0.32 }, "type": "chat" }, { - "id": "qwen-plus", - "name": "qwen-plus", - "display_name": "qwen-plus", + "id": "deepseek-ai/DeepSeek-V2.5", + "name": "deepseek-ai/DeepSeek-V2.5", + "display_name": "deepseek-ai/DeepSeek-V2.5", "limit": { "context": 8192, "output": 8192 }, "tool_call": false, "reasoning": { - "supported": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ] - } + "supported": false }, "cost": { - "input": 0.1126, - "output": 1.126, - "cache_read": 0.02252 + "input": 0.16, + "output": 0.32 }, "type": "chat" }, { - "id": "qwen-turbo", - "name": "qwen-turbo", - "display_name": "qwen-turbo", - "modalities": { - "input": [ - "text" - ] - }, + "id": "deepseek-ai/deepseek-llm-67b-chat", + "name": "deepseek-ai/deepseek-llm-67b-chat", + "display_name": "deepseek-ai/deepseek-llm-67b-chat", "limit": { "context": 8192, "output": 8192 }, "tool_call": false, "reasoning": { - "supported": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ] - } + "supported": false }, "cost": { - "input": 0.046, - "output": 0.092, - "cache_read": 0.0092 + "input": 0.16, + "output": 0.16 }, "type": "chat" }, { - "id": "qwen-turbo-2024-11-01", - "name": "qwen-turbo-2024-11-01", - "display_name": "qwen-turbo-2024-11-01", - "modalities": { - "input": [ - "text" - ] - }, + "id": "deepseek-ai/deepseek-vl2", + "name": "deepseek-ai/deepseek-vl2", + "display_name": "deepseek-ai/deepseek-vl2", "limit": { "context": 8192, "output": 8192 @@ -252822,15 +252941,15 @@ "supported": false }, "cost": { - "input": 0.046, - "output": 0.092 + "input": 0.16, + "output": 0.16 }, "type": "chat" }, { - "id": "qwen2.5-14b-instruct", - "name": "qwen2.5-14b-instruct", - "display_name": "qwen2.5-14b-instruct", + "id": "deepseek-v3", + "name": "deepseek-v3", + "display_name": "deepseek-v3", "limit": { "context": 8192, "output": 8192 @@ -252840,15 +252959,21 @@ "supported": false }, "cost": { - "input": 0.4, - "output": 1.2 + "input": 0.272, + "output": 1.088, + "cache_read": 0 }, "type": "chat" }, { - "id": "qwen2.5-32b-instruct", - "name": "qwen2.5-32b-instruct", - "display_name": "qwen2.5-32b-instruct", + "id": "distil-whisper-large-v3-en", + "name": "distil-whisper-large-v3-en", + "display_name": "distil-whisper-large-v3-en", + "modalities": { + "input": [ + "audio" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -252858,15 +252983,15 @@ "supported": false }, "cost": { - "input": 0.6, - "output": 1.2 + "input": 5.556, + "output": 5.556 }, "type": "chat" }, { - "id": "qwen2.5-3b-instruct", - "name": "qwen2.5-3b-instruct", - "display_name": "qwen2.5-3b-instruct", + "id": "doubao-1-5-thinking-vision-pro-250428", + "name": "doubao-1-5-thinking-vision-pro-250428", + "display_name": "doubao-1-5-thinking-vision-pro-250428", "limit": { "context": 8192, "output": 8192 @@ -252876,15 +253001,16 @@ "supported": false }, "cost": { - "input": 0.4, - "output": 0.8 + "input": 2, + "output": 2, + "cache_read": 2 }, "type": "chat" }, { - "id": "qwen2.5-72b-instruct", - "name": "qwen2.5-72b-instruct", - "display_name": "qwen2.5-72b-instruct", + "id": "fx-flux-2-pro", + "name": "fx-flux-2-pro", + "display_name": "fx-flux-2-pro", "limit": { "context": 8192, "output": 8192 @@ -252894,33 +253020,68 @@ "supported": false }, "cost": { - "input": 0.8, - "output": 2.4 + "input": 2, + "output": 0, + "cache_read": 0 }, "type": "chat" }, { - "id": "qwen2.5-7b-instruct", - "name": "qwen2.5-7b-instruct", - "display_name": "qwen2.5-7b-instruct", + "id": "gemini-2.5-pro-exp-03-25", + "name": "gemini-2.5-pro-exp-03-25", + "display_name": "gemini-2.5-pro-exp-03-25", + "modalities": { + "input": [ + "text", + "image", + "audio", + "video" + ] + }, "limit": { "context": 8192, "output": 8192 }, - "tool_call": false, + "tool_call": true, "reasoning": { - "supported": false + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "budget", + "budget": { + "default": -1, + "min": 128, + "max": 32768, + "auto": -1, + "unit": "tokens" + }, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thought_signatures" + ] + } }, "cost": { - "input": 0.4, - "output": 0.8 + "input": 1.25, + "output": 5, + "cache_read": 0.125 }, "type": "chat" }, { - "id": "qwen2.5-coder-1.5b-instruct", - "name": "qwen2.5-coder-1.5b-instruct", - "display_name": "qwen2.5-coder-1.5b-instruct", + "id": "gemini-embedding-exp-03-07", + "name": "gemini-embedding-exp-03-07", + "display_name": "gemini-embedding-exp-03-07", + "modalities": { + "input": [ + "text" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -252930,15 +253091,15 @@ "supported": false }, "cost": { - "input": 0.2, - "output": 0.4 + "input": 0.02, + "output": 0.02 }, - "type": "chat" + "type": "embedding" }, { - "id": "qwen2.5-coder-7b-instruct", - "name": "qwen2.5-coder-7b-instruct", - "display_name": "qwen2.5-coder-7b-instruct", + "id": "gemini-exp-1114", + "name": "gemini-exp-1114", + "display_name": "gemini-exp-1114", "limit": { "context": 8192, "output": 8192 @@ -252948,15 +253109,15 @@ "supported": false }, "cost": { - "input": 0.2, - "output": 0.4 + "input": 1.25, + "output": 5 }, "type": "chat" }, { - "id": "qwen2.5-math-1.5b-instruct", - "name": "qwen2.5-math-1.5b-instruct", - "display_name": "qwen2.5-math-1.5b-instruct", + "id": "gemini-exp-1121", + "name": "gemini-exp-1121", + "display_name": "gemini-exp-1121", "limit": { "context": 8192, "output": 8192 @@ -252966,15 +253127,15 @@ "supported": false }, "cost": { - "input": 0.2, - "output": 0.2 + "input": 1.25, + "output": 5 }, "type": "chat" }, { - "id": "qwen2.5-math-72b-instruct", - "name": "qwen2.5-math-72b-instruct", - "display_name": "qwen2.5-math-72b-instruct", + "id": "gemini-pro", + "name": "gemini-pro", + "display_name": "gemini-pro", "limit": { "context": 8192, "output": 8192 @@ -252984,15 +253145,15 @@ "supported": false }, "cost": { - "input": 0.8, - "output": 2.4 + "input": 0.2, + "output": 0.6 }, "type": "chat" }, { - "id": "qwen2.5-math-7b-instruct", - "name": "qwen2.5-math-7b-instruct", - "display_name": "qwen2.5-math-7b-instruct", + "id": "gemini-pro-vision", + "name": "gemini-pro-vision", + "display_name": "gemini-pro-vision", "limit": { "context": 8192, "output": 8192 @@ -253002,15 +253163,15 @@ "supported": false }, "cost": { - "input": 0.2, - "output": 0.4 + "input": 1, + "output": 1 }, "type": "chat" }, { - "id": "step-2-16k", - "name": "step-2-16k", - "display_name": "step-2-16k", + "id": "gemma-7b-it", + "name": "gemma-7b-it", + "display_name": "gemma-7b-it", "limit": { "context": 8192, "output": 8192 @@ -253020,20 +253181,15 @@ "supported": false }, "cost": { - "input": 2, - "output": 2 + "input": 0.1, + "output": 0.1 }, "type": "chat" }, { - "id": "text-embedding-v1", - "name": "text-embedding-v1", - "display_name": "text-embedding-v1", - "modalities": { - "input": [ - "text" - ] - }, + "id": "glm-3-turbo", + "name": "glm-3-turbo", + "display_name": "glm-3-turbo", "limit": { "context": 8192, "output": 8192 @@ -253043,20 +253199,15 @@ "supported": false }, "cost": { - "input": 0.1, - "output": 0.1 + "input": 0.71, + "output": 0.71 }, - "type": "embedding" + "type": "chat" }, { - "id": "tts-1-hd-1106", - "name": "tts-1-hd-1106", - "display_name": "tts-1-hd-1106", - "modalities": { - "input": [ - "audio" - ] - }, + "id": "glm-4", + "name": "glm-4", + "display_name": "glm-4", "limit": { "context": 8192, "output": 8192 @@ -253066,19 +253217,15 @@ "supported": false }, "cost": { - "input": 30, - "output": 30 - } + "input": 14.2, + "output": 14.2 + }, + "type": "chat" }, { - "id": "text-embedding-ada-002", - "name": "text-embedding-ada-002", - "display_name": "text-embedding-ada-002", - "modalities": { - "input": [ - "text" - ] - }, + "id": "glm-4-flash", + "name": "glm-4-flash", + "display_name": "glm-4-flash", "limit": { "context": 8192, "output": 8192 @@ -253091,17 +253238,12 @@ "input": 0.1, "output": 0.1 }, - "type": "embedding" + "type": "chat" }, { - "id": "tts-1-hd", - "name": "tts-1-hd", - "display_name": "tts-1-hd", - "modalities": { - "input": [ - "audio" - ] - }, + "id": "glm-4-plus", + "name": "glm-4-plus", + "display_name": "glm-4-plus", "limit": { "context": 8192, "output": 8192 @@ -253111,14 +253253,15 @@ "supported": false }, "cost": { - "input": 30, - "output": 30 - } + "input": 8, + "output": 8 + }, + "type": "chat" }, { - "id": "text-embedding-3-small", - "name": "text-embedding-3-small", - "display_name": "text-embedding-3-small", + "id": "glm-4.5-airx", + "name": "glm-4.5-airx", + "display_name": "glm-4.5-airx", "modalities": { "input": [ "text" @@ -253133,15 +253276,16 @@ "supported": false }, "cost": { - "input": 0.02, - "output": 0.02 + "input": 1.1, + "output": 4.51, + "cache_read": 0.22 }, - "type": "embedding" + "type": "chat" }, { - "id": "text-moderation-007", - "name": "text-moderation-007", - "display_name": "text-moderation-007", + "id": "glm-4v", + "name": "glm-4v", + "display_name": "glm-4v", "limit": { "context": 8192, "output": 8192 @@ -253151,20 +253295,15 @@ "supported": false }, "cost": { - "input": 0.2, - "output": 0.2 + "input": 14.2, + "output": 14.2 }, "type": "chat" }, { - "id": "tts-1-1106", - "name": "tts-1-1106", - "display_name": "tts-1-1106", - "modalities": { - "input": [ - "audio" - ] - }, + "id": "glm-4v-plus", + "name": "glm-4v-plus", + "display_name": "glm-4v-plus", "limit": { "context": 8192, "output": 8192 @@ -253174,14 +253313,15 @@ "supported": false }, "cost": { - "input": 15, - "output": 15 - } + "input": 2, + "output": 2 + }, + "type": "chat" }, { - "id": "text-davinci-edit-001", - "name": "text-davinci-edit-001", - "display_name": "text-davinci-edit-001", + "id": "google-gemma-3-12b-it", + "name": "google-gemma-3-12b-it", + "display_name": "google-gemma-3-12b-it", "limit": { "context": 8192, "output": 8192 @@ -253191,15 +253331,15 @@ "supported": false }, "cost": { - "input": 20, - "output": 20 + "input": 0.2, + "output": 0.2 }, "type": "chat" }, { - "id": "text-davinci-003", - "name": "text-davinci-003", - "display_name": "text-davinci-003", + "id": "google-gemma-3-27b-it", + "name": "google-gemma-3-27b-it", + "display_name": "google-gemma-3-27b-it", "limit": { "context": 8192, "output": 8192 @@ -253209,15 +253349,16 @@ "supported": false }, "cost": { - "input": 20, - "output": 20 + "input": 0.2, + "output": 0.2, + "cache_read": 0 }, "type": "chat" }, { - "id": "text-davinci-002", - "name": "text-davinci-002", - "display_name": "text-davinci-002", + "id": "google-gemma-3-4b-it", + "name": "google-gemma-3-4b-it", + "display_name": "google-gemma-3-4b-it", "limit": { "context": 8192, "output": 8192 @@ -253227,15 +253368,16 @@ "supported": false }, "cost": { - "input": 20, - "output": 20 + "input": 0.2, + "output": 0.2, + "cache_read": 0 }, "type": "chat" }, { - "id": "text-curie-001", - "name": "text-curie-001", - "display_name": "text-curie-001", + "id": "google/gemini-exp-1114", + "name": "google/gemini-exp-1114", + "display_name": "google/gemini-exp-1114", "limit": { "context": 8192, "output": 8192 @@ -253245,20 +253387,15 @@ "supported": false }, "cost": { - "input": 2, - "output": 2 + "input": 1.25, + "output": 5 }, "type": "chat" }, { - "id": "whisper-1", - "name": "whisper-1", - "display_name": "whisper-1", - "modalities": { - "input": [ - "audio" - ] - }, + "id": "google/gemma-2-27b-it", + "name": "google/gemma-2-27b-it", + "display_name": "google/gemma-2-27b-it", "limit": { "context": 8192, "output": 8192 @@ -253268,20 +253405,15 @@ "supported": false }, "cost": { - "input": 100, - "output": 100 + "input": 0.8, + "output": 0.8 }, "type": "chat" }, { - "id": "whisper-large-v3", - "name": "whisper-large-v3", - "display_name": "whisper-large-v3", - "modalities": { - "input": [ - "audio" - ] - }, + "id": "google/gemma-2-9b-it:free", + "name": "google/gemma-2-9b-it:free", + "display_name": "google/gemma-2-9b-it:free", "limit": { "context": 8192, "output": 8192 @@ -253291,20 +253423,15 @@ "supported": false }, "cost": { - "input": 30.834, - "output": 30.834 + "input": 0.02, + "output": 0.02 }, "type": "chat" }, { - "id": "whisper-large-v3-turbo", - "name": "whisper-large-v3-turbo", - "display_name": "whisper-large-v3-turbo", - "modalities": { - "input": [ - "audio" - ] - }, + "id": "gpt-3.5-turbo", + "name": "gpt-3.5-turbo", + "display_name": "gpt-3.5-turbo", "limit": { "context": 8192, "output": 8192 @@ -253314,15 +253441,15 @@ "supported": false }, "cost": { - "input": 5.556, - "output": 5.556 + "input": 0.5, + "output": 1.5 }, "type": "chat" }, { - "id": "text-babbage-001", - "name": "text-babbage-001", - "display_name": "text-babbage-001", + "id": "gpt-3.5-turbo-0301", + "name": "gpt-3.5-turbo-0301", + "display_name": "gpt-3.5-turbo-0301", "limit": { "context": 8192, "output": 8192 @@ -253332,15 +253459,15 @@ "supported": false }, "cost": { - "input": 0.5, - "output": 0.5 + "input": 1.5, + "output": 1.5 }, "type": "chat" }, { - "id": "text-ada-001", - "name": "text-ada-001", - "display_name": "text-ada-001", + "id": "gpt-3.5-turbo-0613", + "name": "gpt-3.5-turbo-0613", + "display_name": "gpt-3.5-turbo-0613", "limit": { "context": 8192, "output": 8192 @@ -253350,20 +253477,15 @@ "supported": false }, "cost": { - "input": 0.4, - "output": 0.4 + "input": 1.5, + "output": 2 }, "type": "chat" }, { - "id": "tts-1", - "name": "tts-1", - "display_name": "tts-1", - "modalities": { - "input": [ - "audio" - ] - }, + "id": "gpt-3.5-turbo-1106", + "name": "gpt-3.5-turbo-1106", + "display_name": "gpt-3.5-turbo-1106", "limit": { "context": 8192, "output": 8192 @@ -253373,14 +253495,15 @@ "supported": false }, "cost": { - "input": 15, - "output": 15 - } + "input": 1, + "output": 2 + }, + "type": "chat" }, { - "id": "text-search-ada-doc-001", - "name": "text-search-ada-doc-001", - "display_name": "text-search-ada-doc-001", + "id": "gpt-3.5-turbo-16k", + "name": "gpt-3.5-turbo-16k", + "display_name": "gpt-3.5-turbo-16k", "limit": { "context": 8192, "output": 8192 @@ -253390,15 +253513,15 @@ "supported": false }, "cost": { - "input": 20, - "output": 20 + "input": 3, + "output": 4 }, "type": "chat" }, { - "id": "text-moderation-stable", - "name": "text-moderation-stable", - "display_name": "text-moderation-stable", + "id": "gpt-3.5-turbo-16k-0613", + "name": "gpt-3.5-turbo-16k-0613", + "display_name": "gpt-3.5-turbo-16k-0613", "limit": { "context": 8192, "output": 8192 @@ -253408,15 +253531,15 @@ "supported": false }, "cost": { - "input": 0.2, - "output": 0.2 + "input": 3, + "output": 4 }, "type": "chat" }, { - "id": "text-moderation-latest", - "name": "text-moderation-latest", - "display_name": "text-moderation-latest", + "id": "gpt-3.5-turbo-instruct", + "name": "gpt-3.5-turbo-instruct", + "display_name": "gpt-3.5-turbo-instruct", "limit": { "context": 8192, "output": 8192 @@ -253426,15 +253549,15 @@ "supported": false }, "cost": { - "input": 0.2, - "output": 0.2 + "input": 1.5, + "output": 2 }, "type": "chat" }, { - "id": "yi-large", - "name": "yi-large", - "display_name": "yi-large", + "id": "gpt-4", + "name": "gpt-4", + "display_name": "gpt-4", "limit": { "context": 8192, "output": 8192 @@ -253444,15 +253567,15 @@ "supported": false }, "cost": { - "input": 3, - "output": 3 + "input": 30, + "output": 60 }, "type": "chat" }, { - "id": "yi-large-rag", - "name": "yi-large-rag", - "display_name": "yi-large-rag", + "id": "gpt-4-0125-preview", + "name": "gpt-4-0125-preview", + "display_name": "gpt-4-0125-preview", "limit": { "context": 8192, "output": 8192 @@ -253462,15 +253585,15 @@ "supported": false }, "cost": { - "input": 4, - "output": 4 + "input": 10, + "output": 30 }, "type": "chat" }, { - "id": "yi-large-turbo", - "name": "yi-large-turbo", - "display_name": "yi-large-turbo", + "id": "gpt-4-0314", + "name": "gpt-4-0314", + "display_name": "gpt-4-0314", "limit": { "context": 8192, "output": 8192 @@ -253480,15 +253603,15 @@ "supported": false }, "cost": { - "input": 1.8, - "output": 1.8 + "input": 30, + "output": 60 }, "type": "chat" }, { - "id": "yi-lightning", - "name": "yi-lightning", - "display_name": "yi-lightning", + "id": "gpt-4-0613", + "name": "gpt-4-0613", + "display_name": "gpt-4-0613", "limit": { "context": 8192, "output": 8192 @@ -253498,15 +253621,15 @@ "supported": false }, "cost": { - "input": 0.2, - "output": 0.2 + "input": 30, + "output": 60 }, "type": "chat" }, { - "id": "yi-medium", - "name": "yi-medium", - "display_name": "yi-medium", + "id": "gpt-4-1106-preview", + "name": "gpt-4-1106-preview", + "display_name": "gpt-4-1106-preview", "limit": { "context": 8192, "output": 8192 @@ -253516,15 +253639,15 @@ "supported": false }, "cost": { - "input": 0.4, - "output": 0.4 + "input": 10, + "output": 30 }, "type": "chat" }, { - "id": "yi-vl-plus", - "name": "yi-vl-plus", - "display_name": "yi-vl-plus", + "id": "gpt-4-32k-0314", + "name": "gpt-4-32k-0314", + "display_name": "gpt-4-32k-0314", "limit": { "context": 8192, "output": 8192 @@ -253534,20 +253657,15 @@ "supported": false }, "cost": { - "input": 0.000852, - "output": 0.000852 + "input": 60, + "output": 120 }, "type": "chat" }, { - "id": "text-embedding-3-large", - "name": "text-embedding-3-large", - "display_name": "text-embedding-3-large", - "modalities": { - "input": [ - "text" - ] - }, + "id": "gpt-4-32k-0613", + "name": "gpt-4-32k-0613", + "display_name": "gpt-4-32k-0613", "limit": { "context": 8192, "output": 8192 @@ -253557,15 +253675,15 @@ "supported": false }, "cost": { - "input": 0.13, - "output": 0.13 + "input": 60, + "output": 120 }, - "type": "embedding" + "type": "chat" }, { - "id": "Baichuan3-Turbo", - "name": "Baichuan3-Turbo", - "display_name": "Baichuan3-Turbo", + "id": "gpt-4-turbo", + "name": "gpt-4-turbo", + "display_name": "gpt-4-turbo", "limit": { "context": 8192, "output": 8192 @@ -253575,15 +253693,15 @@ "supported": false }, "cost": { - "input": 1.9, - "output": 1.9 + "input": 10, + "output": 30 }, "type": "chat" }, { - "id": "Baichuan3-Turbo-128k", - "name": "Baichuan3-Turbo-128k", - "display_name": "Baichuan3-Turbo-128k", + "id": "gpt-4-turbo-2024-04-09", + "name": "gpt-4-turbo-2024-04-09", + "display_name": "gpt-4-turbo-2024-04-09", "limit": { "context": 8192, "output": 8192 @@ -253593,15 +253711,15 @@ "supported": false }, "cost": { - "input": 3.8, - "output": 3.8 + "input": 10, + "output": 30 }, "type": "chat" }, { - "id": "Baichuan4", - "name": "Baichuan4", - "display_name": "Baichuan4", + "id": "gpt-4-turbo-preview", + "name": "gpt-4-turbo-preview", + "display_name": "gpt-4-turbo-preview", "limit": { "context": 8192, "output": 8192 @@ -253611,15 +253729,15 @@ "supported": false }, "cost": { - "input": 16, - "output": 16 + "input": 10, + "output": 30 }, "type": "chat" }, { - "id": "Baichuan4-Air", - "name": "Baichuan4-Air", - "display_name": "Baichuan4-Air", + "id": "gpt-4-vision-preview", + "name": "gpt-4-vision-preview", + "display_name": "gpt-4-vision-preview", "limit": { "context": 8192, "output": 8192 @@ -253629,33 +253747,40 @@ "supported": false }, "cost": { - "input": 0.16, - "output": 0.16 + "input": 10, + "output": 30 }, "type": "chat" }, { - "id": "Baichuan4-Turbo", - "name": "Baichuan4-Turbo", - "display_name": "Baichuan4-Turbo", + "id": "gpt-4o-2024-05-13", + "name": "gpt-4o-2024-05-13", + "display_name": "gpt-4o-2024-05-13", "limit": { - "context": 8192, - "output": 8192 + "context": 128000, + "output": 128000 }, "tool_call": false, "reasoning": { "supported": false }, "cost": { - "input": 2.4, - "output": 2.4 + "input": 5, + "output": 15, + "cache_read": 5 }, "type": "chat" }, { - "id": "DeepSeek-v3", - "name": "DeepSeek-v3", - "display_name": "DeepSeek-v3", + "id": "gpt-4o-mini-2024-07-18", + "name": "gpt-4o-mini-2024-07-18", + "display_name": "gpt-4o-mini-2024-07-18", + "modalities": { + "input": [ + "text", + "image" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -253665,34 +253790,51 @@ "supported": false }, "cost": { - "input": 0.272, - "output": 1.088 + "input": 0.15, + "output": 0.6, + "cache_read": 0.075 }, "type": "chat" }, { - "id": "Doubao-1.5-lite-32k", - "name": "Doubao-1.5-lite-32k", - "display_name": "Doubao-1.5-lite-32k", + "id": "gpt-oss-20b", + "name": "gpt-oss-20b", + "display_name": "gpt-oss-20b", + "modalities": { + "input": [ + "text" + ] + }, "limit": { - "context": 8192, - "output": 8192 + "context": 128000, + "output": 128000 }, - "tool_call": false, + "tool_call": true, "reasoning": { - "supported": false + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } }, "cost": { - "input": 0.05, - "output": 0.1, - "cache_read": 0.01 + "input": 0.11, + "output": 0.55 }, "type": "chat" }, { - "id": "Doubao-1.5-pro-256k", - "name": "Doubao-1.5-pro-256k", - "display_name": "Doubao-1.5-pro-256k", + "id": "grok-2-vision-1212", + "name": "grok-2-vision-1212", + "display_name": "grok-2-vision-1212", + "modalities": { + "input": [ + "text", + "image" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -253702,16 +253844,21 @@ "supported": false }, "cost": { - "input": 0.8, - "output": 1.44, - "cache_read": 0.8 + "input": 1.8, + "output": 9 }, "type": "chat" }, { - "id": "Doubao-1.5-pro-32k", - "name": "Doubao-1.5-pro-32k", - "display_name": "Doubao-1.5-pro-32k", + "id": "grok-vision-beta", + "name": "grok-vision-beta", + "display_name": "grok-vision-beta", + "modalities": { + "input": [ + "text", + "image" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -253721,16 +253868,15 @@ "supported": false }, "cost": { - "input": 0.134, - "output": 0.335, - "cache_read": 0.0268 + "input": 5.6, + "output": 16.8 }, "type": "chat" }, { - "id": "Doubao-1.5-vision-pro-32k", - "name": "Doubao-1.5-vision-pro-32k", - "display_name": "Doubao-1.5-vision-pro-32k", + "id": "groq-llama-3.1-8b-instant", + "name": "groq-llama-3.1-8b-instant", + "display_name": "groq-llama-3.1-8b-instant", "limit": { "context": 8192, "output": 8192 @@ -253740,15 +253886,15 @@ "supported": false }, "cost": { - "input": 0.46, - "output": 1.38 + "input": 0.055, + "output": 0.088 }, "type": "chat" }, { - "id": "Doubao-lite-128k", - "name": "Doubao-lite-128k", - "display_name": "Doubao-lite-128k", + "id": "groq-llama-3.3-70b-versatile", + "name": "groq-llama-3.3-70b-versatile", + "display_name": "groq-llama-3.3-70b-versatile", "limit": { "context": 8192, "output": 8192 @@ -253758,16 +253904,15 @@ "supported": false }, "cost": { - "input": 0.14, - "output": 0.28, - "cache_read": 0.14 + "input": 0.649, + "output": 0.869011 }, "type": "chat" }, { - "id": "Doubao-lite-32k", - "name": "Doubao-lite-32k", - "display_name": "Doubao-lite-32k", + "id": "groq-llama-4-maverick-17b-128e-instruct", + "name": "groq-llama-4-maverick-17b-128e-instruct", + "display_name": "groq-llama-4-maverick-17b-128e-instruct", "limit": { "context": 8192, "output": 8192 @@ -253777,16 +253922,15 @@ "supported": false }, "cost": { - "input": 0.06, - "output": 0.12, - "cache_read": 0.012 + "input": 0.22, + "output": 0.66 }, "type": "chat" }, { - "id": "Doubao-lite-4k", - "name": "Doubao-lite-4k", - "display_name": "Doubao-lite-4k", + "id": "groq-llama-4-scout-17b-16e-instruct", + "name": "groq-llama-4-scout-17b-16e-instruct", + "display_name": "groq-llama-4-scout-17b-16e-instruct", "limit": { "context": 8192, "output": 8192 @@ -253796,16 +253940,20 @@ "supported": false }, "cost": { - "input": 0.06, - "output": 0.12, - "cache_read": 0.06 + "input": 0.122, + "output": 0.366 }, "type": "chat" }, { - "id": "Doubao-pro-128k", - "name": "Doubao-pro-128k", - "display_name": "Doubao-pro-128k", + "id": "jina-embeddings-v2-base-code", + "name": "jina-embeddings-v2-base-code", + "display_name": "jina-embeddings-v2-base-code", + "modalities": { + "input": [ + "text" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -253815,15 +253963,15 @@ "supported": false }, "cost": { - "input": 0.8, - "output": 1.44 + "input": 0.05, + "output": 0.05 }, - "type": "chat" + "type": "embedding" }, { - "id": "Doubao-pro-256k", - "name": "Doubao-pro-256k", - "display_name": "Doubao-pro-256k", + "id": "learnlm-1.5-pro-experimental", + "name": "learnlm-1.5-pro-experimental", + "display_name": "learnlm-1.5-pro-experimental", "limit": { "context": 8192, "output": 8192 @@ -253833,16 +253981,15 @@ "supported": false }, "cost": { - "input": 0.8, - "output": 1.44, - "cache_read": 0.8 + "input": 1.25, + "output": 5 }, "type": "chat" }, { - "id": "Doubao-pro-32k", - "name": "Doubao-pro-32k", - "display_name": "Doubao-pro-32k", + "id": "llama-3.1-405b-instruct", + "name": "llama-3.1-405b-instruct", + "display_name": "llama-3.1-405b-instruct", "limit": { "context": 8192, "output": 8192 @@ -253852,16 +253999,15 @@ "supported": false }, "cost": { - "input": 0.14, - "output": 0.35, - "cache_read": 0.028 + "input": 4, + "output": 4 }, "type": "chat" }, { - "id": "Doubao-pro-4k", - "name": "Doubao-pro-4k", - "display_name": "Doubao-pro-4k", + "id": "llama-3.1-405b-reasoning", + "name": "llama-3.1-405b-reasoning", + "display_name": "llama-3.1-405b-reasoning", "limit": { "context": 8192, "output": 8192 @@ -253871,38 +254017,33 @@ "supported": false }, "cost": { - "input": 0.14, - "output": 0.35 + "input": 4, + "output": 4 }, "type": "chat" }, { - "id": "GPT-OSS-20B", - "name": "GPT-OSS-20B", - "display_name": "GPT-OSS-20B", + "id": "llama-3.1-70b-versatile", + "name": "llama-3.1-70b-versatile", + "display_name": "llama-3.1-70b-versatile", "limit": { "context": 8192, "output": 8192 }, "tool_call": false, "reasoning": { - "supported": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } + "supported": false }, "cost": { - "input": 0.11, - "output": 0.55 + "input": 0.6, + "output": 0.6 }, "type": "chat" }, { - "id": "Gryphe/MythoMax-L2-13b", - "name": "Gryphe/MythoMax-L2-13b", - "display_name": "Gryphe/MythoMax-L2-13b", + "id": "llama-3.1-8b-instant", + "name": "llama-3.1-8b-instant", + "display_name": "llama-3.1-8b-instant", "limit": { "context": 8192, "output": 8192 @@ -253912,20 +254053,15 @@ "supported": false }, "cost": { - "input": 0.4, - "output": 0.4 + "input": 0.3, + "output": 0.6 }, "type": "chat" }, { - "id": "MiniMax-Text-01", - "name": "MiniMax-Text-01", - "display_name": "MiniMax-Text-01", - "modalities": { - "input": [ - "text" - ] - }, + "id": "llama-3.1-sonar-small-128k-online", + "name": "llama-3.1-sonar-small-128k-online", + "display_name": "llama-3.1-sonar-small-128k-online", "limit": { "context": 8192, "output": 8192 @@ -253935,15 +254071,15 @@ "supported": false }, "cost": { - "input": 0.14, - "output": 1.12 + "input": 0.3, + "output": 0.3 }, "type": "chat" }, { - "id": "Mistral-large-2407", - "name": "Mistral-large-2407", - "display_name": "Mistral-large-2407", + "id": "llama-3.2-11b-vision-preview", + "name": "llama-3.2-11b-vision-preview", + "display_name": "llama-3.2-11b-vision-preview", "limit": { "context": 8192, "output": 8192 @@ -253953,15 +254089,15 @@ "supported": false }, "cost": { - "input": 3, - "output": 9 + "input": 0.2, + "output": 0.2 }, "type": "chat" }, { - "id": "Qwen/Qwen2-1.5B-Instruct", - "name": "Qwen/Qwen2-1.5B-Instruct", - "display_name": "Qwen/Qwen2-1.5B-Instruct", + "id": "llama-3.2-1b-preview", + "name": "llama-3.2-1b-preview", + "display_name": "llama-3.2-1b-preview", "limit": { "context": 8192, "output": 8192 @@ -253977,9 +254113,9 @@ "type": "chat" }, { - "id": "Qwen/Qwen2-57B-A14B-Instruct", - "name": "Qwen/Qwen2-57B-A14B-Instruct", - "display_name": "Qwen/Qwen2-57B-A14B-Instruct", + "id": "llama-3.2-3b-preview", + "name": "llama-3.2-3b-preview", + "display_name": "llama-3.2-3b-preview", "limit": { "context": 8192, "output": 8192 @@ -253989,15 +254125,15 @@ "supported": false }, "cost": { - "input": 0.24, - "output": 0.24 + "input": 0.2, + "output": 0.2 }, "type": "chat" }, { - "id": "Qwen/Qwen2-72B-Instruct", - "name": "Qwen/Qwen2-72B-Instruct", - "display_name": "Qwen/Qwen2-72B-Instruct", + "id": "llama-3.2-90b-vision-preview", + "name": "llama-3.2-90b-vision-preview", + "display_name": "llama-3.2-90b-vision-preview", "limit": { "context": 8192, "output": 8192 @@ -254007,15 +254143,15 @@ "supported": false }, "cost": { - "input": 0.8, - "output": 0.8 + "input": 2.4, + "output": 2.4 }, "type": "chat" }, { - "id": "Qwen/Qwen2-7B-Instruct", - "name": "Qwen/Qwen2-7B-Instruct", - "display_name": "Qwen/Qwen2-7B-Instruct", + "id": "llama2-70b-4096", + "name": "llama2-70b-4096", + "display_name": "llama2-70b-4096", "limit": { "context": 8192, "output": 8192 @@ -254025,15 +254161,15 @@ "supported": false }, "cost": { - "input": 0.08, - "output": 0.08 + "input": 0.5, + "output": 0.5 }, "type": "chat" }, { - "id": "Qwen/Qwen2.5-32B-Instruct", - "name": "Qwen/Qwen2.5-32B-Instruct", - "display_name": "Qwen/Qwen2.5-32B-Instruct", + "id": "llama2-70b-40960", + "name": "llama2-70b-40960", + "display_name": "llama2-70b-40960", "limit": { "context": 8192, "output": 8192 @@ -254043,15 +254179,15 @@ "supported": false }, "cost": { - "input": 0.6, - "output": 0.6 + "input": 0.5, + "output": 0.5 }, "type": "chat" }, { - "id": "Qwen/Qwen2.5-72B-Instruct", - "name": "Qwen/Qwen2.5-72B-Instruct", - "display_name": "Qwen/Qwen2.5-72B-Instruct", + "id": "llama2-7b-2048", + "name": "llama2-7b-2048", + "display_name": "llama2-7b-2048", "limit": { "context": 8192, "output": 8192 @@ -254061,15 +254197,15 @@ "supported": false }, "cost": { - "input": 0.8, - "output": 0.8 + "input": 0.1, + "output": 0.1 }, "type": "chat" }, { - "id": "Qwen/Qwen2.5-72B-Instruct-128K", - "name": "Qwen/Qwen2.5-72B-Instruct-128K", - "display_name": "Qwen/Qwen2.5-72B-Instruct-128K", + "id": "llama3-70b-8192", + "name": "llama3-70b-8192", + "display_name": "llama3-70b-8192", "limit": { "context": 8192, "output": 8192 @@ -254079,15 +254215,15 @@ "supported": false }, "cost": { - "input": 0.8, - "output": 0.8 + "input": 0.7, + "output": 0.937288 }, "type": "chat" }, { - "id": "Qwen/Qwen2.5-7B-Instruct", - "name": "Qwen/Qwen2.5-7B-Instruct", - "display_name": "Qwen/Qwen2.5-7B-Instruct", + "id": "llama3-8b-8192", + "name": "llama3-8b-8192", + "display_name": "llama3-8b-8192", "limit": { "context": 8192, "output": 8192 @@ -254097,15 +254233,15 @@ "supported": false }, "cost": { - "input": 0.4, - "output": 0.4 + "input": 0.06, + "output": 0.12 }, "type": "chat" }, { - "id": "Qwen/Qwen2.5-Coder-32B-Instruct", - "name": "Qwen/Qwen2.5-Coder-32B-Instruct", - "display_name": "Qwen/Qwen2.5-Coder-32B-Instruct", + "id": "llama3-groq-70b-8192-tool-use-preview", + "name": "llama3-groq-70b-8192-tool-use-preview", + "display_name": "llama3-groq-70b-8192-tool-use-preview", "limit": { "context": 8192, "output": 8192 @@ -254115,50 +254251,33 @@ "supported": false }, "cost": { - "input": 0.16, - "output": 0.16 + "input": 0.00089, + "output": 0.00089 }, "type": "chat" }, { - "id": "Qwen3-235B-A22B-Thinking-2507", - "name": "Qwen3-235B-A22B-Thinking-2507", - "display_name": "Qwen3-235B-A22B-Thinking-2507", + "id": "llama3-groq-8b-8192-tool-use-preview", + "name": "llama3-groq-8b-8192-tool-use-preview", + "display_name": "llama3-groq-8b-8192-tool-use-preview", "limit": { "context": 8192, "output": 8192 }, "tool_call": false, "reasoning": { - "supported": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ] - } + "supported": false }, "cost": { - "input": 0.28, - "output": 2.8 + "input": 0.00019, + "output": 0.00019 }, "type": "chat" }, { - "id": "Stable-Diffusion-3-5-Large", - "name": "Stable-Diffusion-3-5-Large", - "display_name": "Stable-Diffusion-3-5-Large", - "modalities": { - "input": [ - "text", - "image" - ] - }, + "id": "mai-image-2", + "name": "mai-image-2", + "display_name": "mai-image-2", "limit": { "context": 8192, "output": 8192 @@ -254168,16 +254287,16 @@ "supported": false }, "cost": { - "input": 4, - "output": 4, + "input": 2, + "output": 2, "cache_read": 0 }, "type": "imageGeneration" }, { - "id": "WizardLM/WizardCoder-Python-34B-V1.0", - "name": "WizardLM/WizardCoder-Python-34B-V1.0", - "display_name": "WizardLM/WizardCoder-Python-34B-V1.0", + "id": "meta-llama/Llama-3.2-90B-Vision-Instruct", + "name": "meta-llama/Llama-3.2-90B-Vision-Instruct", + "display_name": "meta-llama/Llama-3.2-90B-Vision-Instruct", "limit": { "context": 8192, "output": 8192 @@ -254187,15 +254306,15 @@ "supported": false }, "cost": { - "input": 0.9, - "output": 0.9 + "input": 0.5, + "output": 0.5 }, "type": "chat" }, { - "id": "ahm-Phi-3-5-MoE-instruct", - "name": "ahm-Phi-3-5-MoE-instruct", - "display_name": "ahm-Phi-3-5-MoE-instruct", + "id": "meta-llama/llama-3.1-405b-instruct:free", + "name": "meta-llama/llama-3.1-405b-instruct:free", + "display_name": "meta-llama/llama-3.1-405b-instruct:free", "limit": { "context": 8192, "output": 8192 @@ -254205,15 +254324,15 @@ "supported": false }, "cost": { - "input": 0.4, - "output": 1.6 + "input": 0.02, + "output": 0.02 }, "type": "chat" }, { - "id": "ahm-Phi-3-5-mini-instruct", - "name": "ahm-Phi-3-5-mini-instruct", - "display_name": "ahm-Phi-3-5-mini-instruct", + "id": "meta-llama/llama-3.1-70b-instruct:free", + "name": "meta-llama/llama-3.1-70b-instruct:free", + "display_name": "meta-llama/llama-3.1-70b-instruct:free", "limit": { "context": 8192, "output": 8192 @@ -254223,21 +254342,15 @@ "supported": false }, "cost": { - "input": 1, - "output": 3 + "input": 0.02, + "output": 0.02 }, "type": "chat" }, { - "id": "ahm-Phi-3-5-vision-instruct", - "name": "ahm-Phi-3-5-vision-instruct", - "display_name": "ahm-Phi-3-5-vision-instruct", - "modalities": { - "input": [ - "text", - "image" - ] - }, + "id": "meta-llama/llama-3.1-8b-instruct:free", + "name": "meta-llama/llama-3.1-8b-instruct:free", + "display_name": "meta-llama/llama-3.1-8b-instruct:free", "limit": { "context": 8192, "output": 8192 @@ -254247,15 +254360,15 @@ "supported": false }, "cost": { - "input": 0.4, - "output": 1.6 + "input": 0.02, + "output": 0.02 }, "type": "chat" }, { - "id": "ahm-Phi-3-medium-128k", - "name": "ahm-Phi-3-medium-128k", - "display_name": "ahm-Phi-3-medium-128k", + "id": "meta-llama/llama-3.2-11b-vision-instruct:free", + "name": "meta-llama/llama-3.2-11b-vision-instruct:free", + "display_name": "meta-llama/llama-3.2-11b-vision-instruct:free", "limit": { "context": 8192, "output": 8192 @@ -254265,15 +254378,15 @@ "supported": false }, "cost": { - "input": 6, - "output": 18 + "input": 0.02, + "output": 0.02 }, "type": "chat" }, { - "id": "ahm-Phi-3-medium-4k", - "name": "ahm-Phi-3-medium-4k", - "display_name": "ahm-Phi-3-medium-4k", + "id": "meta-llama/llama-3.2-3b-instruct:free", + "name": "meta-llama/llama-3.2-3b-instruct:free", + "display_name": "meta-llama/llama-3.2-3b-instruct:free", "limit": { "context": 8192, "output": 8192 @@ -254283,15 +254396,15 @@ "supported": false }, "cost": { - "input": 1, - "output": 3 + "input": 0.02, + "output": 0.02 }, "type": "chat" }, { - "id": "ahm-Phi-3-small-128k", - "name": "ahm-Phi-3-small-128k", - "display_name": "ahm-Phi-3-small-128k", + "id": "meta/llama-3.1-405b-instruct", + "name": "meta/llama-3.1-405b-instruct", + "display_name": "meta/llama-3.1-405b-instruct", "limit": { "context": 8192, "output": 8192 @@ -254301,15 +254414,15 @@ "supported": false }, "cost": { - "input": 1, - "output": 3 + "input": 5, + "output": 5 }, "type": "chat" }, { - "id": "aihubmix-Codestral-2501", - "name": "aihubmix-Codestral-2501", - "display_name": "aihubmix-Codestral-2501", + "id": "meta/llama3-8B-chat", + "name": "meta/llama3-8B-chat", + "display_name": "meta/llama3-8B-chat", "limit": { "context": 8192, "output": 8192 @@ -254319,20 +254432,15 @@ "supported": false }, "cost": { - "input": 0.4, - "output": 1.2 + "input": 0.3, + "output": 0.3 }, "type": "chat" }, { - "id": "aihubmix-Cohere-command-r", - "name": "aihubmix-Cohere-command-r", - "display_name": "aihubmix-Cohere-command-r", - "modalities": { - "input": [ - "text" - ] - }, + "id": "mistralai/mistral-7b-instruct:free", + "name": "mistralai/mistral-7b-instruct:free", + "display_name": "mistralai/mistral-7b-instruct:free", "limit": { "context": 8192, "output": 8192 @@ -254342,15 +254450,15 @@ "supported": false }, "cost": { - "input": 0.64, - "output": 1.92 + "input": 0.002, + "output": 0.002 }, "type": "chat" }, { - "id": "aihubmix-Jamba-1-5-Large", - "name": "aihubmix-Jamba-1-5-Large", - "display_name": "aihubmix-Jamba-1-5-Large", + "id": "mm-minimax-m3", + "name": "mm-minimax-m3", + "display_name": "mm-minimax-m3", "limit": { "context": 8192, "output": 8192 @@ -254360,15 +254468,15 @@ "supported": false }, "cost": { - "input": 2.2, - "output": 8.8 + "input": 0.288, + "output": 1.152 }, "type": "chat" }, { - "id": "aihubmix-Llama-3-1-405B-Instruct", - "name": "aihubmix-Llama-3-1-405B-Instruct", - "display_name": "aihubmix-Llama-3-1-405B-Instruct", + "id": "moonshot-kimi-k2.5", + "name": "moonshot-kimi-k2.5", + "display_name": "moonshot-kimi-k2.5", "limit": { "context": 8192, "output": 8192 @@ -254378,15 +254486,16 @@ "supported": false }, "cost": { - "input": 5, - "output": 15 + "input": 0.6, + "output": 3, + "cache_read": 0.105 }, "type": "chat" }, { - "id": "aihubmix-Llama-3-1-70B-Instruct", - "name": "aihubmix-Llama-3-1-70B-Instruct", - "display_name": "aihubmix-Llama-3-1-70B-Instruct", + "id": "moonshot-v1-128k", + "name": "moonshot-v1-128k", + "display_name": "moonshot-v1-128k", "limit": { "context": 8192, "output": 8192 @@ -254396,15 +254505,15 @@ "supported": false }, "cost": { - "input": 0.6, - "output": 0.78 + "input": 10, + "output": 10 }, "type": "chat" }, { - "id": "aihubmix-Llama-3-1-8B-Instruct", - "name": "aihubmix-Llama-3-1-8B-Instruct", - "display_name": "aihubmix-Llama-3-1-8B-Instruct", + "id": "moonshot-v1-128k-vision-preview", + "name": "moonshot-v1-128k-vision-preview", + "display_name": "moonshot-v1-128k-vision-preview", "limit": { "context": 8192, "output": 8192 @@ -254414,15 +254523,15 @@ "supported": false }, "cost": { - "input": 0.3, - "output": 0.6 + "input": 10, + "output": 10 }, "type": "chat" }, { - "id": "aihubmix-Llama-3-2-11B-Vision", - "name": "aihubmix-Llama-3-2-11B-Vision", - "display_name": "aihubmix-Llama-3-2-11B-Vision", + "id": "moonshot-v1-32k", + "name": "moonshot-v1-32k", + "display_name": "moonshot-v1-32k", "limit": { "context": 8192, "output": 8192 @@ -254432,15 +254541,15 @@ "supported": false }, "cost": { - "input": 0.4, - "output": 0.4 + "input": 4, + "output": 4 }, "type": "chat" }, { - "id": "aihubmix-Llama-3-2-90B-Vision", - "name": "aihubmix-Llama-3-2-90B-Vision", - "display_name": "aihubmix-Llama-3-2-90B-Vision", + "id": "moonshot-v1-32k-vision-preview", + "name": "moonshot-v1-32k-vision-preview", + "display_name": "moonshot-v1-32k-vision-preview", "limit": { "context": 8192, "output": 8192 @@ -254450,15 +254559,15 @@ "supported": false }, "cost": { - "input": 2.4, - "output": 2.4 + "input": 4, + "output": 4 }, "type": "chat" }, { - "id": "aihubmix-Llama-3-70B-Instruct", - "name": "aihubmix-Llama-3-70B-Instruct", - "display_name": "aihubmix-Llama-3-70B-Instruct", + "id": "moonshot-v1-8k", + "name": "moonshot-v1-8k", + "display_name": "moonshot-v1-8k", "limit": { "context": 8192, "output": 8192 @@ -254468,15 +254577,15 @@ "supported": false }, "cost": { - "input": 0.7, - "output": 0.7 + "input": 2, + "output": 2 }, "type": "chat" }, { - "id": "aihubmix-Mistral-large", - "name": "aihubmix-Mistral-large", - "display_name": "aihubmix-Mistral-large", + "id": "moonshot-v1-8k-vision-preview", + "name": "moonshot-v1-8k-vision-preview", + "display_name": "moonshot-v1-8k-vision-preview", "limit": { "context": 8192, "output": 8192 @@ -254486,20 +254595,15 @@ "supported": false }, "cost": { - "input": 4, - "output": 12 + "input": 2, + "output": 2 }, "type": "chat" }, { - "id": "aihubmix-command-r-08-2024", - "name": "aihubmix-command-r-08-2024", - "display_name": "aihubmix-command-r-08-2024", - "modalities": { - "input": [ - "text" - ] - }, + "id": "nvidia/Llama-3_1-Nemotron-Ultra-253B-v1", + "name": "nvidia/Llama-3_1-Nemotron-Ultra-253B-v1", + "display_name": "nvidia/Llama-3_1-Nemotron-Ultra-253B-v1", "limit": { "context": 8192, "output": 8192 @@ -254509,20 +254613,50 @@ "supported": false }, "cost": { - "input": 0.2, - "output": 0.8 + "input": 0.5, + "output": 0.5, + "cache_read": 0 }, "type": "chat" }, { - "id": "aihubmix-command-r-plus", - "name": "aihubmix-command-r-plus", - "display_name": "aihubmix-command-r-plus", - "modalities": { - "input": [ - "text" - ] + "id": "o1-mini-2024-09-12", + "name": "o1-mini-2024-09-12", + "display_name": "o1-mini-2024-09-12", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "effort", + "effort": "medium", + "effort_options": [ + "low", + "medium", + "high" + ], + "visibility": "hidden" + } + }, + "cost": { + "input": 3, + "output": 12, + "cache_read": 1.5 }, + "type": "chat" + }, + { + "id": "omni-moderation-latest", + "name": "omni-moderation-latest", + "display_name": "omni-moderation-latest", "limit": { "context": 8192, "output": 8192 @@ -254532,57 +254666,75 @@ "supported": false }, "cost": { - "input": 3.84, - "output": 19.2 + "input": 0.02, + "output": 0.02 }, "type": "chat" }, { - "id": "aihubmix-command-r-plus-08-2024", - "name": "aihubmix-command-r-plus-08-2024", - "display_name": "aihubmix-command-r-plus-08-2024", - "modalities": { - "input": [ - "text" - ] - }, + "id": "qwen-flash", + "name": "qwen-flash", + "display_name": "qwen-flash", "limit": { "context": 8192, "output": 8192 }, "tool_call": false, "reasoning": { - "supported": false + "supported": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } }, "cost": { - "input": 2.8, - "output": 11.2 + "input": 0.02, + "output": 0.2, + "cache_read": 0.02 }, "type": "chat" }, { - "id": "alicloud-deepseek-v3.2", - "name": "alicloud-deepseek-v3.2", - "display_name": "alicloud-deepseek-v3.2", + "id": "qwen-flash-2025-07-28", + "name": "qwen-flash-2025-07-28", + "display_name": "qwen-flash-2025-07-28", "limit": { "context": 8192, "output": 8192 }, "tool_call": false, "reasoning": { - "supported": false + "supported": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } }, "cost": { - "input": 0.274, - "output": 0.411, - "cache_read": 0.0548 + "input": 0.02, + "output": 0.2, + "cache_read": 0.02 }, "type": "chat" }, { - "id": "alicloud-glm-4.7", - "name": "alicloud-glm-4.7", - "display_name": "alicloud-glm-4.7", + "id": "qwen-long", + "name": "qwen-long", + "display_name": "qwen-long", "limit": { "context": 8192, "output": 8192 @@ -254592,16 +254744,15 @@ "supported": false }, "cost": { - "input": 0.41096, - "output": 1.917786, - "cache_read": 0.41096 + "input": 0.1, + "output": 0.4 }, "type": "chat" }, { - "id": "alicloud-kimi-k2-thinking", - "name": "alicloud-kimi-k2-thinking", - "display_name": "alicloud-kimi-k2-thinking", + "id": "qwen-max", + "name": "qwen-max", + "display_name": "qwen-max", "limit": { "context": 8192, "output": 8192 @@ -254611,84 +254762,103 @@ "supported": false }, "cost": { - "input": 0.548, - "output": 2.192 + "input": 0.38, + "output": 1.52 }, "type": "chat" }, { - "id": "alicloud-kimi-k2.5", - "name": "alicloud-kimi-k2.5", - "display_name": "alicloud-kimi-k2.5", + "id": "qwen-max-longcontext", + "name": "qwen-max-longcontext", + "display_name": "qwen-max-longcontext", "limit": { - "context": 256000, - "output": 256000 + "context": 8192, + "output": 8192 }, "tool_call": false, "reasoning": { "supported": false }, "cost": { - "input": 0.548, - "output": 2.877, - "cache_read": 0.0959 + "input": 7, + "output": 21 }, "type": "chat" }, { - "id": "alicloud-minimax-m2.5", - "name": "alicloud-minimax-m2.5", - "display_name": "alicloud-minimax-m2.5", + "id": "qwen-plus", + "name": "qwen-plus", + "display_name": "qwen-plus", "limit": { "context": 8192, "output": 8192 }, "tool_call": false, "reasoning": { - "supported": false + "supported": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } }, "cost": { - "input": 0.2876, - "output": 1.1504, - "cache_read": 0.05752 + "input": 0.1126, + "output": 1.126, + "cache_read": 0.02252 }, "type": "chat" }, { - "id": "anthropic-opus-4-6", - "name": "anthropic-opus-4-6", - "display_name": "anthropic-opus-4-6", + "id": "qwen-turbo", + "name": "qwen-turbo", + "display_name": "qwen-turbo", "modalities": { "input": [ - "text", - "image" + "text" ] }, "limit": { - "context": 200000, - "output": 200000 + "context": 8192, + "output": 8192 }, - "tool_call": true, + "tool_call": false, "reasoning": { - "supported": true, - "default": true + "supported": true }, "extra_capabilities": { "reasoning": { - "supported": true + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] } }, "cost": { - "input": 5, - "output": 25, - "cache_read": 0.5 + "input": 0.046, + "output": 0.092, + "cache_read": 0.0092 }, "type": "chat" }, { - "id": "azure-deepseek-v3.2", - "name": "azure-deepseek-v3.2", - "display_name": "azure-deepseek-v3.2", + "id": "qwen-turbo-2024-11-01", + "name": "qwen-turbo-2024-11-01", + "display_name": "qwen-turbo-2024-11-01", + "modalities": { + "input": [ + "text" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -254698,15 +254868,15 @@ "supported": false }, "cost": { - "input": 0.58, - "output": 1.680028 + "input": 0.046, + "output": 0.092 }, "type": "chat" }, { - "id": "azure-deepseek-v3.2-speciale", - "name": "azure-deepseek-v3.2-speciale", - "display_name": "azure-deepseek-v3.2-speciale", + "id": "qwen2.5-14b-instruct", + "name": "qwen2.5-14b-instruct", + "display_name": "qwen2.5-14b-instruct", "limit": { "context": 8192, "output": 8192 @@ -254716,18 +254886,18 @@ "supported": false }, "cost": { - "input": 0.58, - "output": 1.680028 + "input": 0.4, + "output": 1.2 }, "type": "chat" }, { - "id": "azure-kimi-k2.5", - "name": "azure-kimi-k2.5", - "display_name": "azure-kimi-k2.5", + "id": "qwen2.5-32b-instruct", + "name": "qwen2.5-32b-instruct", + "display_name": "qwen2.5-32b-instruct", "limit": { - "context": 256000, - "output": 256000 + "context": 8192, + "output": 8192 }, "tool_call": false, "reasoning": { @@ -254735,14 +254905,14 @@ }, "cost": { "input": 0.6, - "output": 3 + "output": 1.2 }, "type": "chat" }, { - "id": "cbs-glm-4.7", - "name": "cbs-glm-4.7", - "display_name": "cbs-glm-4.7", + "id": "qwen2.5-3b-instruct", + "name": "qwen2.5-3b-instruct", + "display_name": "qwen2.5-3b-instruct", "limit": { "context": 8192, "output": 8192 @@ -254752,15 +254922,15 @@ "supported": false }, "cost": { - "input": 2.25, - "output": 2.749995 + "input": 0.4, + "output": 0.8 }, "type": "chat" }, { - "id": "cerebras-llama-3.3-70b", - "name": "cerebras-llama-3.3-70b", - "display_name": "cerebras-llama-3.3-70b", + "id": "qwen2.5-72b-instruct", + "name": "qwen2.5-72b-instruct", + "display_name": "qwen2.5-72b-instruct", "limit": { "context": 8192, "output": 8192 @@ -254770,15 +254940,15 @@ "supported": false }, "cost": { - "input": 0.6, - "output": 0.6 + "input": 0.8, + "output": 2.4 }, "type": "chat" }, { - "id": "chatglm_lite", - "name": "chatglm_lite", - "display_name": "chatglm_lite", + "id": "qwen2.5-7b-instruct", + "name": "qwen2.5-7b-instruct", + "display_name": "qwen2.5-7b-instruct", "limit": { "context": 8192, "output": 8192 @@ -254788,15 +254958,15 @@ "supported": false }, "cost": { - "input": 0.2858, - "output": 0.2858 + "input": 0.4, + "output": 0.8 }, "type": "chat" }, { - "id": "chatglm_pro", - "name": "chatglm_pro", - "display_name": "chatglm_pro", + "id": "qwen2.5-coder-1.5b-instruct", + "name": "qwen2.5-coder-1.5b-instruct", + "display_name": "qwen2.5-coder-1.5b-instruct", "limit": { "context": 8192, "output": 8192 @@ -254806,15 +254976,15 @@ "supported": false }, "cost": { - "input": 1.4286, - "output": 1.4286 + "input": 0.2, + "output": 0.4 }, "type": "chat" }, { - "id": "chatglm_std", - "name": "chatglm_std", - "display_name": "chatglm_std", + "id": "qwen2.5-coder-7b-instruct", + "name": "qwen2.5-coder-7b-instruct", + "display_name": "qwen2.5-coder-7b-instruct", "limit": { "context": 8192, "output": 8192 @@ -254824,15 +254994,15 @@ "supported": false }, "cost": { - "input": 0.7144, - "output": 0.7144 + "input": 0.2, + "output": 0.4 }, "type": "chat" }, { - "id": "chatglm_turbo", - "name": "chatglm_turbo", - "display_name": "chatglm_turbo", + "id": "qwen2.5-math-1.5b-instruct", + "name": "qwen2.5-math-1.5b-instruct", + "display_name": "qwen2.5-math-1.5b-instruct", "limit": { "context": 8192, "output": 8192 @@ -254842,15 +255012,15 @@ "supported": false }, "cost": { - "input": 0.7144, - "output": 0.7144 + "input": 0.2, + "output": 0.2 }, "type": "chat" }, { - "id": "claude-2", - "name": "claude-2", - "display_name": "claude-2", + "id": "qwen2.5-math-72b-instruct", + "name": "qwen2.5-math-72b-instruct", + "display_name": "qwen2.5-math-72b-instruct", "limit": { "context": 8192, "output": 8192 @@ -254860,15 +255030,15 @@ "supported": false }, "cost": { - "input": 8.8, - "output": 8.8 + "input": 0.8, + "output": 2.4 }, "type": "chat" }, { - "id": "claude-2.0", - "name": "claude-2.0", - "display_name": "claude-2.0", + "id": "qwen2.5-math-7b-instruct", + "name": "qwen2.5-math-7b-instruct", + "display_name": "qwen2.5-math-7b-instruct", "limit": { "context": 8192, "output": 8192 @@ -254878,15 +255048,15 @@ "supported": false }, "cost": { - "input": 8.8, - "output": 39.6 + "input": 0.2, + "output": 0.4 }, "type": "chat" }, { - "id": "claude-2.1", - "name": "claude-2.1", - "display_name": "claude-2.1", + "id": "step-2-16k", + "name": "step-2-16k", + "display_name": "step-2-16k", "limit": { "context": 8192, "output": 8192 @@ -254896,19 +255066,18 @@ "supported": false }, "cost": { - "input": 8.8, - "output": 39.6 + "input": 2, + "output": 2 }, "type": "chat" }, { - "id": "claude-3-haiku-20240229", - "name": "claude-3-haiku-20240229", - "display_name": "claude-3-haiku-20240229", + "id": "tts-1-hd-1106", + "name": "tts-1-hd-1106", + "display_name": "tts-1-hd-1106", "modalities": { "input": [ - "text", - "image" + "audio" ] }, "limit": { @@ -254920,19 +255089,17 @@ "supported": false }, "cost": { - "input": 0.275, - "output": 0.275 - }, - "type": "chat" + "input": 30, + "output": 30 + } }, { - "id": "claude-3-haiku-20240307", - "name": "claude-3-haiku-20240307", - "display_name": "claude-3-haiku-20240307", + "id": "tts-1-hd", + "name": "tts-1-hd", + "display_name": "tts-1-hd", "modalities": { "input": [ - "text", - "image" + "audio" ] }, "limit": { @@ -254944,19 +255111,17 @@ "supported": false }, "cost": { - "input": 0.275, - "output": 1.375 - }, - "type": "chat" + "input": 30, + "output": 30 + } }, { - "id": "claude-3-sonnet-20240229", - "name": "claude-3-sonnet-20240229", - "display_name": "claude-3-sonnet-20240229", + "id": "tts-1-1106", + "name": "tts-1-1106", + "display_name": "tts-1-1106", "modalities": { "input": [ - "text", - "image" + "audio" ] }, "limit": { @@ -254968,15 +255133,19 @@ "supported": false }, "cost": { - "input": 3.3, - "output": 16.5 - }, - "type": "chat" + "input": 15, + "output": 15 + } }, { - "id": "claude-instant-1", - "name": "claude-instant-1", - "display_name": "claude-instant-1", + "id": "tts-1", + "name": "tts-1", + "display_name": "tts-1", + "modalities": { + "input": [ + "audio" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -254986,15 +255155,14 @@ "supported": false }, "cost": { - "input": 1.793, - "output": 1.793 - }, - "type": "chat" + "input": 15, + "output": 15 + } }, { - "id": "claude-instant-1.2", - "name": "claude-instant-1.2", - "display_name": "claude-instant-1.2", + "id": "text-search-ada-doc-001", + "name": "text-search-ada-doc-001", + "display_name": "text-search-ada-doc-001", "limit": { "context": 8192, "output": 8192 @@ -255004,15 +255172,15 @@ "supported": false }, "cost": { - "input": 0.88, - "output": 3.96 + "input": 20, + "output": 20 }, "type": "chat" }, { - "id": "code-davinci-edit-001", - "name": "code-davinci-edit-001", - "display_name": "code-davinci-edit-001", + "id": "text-moderation-007", + "name": "text-moderation-007", + "display_name": "text-moderation-007", "limit": { "context": 8192, "output": 8192 @@ -255022,15 +255190,20 @@ "supported": false }, "cost": { - "input": 20, - "output": 20 + "input": 0.2, + "output": 0.2 }, "type": "chat" }, { - "id": "cogview-3", - "name": "cogview-3", - "display_name": "cogview-3", + "id": "text-embedding-ada-002", + "name": "text-embedding-ada-002", + "display_name": "text-embedding-ada-002", + "modalities": { + "input": [ + "text" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -255040,15 +255213,20 @@ "supported": false }, "cost": { - "input": 35.5, - "output": 35.5 + "input": 0.1, + "output": 0.1 }, - "type": "chat" + "type": "embedding" }, { - "id": "cogview-3-plus", - "name": "cogview-3-plus", - "display_name": "cogview-3-plus", + "id": "text-embedding-3-small", + "name": "text-embedding-3-small", + "display_name": "text-embedding-3-small", + "modalities": { + "input": [ + "text" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -255058,15 +255236,15 @@ "supported": false }, "cost": { - "input": 10, - "output": 10 + "input": 0.02, + "output": 0.02 }, - "type": "chat" + "type": "embedding" }, { - "id": "command", - "name": "command", - "display_name": "command", + "id": "text-embedding-3-large", + "name": "text-embedding-3-large", + "display_name": "text-embedding-3-large", "modalities": { "input": [ "text" @@ -255081,15 +255259,15 @@ "supported": false }, "cost": { - "input": 1, - "output": 2 + "input": 0.13, + "output": 0.13 }, - "type": "chat" + "type": "embedding" }, { - "id": "command-light", - "name": "command-light", - "display_name": "command-light", + "id": "text-moderation-stable", + "name": "text-moderation-stable", + "display_name": "text-moderation-stable", "limit": { "context": 8192, "output": 8192 @@ -255099,15 +255277,15 @@ "supported": false }, "cost": { - "input": 1, - "output": 2 + "input": 0.2, + "output": 0.2 }, "type": "chat" }, { - "id": "command-light-nightly", - "name": "command-light-nightly", - "display_name": "command-light-nightly", + "id": "text-davinci-edit-001", + "name": "text-davinci-edit-001", + "display_name": "text-davinci-edit-001", "limit": { "context": 8192, "output": 8192 @@ -255117,15 +255295,20 @@ "supported": false }, "cost": { - "input": 1, - "output": 2 + "input": 20, + "output": 20 }, "type": "chat" }, { - "id": "command-nightly", - "name": "command-nightly", - "display_name": "command-nightly", + "id": "whisper-1", + "name": "whisper-1", + "display_name": "whisper-1", + "modalities": { + "input": [ + "audio" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -255135,18 +255318,18 @@ "supported": false }, "cost": { - "input": 1, - "output": 2 + "input": 100, + "output": 100 }, "type": "chat" }, { - "id": "command-r", - "name": "command-r", - "display_name": "command-r", + "id": "whisper-large-v3", + "name": "whisper-large-v3", + "display_name": "whisper-large-v3", "modalities": { "input": [ - "text" + "audio" ] }, "limit": { @@ -255158,18 +255341,18 @@ "supported": false }, "cost": { - "input": 0.64, - "output": 1.92 + "input": 30.834, + "output": 30.834 }, "type": "chat" }, { - "id": "command-r-08-2024", - "name": "command-r-08-2024", - "display_name": "command-r-08-2024", + "id": "whisper-large-v3-turbo", + "name": "whisper-large-v3-turbo", + "display_name": "whisper-large-v3-turbo", "modalities": { "input": [ - "text" + "audio" ] }, "limit": { @@ -255181,20 +255364,15 @@ "supported": false }, "cost": { - "input": 0.2, - "output": 0.8 + "input": 5.556, + "output": 5.556 }, "type": "chat" }, { - "id": "command-r-plus", - "name": "command-r-plus", - "display_name": "command-r-plus", - "modalities": { - "input": [ - "text" - ] - }, + "id": "text-davinci-003", + "name": "text-davinci-003", + "display_name": "text-davinci-003", "limit": { "context": 8192, "output": 8192 @@ -255204,20 +255382,15 @@ "supported": false }, "cost": { - "input": 3.84, - "output": 19.2 + "input": 20, + "output": 20 }, "type": "chat" }, { - "id": "command-r-plus-08-2024", - "name": "command-r-plus-08-2024", - "display_name": "command-r-plus-08-2024", - "modalities": { - "input": [ - "text" - ] - }, + "id": "text-davinci-002", + "name": "text-davinci-002", + "display_name": "text-davinci-002", "limit": { "context": 8192, "output": 8192 @@ -255227,21 +255400,15 @@ "supported": false }, "cost": { - "input": 2.8, - "output": 11.2 + "input": 20, + "output": 20 }, "type": "chat" }, { - "id": "dall-e-2", - "name": "dall-e-2", - "display_name": "dall-e-2", - "modalities": { - "input": [ - "text", - "image" - ] - }, + "id": "text-curie-001", + "name": "text-curie-001", + "display_name": "text-curie-001", "limit": { "context": 8192, "output": 8192 @@ -255251,15 +255418,15 @@ "supported": false }, "cost": { - "input": 16, - "output": 16 + "input": 2, + "output": 2 }, - "type": "imageGeneration" + "type": "chat" }, { - "id": "davinci", - "name": "davinci", - "display_name": "davinci", + "id": "text-babbage-001", + "name": "text-babbage-001", + "display_name": "text-babbage-001", "limit": { "context": 8192, "output": 8192 @@ -255269,15 +255436,15 @@ "supported": false }, "cost": { - "input": 20, - "output": 20 + "input": 0.5, + "output": 0.5 }, "type": "chat" }, { - "id": "davinci-002", - "name": "davinci-002", - "display_name": "davinci-002", + "id": "text-ada-001", + "name": "text-ada-001", + "display_name": "text-ada-001", "limit": { "context": 8192, "output": 8192 @@ -255287,15 +255454,15 @@ "supported": false }, "cost": { - "input": 2, - "output": 2 + "input": 0.4, + "output": 0.4 }, "type": "chat" }, { - "id": "deepinfra-llama-3.1-8b-instant", - "name": "deepinfra-llama-3.1-8b-instant", - "display_name": "deepinfra-llama-3.1-8b-instant", + "id": "text-moderation-latest", + "name": "text-moderation-latest", + "display_name": "text-moderation-latest", "limit": { "context": 8192, "output": 8192 @@ -255305,15 +255472,15 @@ "supported": false }, "cost": { - "input": 0.033, - "output": 0.054978 + "input": 0.2, + "output": 0.2 }, "type": "chat" }, { - "id": "deepinfra-llama-3.3-70b-instant-turbo", - "name": "deepinfra-llama-3.3-70b-instant-turbo", - "display_name": "deepinfra-llama-3.3-70b-instant-turbo", + "id": "yi-large", + "name": "yi-large", + "display_name": "yi-large", "limit": { "context": 8192, "output": 8192 @@ -255323,15 +255490,15 @@ "supported": false }, "cost": { - "input": 0.11, - "output": 0.352 + "input": 3, + "output": 3 }, "type": "chat" }, { - "id": "deepinfra-llama-4-maverick-17b-128e-instruct", - "name": "deepinfra-llama-4-maverick-17b-128e-instruct", - "display_name": "deepinfra-llama-4-maverick-17b-128e-instruct", + "id": "yi-large-rag", + "name": "yi-large-rag", + "display_name": "yi-large-rag", "limit": { "context": 8192, "output": 8192 @@ -255341,15 +255508,15 @@ "supported": false }, "cost": { - "input": 0.33, - "output": 1.32 + "input": 4, + "output": 4 }, "type": "chat" }, { - "id": "deepinfra-llama-4-scout-17b-16e-instruct", - "name": "deepinfra-llama-4-scout-17b-16e-instruct", - "display_name": "deepinfra-llama-4-scout-17b-16e-instruct", + "id": "yi-large-turbo", + "name": "yi-large-turbo", + "display_name": "yi-large-turbo", "limit": { "context": 8192, "output": 8192 @@ -255359,16 +255526,15 @@ "supported": false }, "cost": { - "input": 0.088, - "output": 0.33, - "cache_read": 0 + "input": 1.8, + "output": 1.8 }, "type": "chat" }, { - "id": "deepseek-ai/DeepSeek-Coder-V2-Instruct", - "name": "deepseek-ai/DeepSeek-Coder-V2-Instruct", - "display_name": "deepseek-ai/DeepSeek-Coder-V2-Instruct", + "id": "yi-lightning", + "name": "yi-lightning", + "display_name": "yi-lightning", "limit": { "context": 8192, "output": 8192 @@ -255378,38 +255544,33 @@ "supported": false }, "cost": { - "input": 0.16, - "output": 0.32 + "input": 0.2, + "output": 0.2 }, "type": "chat" }, { - "id": "deepseek-ai/DeepSeek-R1-Distill-Llama-70B", - "name": "deepseek-ai/DeepSeek-R1-Distill-Llama-70B", - "display_name": "deepseek-ai/DeepSeek-R1-Distill-Llama-70B", + "id": "yi-medium", + "name": "yi-medium", + "display_name": "yi-medium", "limit": { "context": 8192, "output": 8192 }, "tool_call": false, "reasoning": { - "supported": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } + "supported": false }, "cost": { - "input": 0.6, - "output": 0.6 + "input": 0.4, + "output": 0.4 }, "type": "chat" }, { - "id": "deepseek-ai/DeepSeek-R1-Distill-Llama-8B", - "name": "deepseek-ai/DeepSeek-R1-Distill-Llama-8B", - "display_name": "deepseek-ai/DeepSeek-R1-Distill-Llama-8B", + "id": "yi-vl-plus", + "name": "yi-vl-plus", + "display_name": "yi-vl-plus", "limit": { "context": 8192, "output": 8192 @@ -255419,15 +255580,20 @@ "supported": false }, "cost": { - "input": 0.01, - "output": 0.01 + "input": 0.000852, + "output": 0.000852 }, "type": "chat" }, { - "id": "deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B", - "name": "deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B", - "display_name": "deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B", + "id": "text-embedding-v1", + "name": "text-embedding-v1", + "display_name": "text-embedding-v1", + "modalities": { + "input": [ + "text" + ] + }, "limit": { "context": 8192, "output": 8192 @@ -255437,15 +255603,15 @@ "supported": false }, "cost": { - "input": 0.01, - "output": 0.01 + "input": 0.1, + "output": 0.1 }, - "type": "chat" + "type": "embedding" }, { - "id": "deepseek-ai/DeepSeek-R1-Distill-Qwen-14B", - "name": "deepseek-ai/DeepSeek-R1-Distill-Qwen-14B", - "display_name": "deepseek-ai/DeepSeek-R1-Distill-Qwen-14B", + "id": "meta-llama-3-70b", + "name": "meta-llama-3-70b", + "display_name": "meta-llama-3-70b", "limit": { "context": 8192, "output": 8192 @@ -255455,15 +255621,15 @@ "supported": false }, "cost": { - "input": 0.1, - "output": 0.1 + "input": 4.795, + "output": 4.795 }, "type": "chat" }, { - "id": "deepseek-ai/DeepSeek-R1-Distill-Qwen-32B", - "name": "deepseek-ai/DeepSeek-R1-Distill-Qwen-32B", - "display_name": "deepseek-ai/DeepSeek-R1-Distill-Qwen-32B", + "id": "meta-llama-3-8b", + "name": "meta-llama-3-8b", + "display_name": "meta-llama-3-8b", "limit": { "context": 8192, "output": 8192 @@ -255473,33 +255639,116 @@ "supported": false }, "cost": { - "input": 0.2, - "output": 0.2 + "input": 0.548, + "output": 0.548 }, "type": "chat" }, { - "id": "deepseek-ai/DeepSeek-R1-Distill-Qwen-7B", - "name": "deepseek-ai/DeepSeek-R1-Distill-Qwen-7B", - "display_name": "deepseek-ai/DeepSeek-R1-Distill-Qwen-7B", + "id": "o3-global", + "name": "o3-global", + "display_name": "o3-global", "limit": { "context": 8192, "output": 8192 }, "tool_call": false, "reasoning": { - "supported": false + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "effort", + "effort": "medium", + "effort_options": [ + "low", + "medium", + "high" + ], + "visibility": "hidden" + } }, "cost": { - "input": 0.01, - "output": 0.01 + "input": 2, + "output": 8, + "cache_read": 0.5 }, "type": "chat" }, { - "id": "deepseek-ai/DeepSeek-V2-Chat", - "name": "deepseek-ai/DeepSeek-V2-Chat", - "display_name": "deepseek-ai/DeepSeek-V2-Chat", + "id": "o3-mini-global", + "name": "o3-mini-global", + "display_name": "o3-mini-global", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "effort", + "effort": "medium", + "effort_options": [ + "low", + "medium", + "high" + ], + "visibility": "hidden" + } + }, + "cost": { + "input": 1.1, + "output": 4.4, + "cache_read": 0.55 + }, + "type": "chat" + }, + { + "id": "o3-pro-global", + "name": "o3-pro-global", + "display_name": "o3-pro-global", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "effort", + "effort": "medium", + "effort_options": [ + "low", + "medium", + "high" + ], + "visibility": "hidden" + } + }, + "cost": { + "input": 20, + "output": 80 + }, + "type": "chat" + }, + { + "id": "qianfan-chinese-llama-2-13b", + "name": "qianfan-chinese-llama-2-13b", + "display_name": "qianfan-chinese-llama-2-13b", "limit": { "context": 8192, "output": 8192 @@ -255509,15 +255758,15 @@ "supported": false }, "cost": { - "input": 0.16, - "output": 0.32 + "input": 0.822, + "output": 0.822 }, "type": "chat" }, { - "id": "deepseek-ai/DeepSeek-V2.5", - "name": "deepseek-ai/DeepSeek-V2.5", - "display_name": "deepseek-ai/DeepSeek-V2.5", + "id": "qianfan-llama-vl-8b", + "name": "qianfan-llama-vl-8b", + "display_name": "qianfan-llama-vl-8b", "limit": { "context": 8192, "output": 8192 @@ -255527,8 +255776,8 @@ "supported": false }, "cost": { - "input": 0.16, - "output": 0.32 + "input": 0.274, + "output": 0.685 }, "type": "chat" }, @@ -255623,179 +255872,6 @@ "cache_read": 0.075 }, "type": "chat" - }, - { - "id": "meta-llama-3-70b", - "name": "meta-llama-3-70b", - "display_name": "meta-llama-3-70b", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 4.795, - "output": 4.795 - }, - "type": "chat" - }, - { - "id": "meta-llama-3-8b", - "name": "meta-llama-3-8b", - "display_name": "meta-llama-3-8b", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 0.548, - "output": 0.548 - }, - "type": "chat" - }, - { - "id": "o3-global", - "name": "o3-global", - "display_name": "o3-global", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "default_enabled": true, - "mode": "effort", - "effort": "medium", - "effort_options": [ - "low", - "medium", - "high" - ], - "visibility": "hidden" - } - }, - "cost": { - "input": 2, - "output": 8, - "cache_read": 0.5 - }, - "type": "chat" - }, - { - "id": "o3-mini-global", - "name": "o3-mini-global", - "display_name": "o3-mini-global", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "default_enabled": true, - "mode": "effort", - "effort": "medium", - "effort_options": [ - "low", - "medium", - "high" - ], - "visibility": "hidden" - } - }, - "cost": { - "input": 1.1, - "output": 4.4, - "cache_read": 0.55 - }, - "type": "chat" - }, - { - "id": "o3-pro-global", - "name": "o3-pro-global", - "display_name": "o3-pro-global", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "default_enabled": true, - "mode": "effort", - "effort": "medium", - "effort_options": [ - "low", - "medium", - "high" - ], - "visibility": "hidden" - } - }, - "cost": { - "input": 20, - "output": 80 - }, - "type": "chat" - }, - { - "id": "qianfan-chinese-llama-2-13b", - "name": "qianfan-chinese-llama-2-13b", - "display_name": "qianfan-chinese-llama-2-13b", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 0.822, - "output": 0.822 - }, - "type": "chat" - }, - { - "id": "qianfan-llama-vl-8b", - "name": "qianfan-llama-vl-8b", - "display_name": "qianfan-llama-vl-8b", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 0.274, - "output": 0.685 - }, - "type": "chat" } ] }, @@ -258287,7 +258363,7 @@ }, "limit": { "context": 262144, - "output": 262144 + "output": 16384 }, "temperature": true, "tool_call": true, @@ -258660,6 +258736,34 @@ }, "type": "chat" }, + { + "id": "meituan/longcat-2.0", + "name": "Meituan: LongCat 2.0", + "display_name": "Meituan: LongCat 2.0", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1048756, + "output": 262144 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "type": "chat" + }, { "id": "meta-llama/llama-3.1-70b-instruct", "name": "Meta: Llama 3.1 70B Instruct", @@ -260066,7 +260170,7 @@ }, "limit": { "context": 262144, - "output": 16384 + "output": 262144 }, "temperature": true, "tool_call": true, @@ -263558,8 +263662,8 @@ ] }, "limit": { - "context": 262144, - "output": 262144 + "context": 131072, + "output": 32768 }, "tool_call": true, "reasoning": { @@ -264599,7 +264703,7 @@ }, "limit": { "context": 262144, - "output": 131072 + "output": 262144 }, "temperature": true, "tool_call": true, @@ -264643,30 +264747,6 @@ }, "type": "chat" }, - { - "id": "tencent/hy3:free", - "name": "Tencent: Hy3 (free)", - "display_name": "Tencent: Hy3 (free)", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 262144, - "output": 262144 - }, - "temperature": true, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "type": "chat" - }, { "id": "thedrummer/cydonia-24b-v4.1", "name": "TheDrummer: Cydonia 24B V4.1", @@ -267924,29 +268004,6 @@ }, "type": "chat" }, - { - "id": "gpt-5-chat-latest", - "name": "gpt-5-chat-latest", - "display_name": "gpt-5-chat-latest", - "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 400000, - "output": 128000 - }, - "tool_call": true, - "reasoning": { - "supported": false - }, - "type": "chat" - }, { "id": "gpt-5", "name": "gpt-5", @@ -268800,31 +268857,6 @@ }, "type": "chat" }, - { - "id": "gemini-2.0-flash-lite", - "name": "gemini-2.0-flash-lite", - "display_name": "gemini-2.0-flash-lite", - "modalities": { - "input": [ - "text", - "image", - "video", - "audio" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 1048576, - "output": 8192 - }, - "tool_call": true, - "reasoning": { - "supported": false - }, - "type": "chat" - }, { "id": "google/gemma-3-27b-it", "name": "Gemma 3 27B", From f43b79251f9e87a327483f147a6593ff69b05f5b Mon Sep 17 00:00:00 2001 From: zhangmo8 Date: Tue, 21 Jul 2026 18:28:01 +0800 Subject: [PATCH 08/12] perf(renderer): streamline Markstream streaming --- .../plan.md | 136 +++++++ .../spec.md | 123 +++++++ .../tasks.md | 21 ++ .../markstream-code-block-rendering/spec.md | 87 +++++ package.json | 3 +- .../components/markdown/MarkdownRenderer.vue | 281 +++++++------- .../message/MessageBlockContent.vue | 5 +- .../message/MessageItemAssistant.vue | 2 +- .../composables/useDisplayMessages.ts | 24 +- src/renderer/src/stores/ui/message.ts | 59 ++- src/renderer/src/stores/ui/stream.ts | 6 +- test/main/agent/deepchat/runtime/echo.test.ts | 16 + .../components/MarkdownRenderer.test.ts | 342 ++++++++++++++---- .../components/MarkstreamDomContract.test.ts | 145 ++++++++ .../message/MessageBlockContent.test.ts | 37 +- .../message/MessageItemAssistant.test.ts | 13 + .../composables/useDisplayMessages.test.ts | 196 ++++++++++ test/renderer/stores/messageStore.test.ts | 68 +++- 18 files changed, 1347 insertions(+), 217 deletions(-) create mode 100644 docs/architecture/chat-markstream-rendering-pipeline/plan.md create mode 100644 docs/architecture/chat-markstream-rendering-pipeline/spec.md create mode 100644 docs/architecture/chat-markstream-rendering-pipeline/tasks.md create mode 100644 docs/issues/markstream-code-block-rendering/spec.md create mode 100644 test/renderer/components/MarkstreamDomContract.test.ts create mode 100644 test/renderer/features/chat-page/composables/useDisplayMessages.test.ts diff --git a/docs/architecture/chat-markstream-rendering-pipeline/plan.md b/docs/architecture/chat-markstream-rendering-pipeline/plan.md new file mode 100644 index 0000000000..6f2be98564 --- /dev/null +++ b/docs/architecture/chat-markstream-rendering-pipeline/plan.md @@ -0,0 +1,136 @@ +# 实施计划 + +## 1. 建立单一流式节拍 owner + +`MarkdownRenderer` 保留非流式 fast/slow debounce,但为 chat streaming 增加同步旁路: + +```text +content update + -> streaming now or streaming just ended? + yes -> invalidate older debounce revision and commit immediately + no -> keep existing 32/96 ms static debounce + -> NodeRenderer + -> Markstream smooth stream / parse scheduler +``` + +监听 source 同时包含 `content` 与 `isStreaming`。完成态转换必须根据 previous streaming state +同步提交,以覆盖父组件同一次 patch 中 content/status 一起改变的情况。旧 timer 继续用 revision +guard no-op,不新增 timer cleanup 或第二份派生状态。 + +## 2. 解耦 node virtualization 与重节点优先级 + +保留两类派生值: + +- `shouldVirtualizeNodes = virtualizeNodes && !isStreaming`:只决定 Markdown node DOM window; +- `shouldUseViewportPriority = virtualizeNodes`:继续决定 heavy node 是否接近视口后才启动。 + +保持 `codeRenderer="monaco"` 贯穿同一个 `NodeRenderer`,不要以 phase key 重建它。当前 Markstream 将 +该兼容名称映射到由 `stream-diffs` 支持的 `CodeBlockNode`:流式期间它保留自身的 `
` fallback,代码块
+完成且可见后再把同一宿主原子升级为单一 File/FileDiff surface。`deferNodesUntilVisible` 与
+`viewportPriority` 继续使用第二个值,保留既有 Mermaid/KaTeX 等重节点的调度策略。搜索/截图传入
+`virtualizeNodes=false` 时,所有内容仍立即可用。
+
+`MessageBlockContent` 只在搜索、截图等完整 DOM 消费者出现时传 `virtualizeNodes=false`。streaming 的
+node window 仍由 `MarkdownRenderer` 内部的 `virtualizeNodes && !isStreaming` 负责关闭。
+
+chat streaming 的 typewriter 使用 `simple` 模式。它保留尾部 CSS 光标和 smooth streaming 的 auto
+eligibility,但不使用 precise 模式每次提交的 Range/getClientRects 光标定位。
+
+## 3. 使用 Markstream 内建 code renderer
+
+- 增加 `stream-diffs` direct dependency,使 Markstream 的动态 runtime import 可解析;
+- live chat 与 completed/static 均显式传 `codeRenderer="monaco"`;live 传 `codeBlockStream=true`,完成时传 false 并同步更新 `final=true`。不重建 NodeRenderer,也不直接调用 `stream-diffs`;
+- 删除 generic `code_block` custom mapping,恢复 `CodeBlockNode` 的内建 `
` fallback 与增强 surface handoff;
+- 通过 `codeBlockProps` 传 themes 和工具栏选项,通过 `codeBlockMonacoOptions` 传字体/换行,通过 `@handle-artifact-click` 接收预览;
+- 保留 Mermaid strict props 和局部 `word-break: normal` 保护,但不覆盖 `stream-diffs` gutter/content 内部几何。
+
+## 4. 恢复真实尾部 stream 快路径
+
+`useDisplayMessages` 只在 inline stream 确实位于 `messageIds` 中间时返回 null。若
+`messageIds.at(-1) === streamId`,它与 synthetic stream 一样返回 `{ stable, tail }`,使
+`useMessageWindow` 复用 settled layout entries。判断保持 O(1),不扫描“最后一个可渲染消息”;这样
+cache 尚未插入 id、retry 或旧消息续写仍自动走保守路径。
+
+本地 optimistic user 和首次 stream placeholder 的 `orderSeq` 使用当前缓存最大有限值加一,而不是
+`messageIds.length + 1`。这样只加载长会话尾页时,新消息仍保持真实尾序,不会因一次全量排序被
+移到历史前方。该扫描只发生在本地消息首次插入,不在 token snapshot 热路径。
+
+## 5. 缩短 full snapshot 的 renderer 热路径
+
+- stream store 使用 shallow ref,因为每个 snapshot 都是整数组替换,内部没有嵌套 mutation contract;
+- `applyStreamingBlocksToMessage` 在写入 JSON record 的同时,用同一份已校验 blocks 预填 parsed cache;
+- stable block identity 继续通过 `reuseStableAssistantBlocks` 复用,metadata cache 在内容更新时保留。
+
+main 的 JSON normalization + Zod clone 继续保留,因为真实 `extra` payload 含有 undefined 值,直接
+schema parse 会改变兼容语义甚至拒绝 snapshot。preload 与 renderer event contract 校验也继续保留。
+full snapshot transport 仍会随累计长度重复校验和跨进程复制;delta protocol 需要独立设计和
+profile,不在本次变更中暗改。
+
+## 6. 移除 custom registry 热路径
+
+MarkdownRenderer 改用 NodeRenderer 内建 link/reference/Mermaid 组件:
+
+- `mermaidProps.isStrict=true` 保持安全模式;
+- 根级 click 委托识别 anchor,继续调用 `navigateLink`;
+- 根级 mouse/click 委托识别 `.reference-node`,继续加载搜索结果与显示引用浮层;
+- code preview 通过公开 `@handle-artifact-click` event 处理。
+
+因此不再调用 `setCustomComponents/removeCustomComponents`,同消息多个 text part 不会互相覆盖,
+Markstream 也可以启用 append-only parse 和 stable top-level node reuse。`customId` 仍包含实例 token,
+用于隔离 NodeRenderer 自身的虚拟化/测量 identity。
+
+## 7. 与 PR #2000 的边界
+
+不改变 `useMessageWindow` 或 `useMessageVirtualization` 的 contract。本次补齐
+`useDisplayMessages` 对真实尾部 inline stream 的分类,并保证 tail 到 Markstream 的交接不再重复
+pacing、重复 JSON parse 或因 registry 关闭增量节点复用。
+
+完成态继续依赖现有 message id / renderKey handoff:
+
+```text
+pending placeholder -> real stream message -> persisted final message
+       same outer row identity      same MarkdownRenderer instance
+```
+
+## 8. 测试策略
+
+- MarkdownRenderer component test:
+  - 后续 streaming snapshot 同步转交,不只覆盖首 chunk;
+  - final transition 同步带上最终 content,且旧 timer 不能回写;
+  - streaming 到 final 始终选择同一个 enhanced renderer,并把 `codeBlockStream` 从 true 切到 false;
+  - explicit `virtualizeNodes=false` 同时关闭三类延迟;
+  - `stream-diffs` handoff、无 generic override、preview 和 Mermaid strict 保持。
+- useDisplayMessages:
+  - 尾部 inline stream 返回 segments,连续 snapshot 保持 stable identity;
+  - 中间位置 inline stream 返回 null,完整消息顺序不变。
+- message/echo:
+  - schema parse 保持深拷贝;
+  - snapshot blocks 预填 parsed cache,并保持 settled block identity。
+- 复跑 MessageBlockContent、MessageList、MessageItemAssistant、useDisplayMessages、
+  useMessageWindow、useMessageVirtualization 和 ChatPage 相关 suite。
+- 执行 typecheck 与 production renderer build,验证 Markstream optional peer 的真实解析路径。
+
+## 9. 验证命令
+
+```bash
+pnpm exec vitest --config vitest.config.renderer.ts test/renderer/components/MarkdownRenderer.test.ts test/renderer/components/message/MessageBlockContent.test.ts
+pnpm exec vitest --config vitest.config.renderer.ts test/renderer/components/MessageList.test.ts test/renderer/components/message/MessageItemAssistant.test.ts test/renderer/composables/useMessageWindow.test.ts test/renderer/composables/useMessageVirtualization.test.ts
+pnpm run format
+pnpm run i18n
+pnpm run lint
+pnpm run typecheck
+pnpm run test:renderer
+pnpm run build
+```
+
+## 风险与缓解
+
+| 风险 | 缓解 |
+| --- | --- |
+| final 与 content 同 patch 时顺序不确定 | 同时 watch 两个 source,并用 previous streaming 状态同步提交 |
+| streaming heavy node 延迟后高度变化影响滚动 | 使用 Markstream 自带 lifecycle/ResizeObserver,外层继续按 PR #2000 批量测量 |
+| 搜索或截图拿不到离屏 DOM | `virtualizeNodes=false` 同时关闭 node window 与 viewport deferral |
+| stream-diffs optional peer 缺失 | direct dependency + Markstream 内建 pre fallback + production build 验证 |
+| 旧静态 debounce 晚到覆盖 stream | 每次同步旁路递增同一个 revision,使旧 callback no-op |
+| 事件委托误拦截普通节点 | 只处理 closest anchor / `.reference-node`,其他 click/mouse event 原样忽略 |
+| 中间 stream 被错误追加到尾部 | 仅以排序后的 `messageIds` 最后一个 id 精确匹配,其他情况返回 null |
diff --git a/docs/architecture/chat-markstream-rendering-pipeline/spec.md b/docs/architecture/chat-markstream-rendering-pipeline/spec.md
new file mode 100644
index 0000000000..273c18cf2f
--- /dev/null
+++ b/docs/architecture/chat-markstream-rendering-pipeline/spec.md
@@ -0,0 +1,123 @@
+# Chat 与 Markstream 流式渲染管线
+
+## 背景
+
+PR #2000 已把高频流式消息从稳定历史列表中拆出,并为消息窗口增加 append-only tail
+快路径、批量测量和显式行级 streaming 状态。与此同时,聊天正文仍经过 DeepChat 自己的
+内容防抖,再交给当前 `markstream-vue@1.0.7-beta.4` 的 smooth streaming、解析合并、节点批量挂载和重节点延迟机制。增强代码块使用其 `stream-diffs@0.0.2` peer。
+
+当前端到端链路如下:
+
+```text
+provider token events
+  -> main accumulator
+  -> echo renderer throttle (120 ms, full block snapshot)
+  -> chat.stream.updated typed event
+  -> messageIpc request/session ordering gate
+  -> stream store + folded message record
+  -> stable display history + active streaming tail
+  -> outer message window / scroll arbitration
+  -> MessageBlockContent ant-tag projection
+  -> MarkdownRenderer stream handoff
+  -> Markstream smooth streaming (up to 20 commits/s)
+  -> incremental Markdown parse + node batching
+  -> Monaco / Mermaid / KaTeX viewport deferral
+  -> DOM measurement -> outer message window / scroll follow
+```
+
+主进程的 120 ms 合帧控制跨进程吞吐,Markstream 的 smooth streaming 控制用户实际看到的
+文本节奏,PR #2000 的消息窗口控制历史行重算和滚动。集成层不应再成为第三个流式节拍 owner。
+
+## 问题
+
+1. 聊天流内容在进入 Markstream 前再经过 32 ms 或 96 ms 防抖。它不能减少 main snapshot
+   数量,却增加首段之外每个 snapshot 的延迟,并可能让 `final` 先于最新防抖内容生效。
+2. 旧集成曾把 `codeRenderer="monaco"` 误当作流式期间即时创建 Monaco 的开关,并因此尝试在
+   应用层把 renderer 从 `pre` 重建为 `monaco`。当前 Markstream 的该兼容名称实际选择由
+   `stream-diffs` 驱动的增强 `CodeBlockNode`:它在 block streaming 时保留内建 `
`,仅在
+   block 完成且可见后才升级同一宿主。因此外部 remount 会破坏受支持的 handoff,并可能造成完成闪烁或瞬时几何。
+3. generic `code_block` 自定义映射会绕过 Markstream 内建的 renderer selection、异步 fallback 和
+   viewport-deferred `stream-diffs` 路径。仅安装 optional peer 或直接导入其 controller 都不能替代该路径。
+4. 正常回复虽然已经折叠进 `messageCache`,但 `useDisplayMessages` 将所有 inline stream 都视为
+   可能位于历史中间,导致真实的尾部回复无法使用 PR #2000 的 append-only layout segments。
+5. 每个累计 snapshot 在 main 中先 JSON round-trip 再 Zod clone,renderer 写入消息后又立即把
+   同一份 blocks JSON.parse 回来;长回复会放大全量 snapshot 协议本身的 O(L^2) 累计成本。
+6. 每个 MarkdownRenderer 通过全局 custom component registry 注册纯渲染 wrapper。同一消息被
+   artifact 分段时 registry id 冲突,且任意非空 custom map 都会让 Markstream 禁用稳定顶层节点复用。
+7. `codeBlockStream=false` 在 loading code node 上显示 skeleton,而不是实时代码;它不负责选择
+   Monaco。对于聊天流,轻量 `
` 比 skeleton 更连续,enhanced runtime 仍应等节点闭合且可见。
+8. 外层消息窗口与 Markstream 内层节点窗口职责相邻。两者必须各自只处理自己的粒度:外层按
+   message 行裁剪,内层按 Markdown node 裁剪,搜索/截图需要完整 DOM 时同时显式退出延迟行为。
+
+## 目标
+
+1. 让 main 进程负责 snapshot 合帧,Markstream 负责文本 pacing;聊天集成层同步转交流式内容。
+2. 从 streaming 到 final 的同一消息、MarkdownRenderer、NodeRenderer 与内建 CodeBlockNode 都保持身份稳定;最终内容不会被旧的防抖回调覆盖。
+3. 整个生命周期保持 Markstream `codeRenderer="monaco"`,并仅以 `codeBlockStream` / `final` 表达流式状态;内建 `CodeBlockNode` 在 streaming 时呈现 `
`,完成且可见后才挂载 `stream-diffs` 表面。内层 node virtualization 继续在 streaming 阶段关闭,避免 typewriter 尾部被节点窗口裁掉。
+4. completed 历史消息继续使用 Markstream node virtualization 及可见性延迟;聊天搜索、截图和其他要求
+   完整 DOM 的路径仍可通过 `virtualizeNodes=false` 同时关闭虚拟化与视口延迟。
+5. ordinary fenced code 使用 Markstream 内建 `stream-diffs` 路径;streaming 时先显示轻量 fallback,
+   final 且接近视口后再加载 enhanced File/FileDiff surface。Mermaid 保持 strict 处理。
+6. 保留非流式、可高频编辑的 docs/artifact surface 的现有内容防抖,避免把聊天优化扩散到不同
+   交互语义的页面。
+7. 正常追加到 `messageIds` 尾部的 inline stream 使用 stable/tail layout contract;retry、旧消息
+   续写和任何中间位置 stream 继续保守地重建完整顺序。
+8. 复用已通过 IPC schema 校验的 blocks,减少 renderer 内部重复 JSON parse,同时不削弱
+   main/preload/renderer 边界校验,也不改变持久化 JSON 格式。
+9. 使用 Markstream 内建 link/reference/Mermaid 节点和 renderer 级事件委托,避免全局 registry
+   写入及其 parser reuse 失效;链接导航、搜索引用预览和 strict Mermaid 行为保持不变。
+10. 高频 chat stream 使用 Markstream 的 simple CSS typewriter cursor,避免 precise cursor 每次可见
+    提交都通过 Range/getClientRects 触发布局读取。
+
+## 非目标
+
+- 不修改 `chat.stream.updated` snapshot contract、main 120 ms renderer 合帧或 600 ms DB 合帧。
+- 不改 Markstream、stream-diffs 或 DeepChat 独立 editor surfaces 所用 stream-monaco 的上游实现,不维护本地 fork。
+- 不重写 `useArtifacts` 的 ant-tag parser、消息 JSON 持久化格式或 message store 的折叠协议。
+- 不在本次工作中把 full snapshot IPC 改为 delta transport;它仍是超长输出的剩余架构瓶颈。
+- 不替换 PR #2000 的 outer message window、scroll controller、search highlight 或 capture owner。
+- 不在本次工作中改变 markdown worker 的全局生命周期或引入新的用户设置。
+
+## 验收标准
+
+1. streaming 状态下,首个及后续 `content` snapshot 都在同一 Vue 更新周期传给 NodeRenderer,
+   不等待 DeepChat 的 32/96 ms timer。
+2. streaming -> final 时,NodeRenderer 同步收到最终 content 和 `final=true`;任何较早的静态防抖
+   任务都不能回写旧内容。
+3. 非流式内容更新继续走已有 fast/slow debounce;现有 docs/artifact 行为不变。
+4. 正常 streaming chat 配置为 `nodeVirtual=false`、`maxLiveNodes=0`、`codeRenderer="monaco"`、`codeBlockStream=true`;完成态在同一 NodeRenderer 上设为 `final=true` 与 `codeBlockStream=false`。Markstream 在流式阶段展示内建 `
`,完成且可见后才升级 `stream-diffs` surface。`viewportPriority` 与 `deferNodesUntilVisible` 仍仅由 `virtualizeNodes` 控制。
+5. `virtualizeNodes=false` 时,node virtualization、viewport priority 和 visible deferral 均关闭,
+   保证搜索、截图和完整 DOM 消费者可用。
+6. NodeRenderer 在 live chat 和 completed/static 内容均保持 `codeRenderer="monaco"`,只通过 `codeBlockStream` 与 `final` 交给 Markstream 管理单一代码块 handoff;preview event 与 strict Mermaid 仍可用。
+7. MarkdownRenderer 不写入 Markstream 全局 custom component registry;内建 link/reference 事件经
+   根级委托保持 DeepChat 导航和引用交互,同消息多个 text part 互不覆盖。
+8. 尾部 inline stream 返回 stable/tail segments,连续 token update 保持 stable 数组和 settled row
+   引用;中间位置 inline stream 仍返回 null 并保持原顺序。
+9. renderer 已校验 blocks 直接预填 parsed cache,不在同一个 snapshot 写入后立即 JSON.parse。
+11. PR #2000 的 stable history / streaming tail、单行 streaming 状态和 completion node reuse 回归
+   测试继续通过。
+12. format、i18n、lint、typecheck、targeted renderer tests 和 renderer production build 通过。
+
+## 性能与交互预算
+
+- 跨进程 snapshot 频率仍由 main 限制为最多约 8.3 次/秒。
+- 屏幕文本提交频率由 Markstream 限制为最多 20 次/秒;集成层不再叠加聊天内容 timer。
+- 单个 stream snapshot 只允许使 active assistant row 及其外层测量链失效,settled rows 保持对象
+  与 props 稳定。
+- 正常尾部 inline stream 不得为每个 snapshot 重新 materialize 全部 layout entries;已校验 blocks
+  不得在 renderer 同步热路径再次 JSON.parse。
+- 重节点在接近视口前不得启动 Monaco/Mermaid/KaTeX 重工作;完成态的 fallback 到 enhanced
+  切换不得替换外层 message row。
+- 沿用 chat scroll ownership 的每帧最多一次 scroll write、1 px anchor 误差和无新增 >50 ms
+  long task 预算;jsdom 测试仅作为调度回归,真实 DOM 性能仍以 Electron/Chromium profile 为准。
+
+## 兼容性与回滚
+
+变化位于 main snapshot clone 与 renderer 集成层,不改变持久化数据、IPC schema 或用户设置。
+若需回滚,可以分别恢复内容路由、layout segment 分类、parsed cache 预填、事件委托和内建 code
+renderer 选择;PR #2000 的消息窗口仍可独立工作。
+
+## GitHub Issue
+
+未请求或创建 GitHub issue。当前工作区的 `docs/issues/markstream-code-block-rendering/spec.md`
+记录 ordinary code block 的具体回归,本架构目标覆盖其集成层根因。
diff --git a/docs/architecture/chat-markstream-rendering-pipeline/tasks.md b/docs/architecture/chat-markstream-rendering-pipeline/tasks.md
new file mode 100644
index 0000000000..cceedb6de3
--- /dev/null
+++ b/docs/architecture/chat-markstream-rendering-pipeline/tasks.md
@@ -0,0 +1,21 @@
+# 任务清单
+
+- [x] 审计 PR #2000 的 stable history、streaming tail、outer message window 与 scroll 链路。
+- [x] 审计 main 120 ms snapshot 合帧、renderer message store,以及当前 Markstream + stream-diffs 源码与 AI workflow。
+- [x] 明确 main、renderer integration、Markstream 和 outer window 的 owner 边界。
+- [x] 写入 spec、plan 和验证预算,无 `[NEEDS CLARIFICATION]`。
+- [x] 让 streaming snapshot 和 final handoff 绕过 DeepChat 静态内容 debounce。
+- [x] 建立稳定的 stream-diffs handoff:streaming 保持 Markstream `
` fallback,completed/visible 由同一 CodeBlockNode 升级 File/FileDiff surface。
+- [x] 完成 Markstream 内建 enhanced code renderer 路径与公开 preview event 兼容处理。
+- [x] 补同步 stream handoff、final、viewport 和 enhanced code renderer 行为测试。
+- [ ] 让正常尾部 inline stream 命中 stable/tail layout contract,并覆盖中间 stream 回退。
+- [ ] 修正高 orderSeq 分页窗口中的 optimistic/stream 本地尾序。
+- [ ] 使用 shallow stream state,并预填 renderer parsed cache;保留 main JSON normalization 兼容语义。
+- [x] 用内建 link/reference/Mermaid + 事件委托移除 MarkdownRenderer 全局 custom registry 热路径。
+- [x] 恢复 Markstream live code `
` fallback,并覆盖 static/completed handoff 行为。
+- [x] 将高频 chat typewriter 切换为 simple CSS cursor。
+- [x] 更新 `markstream-code-block-rendering` issue spec 的版本、handoff 与验证状态。
+- [x] 运行 targeted renderer tests 并复审 failure。
+- [x] 运行 format、i18n、lint、typecheck、完整 renderer tests 与 production build。(full renderer 并行运行受宿主容量影响,出现 32 个无关 10 秒超时;Markstream 相关 suites、类型检查和 production bundling 均通过。)
+- [x] 复审最终 diff、性能职责和 PR #2000 描述是否仍准确。
+- [ ] 在 Electron/Chromium 中手工验证完成态 `stream-diffs` gutter/content 几何;jsdom 不具备该布局能力。
diff --git a/docs/issues/markstream-code-block-rendering/spec.md b/docs/issues/markstream-code-block-rendering/spec.md
new file mode 100644
index 0000000000..fc9412f7ad
--- /dev/null
+++ b/docs/issues/markstream-code-block-rendering/spec.md
@@ -0,0 +1,87 @@
+# Markstream Code Block Rendering Regression
+
+## Issue
+
+DeepChat's Markstream-backed code blocks can show gutter/content overlap or an incorrectly measured
+code surface after an assistant message changes from streaming to final. The integration must use the
+current enhanced code-block runtime: `markstream-vue@1.0.7-beta.4` with its `stream-diffs@0.0.2`
+peer.
+
+## Impact
+
+- Language-labeled and unlabeled fences must keep their source readable while generation is active.
+- Completed, visible fences must upgrade to the enhanced `stream-diffs` File/FileDiff surface without
+  replacing the outer message or reusing transient streaming geometry.
+- The application must not depend on Markstream internals, direct `stream-diffs` controllers, or CSS
+  overrides of editor/gutter geometry.
+
+## Root Cause
+
+The prior diagnosis inspected a `markstream-vue@1.0.5 + stream-monaco` runtime after the dependency
+had been pinned away from the current release line. That runtime is not the one requested for this
+integration, and its application-managed continuous Monaco lifecycle is not the documented behavior
+of the current `stream-diffs` adapter.
+
+For current Markstream, `codeRenderer="monaco"` remains the compatibility name for the enhanced
+`CodeBlockNode`, but it does **not** create a live Monaco instance for every streaming code update.
+The component owns a stable handoff:
+
+```text
+MarkdownRenderer
+  -> NodeRenderer (content grows, final=false, codeRenderer="monaco", codeBlockStream=true)
+    -> CodeBlockNode renders its built-in PreCodeNode while the fence is streaming
+
+same NodeRenderer / same CodeBlockNode
+  -> final=true, codeBlockStream=false, completed and visible
+    -> CodeBlockNode dynamically imports stream-diffs and mounts one File/FileDiff surface
+```
+
+Replacing the renderer at the application boundary (`pre` → `monaco`) or calling private runtime APIs
+would bypass that lifecycle and reintroduce a completion flash or geometry race. The app-level prose
+root also applies `break-all`; enhanced code hosts must opt out of that inherited text-breaking rule,
+and their flex parent must be allowed to shrink, without targeting runtime gutter internals.
+
+## Fix Plan
+
+1. Restore current direct dependencies: `markstream-vue@1.0.7-beta.4` and `stream-diffs@0.0.2`.
+   Keep `stream-monaco` because DeepChat's independent artifact/editor surfaces still import it.
+2. Keep one stable `NodeRenderer` with `codeRenderer="monaco"` for both streaming and final states.
+   Pass `codeBlockStream=true` only while the message is live and set `final=true` with the last
+   content snapshot when it completes.
+3. Do not set `renderCodeBlocksAsPre`, register a generic `code_block` override, remount the renderer,
+   import `stream-diffs` directly from DeepChat, or call private adapter methods.
+4. Continue using Markstream's documented `handle-artifact-click` event and supported
+   `codeBlockProps` / `codeBlockMonacoOptions` props.
+5. Replace DeepChat-only Markdown fence labels such as `desktop-local-file` with `plaintext` before
+   passing content to Markstream. `stream-diffs` delegates final highlighting to Shiki and rejects
+   unsupported identifiers; valid standard fence languages remain unchanged.
+6. Use `break-words` instead of the prose root's `break-all`, and retain `min-w-0` on the assistant
+   content flex item. Neither change overrides runtime gutter/content geometry.
+7. Update DOM-contract tests to cover both unlabeled and TypeScript fallback/final handoff. Validate
+   actual surface geometry in Electron because jsdom cannot provide browser layout measurements.
+
+## Validation
+
+- [x] Package lock resolves `markstream-vue@1.0.7-beta.4` with `stream-diffs@0.0.2`.
+- [x] Focused MarkdownRenderer tests confirm one enhanced renderer configuration through streaming and
+  final completion, with the final snapshot committed synchronously.
+- [x] Markstream DOM-contract test proves the built-in streaming `
` fallback and post-completion
+  enhanced `stream-diffs` handoff for unlabeled and TypeScript fences.
+- [x] Message layout test verifies the code host's flex ancestor can shrink.
+- [ ] In a fresh Electron window, verify visible unlabeled and TypeScript fences after completion have
+  distinct gutter/content columns with no overlap or collapsed surface width.
+- [x] Focused renderer tests (54), format, i18n, lint, typecheck, and direct `electron-vite build`
+  passed. The full renderer suite remains a host-capacity follow-up: its parallel run produced 32
+  unrelated 10-second test timeouts, while all Markstream-related suites passed. The standard
+  `pnpm run build` command also needs a `pnpm` Corepack shim in this host's child-script PATH; its
+  prebuild refresh completed and direct production bundling passed.
+
+### Manual Electron Follow-up
+
+No DeepChat Electron window was available for this change, and the local CUA driver lacked Screen
+Recording permission. Browser-layout validation of the enhanced `stream-diffs` gutter remains an
+explicit manual acceptance step; jsdom cannot establish or assert that geometry.
+
+## GitHub Issue
+
+No GitHub issue has been requested or created.
diff --git a/package.json b/package.json
index a66ffb7c1a..d93898b334 100644
--- a/package.json
+++ b/package.json
@@ -197,7 +197,7 @@
     "electron-vite": "5.0.0",
     "jsdom": "^26.1.0",
     "katex": "^0.16.47",
-    "markstream-vue": "^1.0.6",
+    "markstream-vue": "1.0.7-beta.4",
     "mermaid": "^11.15.0",
     "minimatch": "^10.2.5",
     "monaco-editor": "^0.55.1",
@@ -206,6 +206,7 @@
     "picocolors": "^1.1.1",
     "pinia": "^3.0.4",
     "reka-ui": "^2.10.1",
+    "stream-diffs": "0.0.2",
     "stream-monaco": "^0.0.49",
     "tailwind-merge": "^3.6.0",
     "tailwind-scrollbar-hide": "^4.0.0",
diff --git a/src/renderer/src/components/markdown/MarkdownRenderer.vue b/src/renderer/src/components/markdown/MarkdownRenderer.vue
index 6c8eab77e8..89535d1754 100644
--- a/src/renderer/src/components/markdown/MarkdownRenderer.vue
+++ b/src/renderer/src/components/markdown/MarkdownRenderer.vue
@@ -1,16 +1,20 @@
 
@@ -39,17 +47,10 @@ import { useArtifactStore } from '@/stores/artifact'
 import { useReferenceStore } from '@/stores/reference'
 import { nanoid } from 'nanoid'
 import { useDebounceFn } from '@vueuse/core'
-import { computed, h, onBeforeUnmount, onMounted, ref, watch } from 'vue'
-import NodeRenderer, {
-  CodeBlockNode,
-  ReferenceNode,
-  removeCustomComponents,
-  setCustomComponents,
-  MermaidBlockNode
-} from 'markstream-vue'
+import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
+import NodeRenderer, { type CodeBlockPreviewPayload } from 'markstream-vue'
 import { useThemeStore } from '@/stores/theme'
 import { useUiSettingsStore } from '@/stores/uiSettingsStore'
-import LinkNode from './LinkNode.vue'
 import { useMarkdownLinkNavigation } from './useMarkdownLinkNavigation'
 import type { MarkdownLinkContext } from './linkTypes'
 import { ensureMarkdownWorkers } from '@/lib/markdownWorkerLifecycle'
@@ -77,16 +78,26 @@ const props = withDefaults(
 )
 const themeStore = useThemeStore()
 const uiSettingsStore = useUiSettingsStore()
-// 组件映射表
 const artifactStore = useArtifactStore()
-// 生成唯一的 message ID 和 thread ID,用于 MarkdownRenderer
+const UNSUPPORTED_CODE_FENCE_LANGUAGES = new Set(['desktop-local-file'])
 const fallbackMessageId = `artifact-msg-${nanoid()}`
 const fallbackThreadId = `artifact-thread-${nanoid()}`
 const referenceStore = useReferenceStore()
 const sessionClient = createSessionClient()
-const referenceNode = ref(null)
-const debouncedContent = ref(props.content)
+const normalizeCodeFenceLanguages = (content: string): string =>
+  content.replace(/(^|\n)(`{3,}|~{3,})([^\r\n]*)/g, (fence, lineStart, delimiter, info) => {
+    const [language, ...meta] = info.trim().split(/\s+/)
+    if (!UNSUPPORTED_CODE_FENCE_LANGUAGES.has(language?.toLowerCase())) {
+      return fence
+    }
+
+    return `${lineStart}${delimiter}plaintext${meta.length ? ` ${meta.join(' ')}` : ''}`
+  })
+
+const renderContent = ref(normalizeCodeFenceLanguages(props.content))
 let searchResultsPromise: ReturnType | null = null
+let activeReferenceElement: HTMLElement | null = null
+let rendererContextRevision = 0
 const effectiveMessageId = computed(() => props.messageId ?? fallbackMessageId)
 const effectiveThreadId = computed(() => props.threadId ?? fallbackThreadId)
 const effectiveLinkContext = computed(() => {
@@ -107,7 +118,8 @@ const customRendererId = computed(() =>
     effectiveMessageId.value,
     effectiveLinkContext.value.source,
     effectiveLinkContext.value.sessionId ?? '',
-    effectiveLinkContext.value.sourceFilePath ?? ''
+    effectiveLinkContext.value.sourceFilePath ?? '',
+    fallbackMessageId
   ].join('::')
 )
 const codeBlockThemes = ['vitesse-dark', 'vitesse-light'] as const
@@ -117,6 +129,10 @@ const codeBlockMonacoOption = computed(() => ({
   fontFamily: uiSettingsStore.formattedCodeFontFamily,
   wordWrap: 'on' as const
 }))
+const codeBlockProps = computed(() => ({
+  themes: [...codeBlockThemes]
+}))
+const mermaidProps = { isStrict: true } as const
 const isStreaming = computed(
   () => props.final === false || (props.streaming && props.final !== true)
 )
@@ -128,6 +144,10 @@ const resolvedSmoothStreaming = computed(() => {
 
   return 'auto' as const
 })
+const resolvedTypewriter = computed(() => (isStreaming.value ? ('simple' as const) : false))
+// In current Markstream, `monaco` is the compatibility name for the enhanced stream-diffs
+// CodeBlockNode. Keep it stable: Markstream owns the streaming 
 -> final surface handoff.
+const codeRenderer = 'monaco' as const
 const STREAM_INITIAL_RENDER_BATCH_SIZE = 10
 const STREAM_RENDER_BATCH_SIZE = 14
 const STREAM_RENDER_BATCH_DELAY_MS = 8
@@ -144,7 +164,7 @@ const STATIC_MAX_LIVE_NODES = 260
 const STATIC_LIVE_NODE_BUFFER = 80
 
 const shouldVirtualizeNodes = computed(() => props.virtualizeNodes && !isStreaming.value)
-const shouldDeferNodesUntilVisible = computed(() => shouldVirtualizeNodes.value)
+const shouldUseViewportPriority = computed(() => props.virtualizeNodes)
 const resolvedNodeVirtual = computed(() =>
   shouldVirtualizeNodes.value ? ('auto' as const) : false
 )
@@ -177,6 +197,91 @@ const getSearchResults = () => {
   return searchResultsPromise
 }
 
+function closestEventElement(event: Event, selector: string): HTMLElement | null {
+  const target = event.target
+  return target instanceof Element ? (target.closest(selector) as HTMLElement | null) : null
+}
+
+function getReferenceIndex(element: HTMLElement): number {
+  return Number.parseInt(element.textContent?.trim() ?? '', 10) - 1
+}
+
+function isEventInsideElement(event: MouseEvent, element: HTMLElement): boolean {
+  return event.relatedTarget instanceof Node && element.contains(event.relatedTarget)
+}
+
+function handleArtifactClick(v: CodeBlockPreviewPayload): void {
+  artifactStore.showArtifact(
+    {
+      id: v.id,
+      type: v.artifactType,
+      title: v.artifactTitle,
+      language: v.node.language,
+      content: v.node.code,
+      status: 'loaded'
+    },
+    effectiveMessageId.value,
+    effectiveThreadId.value,
+    { force: true }
+  )
+}
+
+function handleRendererClick(event: MouseEvent): void {
+  const referenceElement = closestEventElement(event, '.reference-node')
+  if (referenceElement) {
+    const index = getReferenceIndex(referenceElement)
+    const contextRevision = rendererContextRevision
+    if (index >= 0) {
+      getSearchResults().then((results) => {
+        if (contextRevision === rendererContextRevision && index < results.length) {
+          void navigateLink(results[index].url, event)
+        }
+      })
+    }
+    return
+  }
+
+  const anchor = closestEventElement(event, 'a.link-node[href]')
+  if (anchor) {
+    void navigateLink(anchor.getAttribute('href') ?? '', event)
+  }
+}
+
+function handleRendererKeydown(event: KeyboardEvent): void {
+  if (event.key !== 'Enter' && event.key !== ' ') return
+  const referenceElement = closestEventElement(event, '.reference-node')
+  if (!referenceElement) return
+
+  event.preventDefault()
+  referenceElement.click()
+}
+
+function handleRendererMouseover(event: MouseEvent): void {
+  const referenceElement = closestEventElement(event, '.reference-node')
+  if (!referenceElement || isEventInsideElement(event, referenceElement)) return
+
+  activeReferenceElement = referenceElement
+  referenceStore.hideReference()
+  const index = getReferenceIndex(referenceElement)
+  if (index < 0) return
+
+  getSearchResults().then((results) => {
+    if (activeReferenceElement === referenceElement && index < results.length) {
+      referenceStore.showReference(results[index], referenceElement.getBoundingClientRect())
+    }
+  })
+}
+
+function handleRendererMouseout(event: MouseEvent): void {
+  const referenceElement = closestEventElement(event, '.reference-node')
+  if (!referenceElement || isEventInsideElement(event, referenceElement)) return
+
+  if (activeReferenceElement === referenceElement) {
+    activeReferenceElement = null
+  }
+  referenceStore.hideReference()
+}
+
 // Shared revision guard so an older slow-path update can never land after a
 // newer fast-path update (or vice versa) when the routing condition flips,
 // which would repaint stale markdown and reintroduce the completion flash.
@@ -185,7 +290,7 @@ let contentRevision = 0
 const updateContentFast = useDebounceFn(
   (revision: number, value: string) => {
     if (revision === contentRevision) {
-      debouncedContent.value = value
+      renderContent.value = value
     }
   },
   32,
@@ -194,126 +299,45 @@ const updateContentFast = useDebounceFn(
 const updateContentSlow = useDebounceFn(
   (revision: number, value: string) => {
     if (revision === contentRevision) {
-      debouncedContent.value = value
+      renderContent.value = value
     }
   },
   96,
   { maxWait: 180 }
 )
 
-const updateContent = (value: string) => {
+const updateContent = (value: string, commitImmediately: boolean) => {
   const revision = ++contentRevision
+  const normalizedValue = normalizeCodeFenceLanguages(value)
 
-  if (isStreaming.value && debouncedContent.value.length === 0 && value.length > 0) {
-    debouncedContent.value = value
+  // Main already coalesces renderer snapshots and Markstream owns visible pacing.
+  // Stream updates, including the final handoff, must not pass through a third timer.
+  if (commitImmediately) {
+    renderContent.value = normalizedValue
     return
   }
 
   if (props.smoothStreaming && value.length > 12_000) {
-    updateContentSlow(revision, value)
+    updateContentSlow(revision, normalizedValue)
     return
   }
 
-  updateContentFast(revision, value)
+  updateContentFast(revision, normalizedValue)
 }
 
-watch(
-  () => props.content,
-  (value) => {
-    updateContent(value)
-  }
-)
-
-watch(effectiveMessageId, () => {
-  searchResultsPromise = null
+watch([() => props.content, isStreaming], ([value, streaming], [, wasStreaming]) => {
+  updateContent(value, streaming || wasStreaming === true)
 })
 
-watch(
-  customRendererId,
-  (nextCustomRendererId, previousCustomRendererId) => {
-    if (previousCustomRendererId && previousCustomRendererId !== nextCustomRendererId) {
-      removeCustomComponents(previousCustomRendererId)
-    }
-
-    setCustomComponents(nextCustomRendererId, {
-      link: (_props) =>
-        h(LinkNode, {
-          ..._props,
-          linkContext: effectiveLinkContext.value
-        }),
-      reference: (_props) =>
-        h(ReferenceNode, {
-          ..._props,
-          messageId: effectiveMessageId.value,
-          threadId: effectiveThreadId.value,
-          onClick(event?: MouseEvent) {
-            getSearchResults().then((results) => {
-              const index = parseInt(_props.node.id, 10) - 1
-              if (index >= 0 && index < results.length) {
-                void navigateLink(results[index].url, event)
-              }
-            })
-          },
-          onMouseEnter() {
-            referenceStore.hideReference()
-            getSearchResults().then((results) => {
-              const index = parseInt(_props.node.id, 10) - 1
-              if (index >= 0 && index < results.length && referenceNode.value) {
-                referenceStore.showReference(
-                  results[index],
-                  referenceNode.value.getBoundingClientRect()
-                )
-              }
-            })
-          },
-          onMouseLeave() {
-            referenceStore.hideReference()
-          }
-        }),
-      mermaid: (_props) => {
-        return h(MermaidBlockNode, {
-          ..._props,
-          isStrict: true
-        })
-      },
-      code_block: (_props) => {
-        const isMermaid = _props.node.language === 'mermaid'
-        if (isMermaid) {
-          return h(MermaidBlockNode, {
-            ..._props,
-            isStrict: true
-          })
-        }
-        return h(CodeBlockNode, {
-          ..._props,
-          isDark: themeStore.isDark,
-          darkTheme: codeBlockDarkTheme,
-          lightTheme: codeBlockLightTheme,
-          themes: [...codeBlockThemes],
-          monacoOptions: codeBlockMonacoOption.value,
-          onPreviewCode(v) {
-            artifactStore.showArtifact(
-              {
-                id: v.id,
-                type: v.artifactType,
-                title: v.artifactTitle,
-                language: v.language,
-                content: v.node.code,
-                status: 'loaded'
-              },
-              effectiveMessageId.value,
-              effectiveThreadId.value,
-              { force: true }
-            )
-          }
-        })
-      }
-    })
-  },
-  {
-    immediate: true
+watch(customRendererId, () => {
+  rendererContextRevision += 1
+  searchResultsPromise = null
+  const ownedReferencePreview = activeReferenceElement !== null
+  activeReferenceElement = null
+  if (ownedReferencePreview) {
+    referenceStore.hideReference()
   }
-)
+})
 
 onMounted(() => {
   ensureMarkdownWorkers().catch((error) => {
@@ -322,7 +346,12 @@ onMounted(() => {
 })
 
 onBeforeUnmount(() => {
-  removeCustomComponents(customRendererId.value)
+  rendererContextRevision += 1
+  const ownedReferencePreview = activeReferenceElement !== null
+  activeReferenceElement = null
+  if (ownedReferencePreview) {
+    referenceStore.hideReference()
+  }
 })
 
 defineEmits(['copy'])
@@ -395,14 +424,6 @@ defineEmits(['copy'])
     contain: layout style paint;
   }
 
-  .markstream-vue [data-markstream-code-block='1'],
-  .markstream-vue [data-markstream-code-block='1'] .code-editor-container,
-  .markstream-vue [data-markstream-code-block='1'] .code-pre-fallback,
-  .markstream-vue pre[class^='language-'],
-  .markstream-vue pre[class*=' language-'] {
-    scrollbar-gutter: stable;
-  }
-
   table {
     @apply py-0 my-0;
     border-collapse: collapse;
diff --git a/src/renderer/src/components/message/MessageBlockContent.vue b/src/renderer/src/components/message/MessageBlockContent.vue
index 4180c3761b..5b73dc5bba 100644
--- a/src/renderer/src/components/message/MessageBlockContent.vue
+++ b/src/renderer/src/components/message/MessageBlockContent.vue
@@ -64,11 +64,8 @@ const shouldSmoothStream = computed(
   () => props.block.status === 'pending' || props.block.status === 'loading'
 )
 const isStreamingPart = (part: ProcessedPart) => shouldSmoothStream.value || Boolean(part.loading)
-const hasStreamingContent = computed(() =>
-  processedContent.value.some((part) => part.type === 'text' && isStreamingPart(part))
-)
 const shouldVirtualizeNodes = computed(
-  () => !props.disableMarkdownVirtualization && !props.isSearchResult && !hasStreamingContent.value
+  () => !props.disableMarkdownVirtualization && !props.isSearchResult
 )
 
 const artifactSnapshot = computed(() =>
diff --git a/src/renderer/src/components/message/MessageItemAssistant.vue b/src/renderer/src/components/message/MessageItemAssistant.vue
index 3f5ba57924..c3156af14e 100644
--- a/src/renderer/src/components/message/MessageItemAssistant.vue
+++ b/src/renderer/src/components/message/MessageItemAssistant.vue
@@ -27,7 +27,7 @@
           />
         
 
-        
+
{ return left < right ? -1 : 1 } + function getNextLocalOrderSeq(): number { + let maxOrderSeq = 0 + for (const id of messageIds.value) { + const orderSeq = messageCache.value.get(id)?.orderSeq + if (typeof orderSeq === 'number' && Number.isFinite(orderSeq)) { + maxOrderSeq = Math.max(maxOrderSeq, orderSeq) + } + } + return maxOrderSeq + 1 + } + function sortMessageIdsByOrderSeq(): void { messageIds.value.sort((a, b) => { const aSeq = messageCache.value.get(a)?.orderSeq ?? Number.MAX_SAFE_INTEGER @@ -373,6 +384,32 @@ export const useMessageStore = defineStore('message', () => { ) } + function cacheStreamingAssistantBlocks( + record: ChatMessageRecord, + blocks: AssistantMessageBlock[] + ): void { + const cached = parsedMessageCache.get(record.id) + const previousBlocks = cached?.assistantBlocks ?? cached?.prevAssistantBlocks + const assistantBlocks = reuseStableAssistantBlocks( + blocks as DisplayAssistantMessageBlock[], + previousBlocks + ) + const entry: ParsedMessageCacheEntry = { + updatedAt: record.updatedAt, + content: record.content, + metadata: record.metadata, + assistantBlocks, + prevAssistantBlocks: assistantBlocks + } + + if (cached?.metadata === record.metadata && cached.parsedMetadata) { + entry.parsedMetadata = cached.parsedMetadata + } + + parsedMessageCache.delete(record.id) + setParsedEntry(record.id, entry) + } + function getAssistantMessageBlocks(record: ChatMessageRecord): DisplayAssistantMessageBlock[] { const entry = getParsedEntry(record) if (entry.assistantBlocks) { @@ -786,7 +823,7 @@ export const useMessageStore = defineStore('message', () => { const record: ChatMessageRecord = { id, sessionId, - orderSeq: messageIds.value.length + 1, + orderSeq: getNextLocalOrderSeq(), role: 'user', content: JSON.stringify({ text: normalizedInput.text, @@ -889,35 +926,41 @@ export const useMessageStore = defineStore('message', () => { existing.status === 'pending' && existing.metadata === nextMetadata ) { + cacheStreamingAssistantBlocks(existing, blocks) return } markLiveMessageViewMutation(conversationId) - upsertMessageRecord({ + const nextRecord: ChatMessageRecord = { ...existing, content: serializedBlocks, metadata: nextMetadata, status: 'pending', updatedAt: Date.now() - }) + } + upsertMessageRecord(nextRecord) + cacheStreamingAssistantBlocks(nextRecord, blocks) return } if (hydratingStreamMessageIds.has(messageId)) return hydratingStreamMessageIds.add(messageId) markLiveMessageViewMutation(conversationId) - upsertMessageRecord({ + const now = Date.now() + const nextRecord: ChatMessageRecord = { id: messageId, sessionId: conversationId, - orderSeq: messageIds.value.length + 1, + orderSeq: getNextLocalOrderSeq(), role: 'assistant', content: serializedBlocks, status: 'pending', isContextEdge: 0, metadata: serializedMetadata, traceCount: 0, - createdAt: Date.now(), - updatedAt: Date.now() - }) + createdAt: now, + updatedAt: now + } + upsertMessageRecord(nextRecord) + cacheStreamingAssistantBlocks(nextRecord, blocks) hydratingStreamMessageIds.delete(messageId) } diff --git a/src/renderer/src/stores/ui/stream.ts b/src/renderer/src/stores/ui/stream.ts index 7f5b315a43..9cc7704ec0 100644 --- a/src/renderer/src/stores/ui/stream.ts +++ b/src/renderer/src/stores/ui/stream.ts @@ -1,10 +1,12 @@ import { defineStore } from 'pinia' -import { ref } from 'vue' +import { ref, shallowRef } from 'vue' import type { AssistantMessageBlock } from '@shared/types/agent-interface' export const useStreamStateStore = defineStore('streamState', () => { const isStreaming = ref(false) - const streamingBlocks = ref([]) + // Stream snapshots are schema-validated whole-array replacements. Avoid recursively + // proxying an ever-growing payload that is never mutated in place. + const streamingBlocks = shallowRef([]) const currentStreamSessionId = ref(null) const currentStreamRequestId = ref(null) const currentStreamMessageId = ref(null) diff --git a/test/main/agent/deepchat/runtime/echo.test.ts b/test/main/agent/deepchat/runtime/echo.test.ts index c82b204a59..5630c6ffbf 100644 --- a/test/main/agent/deepchat/runtime/echo.test.ts +++ b/test/main/agent/deepchat/runtime/echo.test.ts @@ -203,4 +203,20 @@ describe('echo', () => { expect(cloned[0]?.extra?.nested).not.toBe(blocks[0]?.extra?.nested) expect(cloned[0]?.extra?.nested[0]).not.toBe(blocks[0]?.extra?.nested[0]) }) + + it('removes undefined JSON fields before validating renderer blocks', () => { + const blocks = [ + { + type: 'search' as const, + status: 'success' as const, + timestamp: 1, + extra: { + label: 'web_search', + engine: undefined + } + } + ] + + expect(cloneBlocksForRenderer(blocks)[0]?.extra).toEqual({ label: 'web_search' }) + }) }) diff --git a/test/renderer/components/MarkdownRenderer.test.ts b/test/renderer/components/MarkdownRenderer.test.ts index 336aca4973..5339f563c2 100644 --- a/test/renderer/components/MarkdownRenderer.test.ts +++ b/test/renderer/components/MarkdownRenderer.test.ts @@ -1,8 +1,16 @@ import { flushPromises, mount } from '@vue/test-utils' import { defineComponent, h } from 'vue' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { MarkdownLinkContext } from '@/components/markdown/linkTypes' +function createDeferred() { + let resolve!: (value: T) => void + const promise = new Promise((innerResolve) => { + resolve = innerResolve + }) + return { promise, resolve } +} + const { showArtifactMock, getSearchResultsMock, @@ -25,6 +33,16 @@ const setup = async (props: Record = {}) => { vi.resetModules() let customComponents: Record any> = {} + const setCustomComponentsMock = vi.fn( + ( + customIdOrComponents: string | Record any>, + maybeComponents?: Record any> + ) => { + customComponents = + typeof customIdOrComponents === 'string' ? (maybeComponents ?? {}) : customIdOrComponents + } + ) + const removeCustomComponentsMock = vi.fn() vi.doMock('nanoid', () => ({ nanoid: nanoidMock @@ -78,7 +96,8 @@ const setup = async (props: Record = {}) => { artifactTitle: 'HTML Preview', language: 'html', node: { - code: '

Hello

' + code: '

Hello

', + language: 'html' } } @@ -102,7 +121,7 @@ const setup = async (props: Record = {}) => { default: false }, typewriter: { - type: Boolean, + type: [Boolean, String], default: false }, batchRendering: { @@ -133,6 +152,22 @@ const setup = async (props: Record = {}) => { type: Boolean, default: false }, + codeRenderer: { + type: String, + default: undefined + }, + codeBlockProps: { + type: Object, + default: undefined + }, + mermaidProps: { + type: Object, + default: undefined + }, + customId: { + type: String, + default: undefined + }, initialRenderBatchSize: { type: Number, default: undefined @@ -162,7 +197,8 @@ const setup = async (props: Record = {}) => { default: '' } }, - setup(props) { + emits: ['click', 'mouseover', 'mouseout', 'handleArtifactClick'], + setup(props, { emit }) { return () => h( 'div', @@ -180,6 +216,10 @@ const setup = async (props: Record = {}) => { 'data-max-live-nodes': String(props.maxLiveNodes), 'data-live-node-buffer': String(props.liveNodeBuffer), 'data-code-block-stream': String(props.codeBlockStream), + 'data-code-renderer': props.codeRenderer, + 'data-code-block-theme-count': String(props.codeBlockProps?.themes?.length ?? 0), + 'data-mermaid-strict': String(props.mermaidProps?.isStrict), + 'data-custom-id': props.customId, 'data-initial-render-batch-size': String(props.initialRenderBatchSize), 'data-render-batch-size': String(props.renderBatchSize), 'data-render-batch-delay': String(props.renderBatchDelay), @@ -189,57 +229,55 @@ const setup = async (props: Record = {}) => { 'data-content': props.content }, [ - customComponents.code_block?.({ - node: { - language: 'html', - code: '

Hello

', - raw: '

Hello

' - } - }) ?? h('div') + h( + 'a', + { + href: 'https://example.com/link', + class: 'link-node', + 'data-testid': 'rendered-link', + onClick: (event: MouseEvent) => emit('click', event) + }, + 'link' + ), + h( + 'a', + { + href: '#unmarked-anchor', + 'data-testid': 'unmarked-anchor', + onClick: (event: MouseEvent) => emit('click', event) + }, + 'unmarked anchor' + ), + h( + 'button', + { + type: 'button', + 'data-testid': 'preview-code', + onClick: () => emit('handleArtifactClick', previewPayload) + }, + 'preview code' + ), + h( + 'span', + { + class: 'reference-node', + 'data-testid': 'reference-node', + onClick: (event: MouseEvent) => emit('click', event), + onMouseover: (event: MouseEvent) => emit('mouseover', event), + onMouseout: (event: MouseEvent) => emit('mouseout', event) + }, + '1' + ) ] ) } }) - const CodeBlockNode = defineComponent({ - name: 'CodeBlockNode', - emits: ['previewCode'], - mounted() { - this.$emit('previewCode', previewPayload) - }, - render() { - return h('div', { 'data-testid': 'code-block-node' }) - } - }) - - const ReferenceNode = defineComponent({ - name: 'ReferenceNode', - render() { - return h('div') - } - }) - - const MermaidBlockNode = defineComponent({ - name: 'MermaidBlockNode', - render() { - return h('div') - } - }) - return { default: NodeRenderer, NodeRenderer, - CodeBlockNode, - ReferenceNode, - MermaidBlockNode, - removeCustomComponents: vi.fn(), - setCustomComponents: ( - customIdOrComponents: string | Record any>, - maybeComponents?: Record any> - ) => { - customComponents = - typeof customIdOrComponents === 'string' ? (maybeComponents ?? {}) : customIdOrComponents - } + removeCustomComponents: removeCustomComponentsMock, + setCustomComponents: setCustomComponentsMock } }) @@ -255,7 +293,9 @@ const setup = async (props: Record = {}) => { return { wrapper, - getCustomComponents: () => customComponents + getCustomComponents: () => customComponents, + setCustomComponentsMock, + removeCustomComponentsMock } } @@ -270,10 +310,17 @@ describe('MarkdownRenderer', () => { ensureMarkdownWorkersMock.mockReset() ensureMarkdownWorkersMock.mockResolvedValue(undefined) navigateLinkMock.mockReset() - navigateLinkMock.mockResolvedValue(true) + navigateLinkMock.mockImplementation(async (_href: string, event?: MouseEvent | null) => { + event?.preventDefault() + return true + }) nanoidMock.mockReturnValueOnce('fallback-message').mockReturnValueOnce('fallback-thread') }) + afterEach(() => { + vi.useRealTimers() + }) + it('initializes markdown workers lazily when mounted', async () => { await setup() @@ -281,11 +328,13 @@ describe('MarkdownRenderer', () => { }) it('uses the provided message and thread ids for HTML preview artifacts', async () => { - await setup({ + const { wrapper } = await setup({ messageId: 'message-1', threadId: 'thread-1' }) + await wrapper.get('[data-testid="preview-code"]').trigger('click') + expect(showArtifactMock).toHaveBeenCalledWith( { id: 'preview-artifact', @@ -302,7 +351,8 @@ describe('MarkdownRenderer', () => { }) it('falls back to local ids when no message or thread ids are provided', async () => { - await setup() + const { wrapper } = await setup() + await wrapper.get('[data-testid="preview-code"]').trigger('click') expect(showArtifactMock).toHaveBeenCalledWith( { @@ -319,6 +369,28 @@ describe('MarkdownRenderer', () => { ) }) + it('normalizes unsupported code fence languages before they reach Markstream', async () => { + const { wrapper } = await setup({ + content: '```desktop-local-file\nconst answer = 42\n```' + }) + + expect(wrapper.get('[data-testid="node-renderer"]').attributes('data-content')).toBe( + '```plaintext\nconst answer = 42\n```' + ) + }) + + it('normalizes unsupported code fence languages during streaming updates', async () => { + const { wrapper } = await setup({ content: '', streaming: true, final: false }) + + await wrapper.setProps({ + content: '~~~DESKTOP-LOCAL-FILE path=src/example.ts\nconst answer = 42\n~~~' + }) + + expect(wrapper.get('[data-testid="node-renderer"]').attributes('data-content')).toBe( + '~~~plaintext path=src/example.ts\nconst answer = 42\n~~~' + ) + }) + it('renders static markdown as final docs content by default', async () => { const { wrapper } = await setup() const nodeRenderer = wrapper.get('[data-testid="node-renderer"]') @@ -335,6 +407,8 @@ describe('MarkdownRenderer', () => { expect(nodeRenderer.attributes('data-max-live-nodes')).toBe('260') expect(nodeRenderer.attributes('data-live-node-buffer')).toBe('80') expect(nodeRenderer.attributes('data-code-block-stream')).toBe('false') + expect(nodeRenderer.attributes('data-code-renderer')).toBe('monaco') + expect(nodeRenderer.attributes('data-code-block-theme-count')).toBe('2') expect(nodeRenderer.attributes('data-initial-render-batch-size')).toBe('96') expect(nodeRenderer.attributes('data-render-batch-size')).toBe('80') expect(nodeRenderer.attributes('data-render-batch-delay')).toBe('0') @@ -343,10 +417,40 @@ describe('MarkdownRenderer', () => { expect(nodeRenderer.attributes('data-parse-coalesce-ms')).toBe('0') }) - it('marks the root for scoped code block scrollbar stabilization', async () => { + it('keeps prose wrapping from splitting code surfaces at every character', async () => { const { wrapper } = await setup() expect(wrapper.classes()).toContain('markdown-renderer-root') + expect(wrapper.classes()).toContain('break-words') + expect(wrapper.classes()).not.toContain('break-all') + }) + + it('leaves generic code blocks on Markstream’s built-in Monaco path', async () => { + const { getCustomComponents } = await setup({ mode: 'chat' }) + + expect(getCustomComponents().code_block).toBeUndefined() + }) + + it('uses the built-in strict Mermaid renderer without a global custom registry', async () => { + const { wrapper, getCustomComponents, setCustomComponentsMock, removeCustomComponentsMock } = + await setup() + + expect(wrapper.get('[data-testid="node-renderer"]').attributes('data-mermaid-strict')).toBe( + 'true' + ) + expect(getCustomComponents()).toEqual({}) + expect(setCustomComponentsMock).not.toHaveBeenCalled() + + wrapper.unmount() + expect(removeCustomComponentsMock).not.toHaveBeenCalled() + }) + + it('keeps each NodeRenderer measurement identity instance-local', async () => { + const { wrapper } = await setup({ messageId: 'message-1', threadId: 'thread-1' }) + + expect(wrapper.get('[data-testid="node-renderer"]').attributes('data-custom-id')).toContain( + 'artifact-msg-fallback-message' + ) }) it('passes the requested chat mode and streaming options to NodeRenderer for live content', async () => { @@ -361,13 +465,15 @@ describe('MarkdownRenderer', () => { expect(nodeRenderer.attributes('data-mode')).toBe('chat') expect(nodeRenderer.attributes('data-final')).toBe('false') expect(nodeRenderer.attributes('data-smooth-streaming')).toBe('auto') - expect(nodeRenderer.attributes('data-typewriter')).toBe('true') + expect(nodeRenderer.attributes('data-typewriter')).toBe('simple') expect(nodeRenderer.attributes('data-node-virtual')).toBe('false') expect(nodeRenderer.attributes('data-max-live-nodes')).toBe('0') expect(nodeRenderer.attributes('data-live-node-buffer')).toBe('0') expect(nodeRenderer.attributes('data-code-block-stream')).toBe('true') - expect(nodeRenderer.attributes('data-defer-nodes-until-visible')).toBe('false') - expect(nodeRenderer.attributes('data-viewport-priority')).toBe('false') + expect(nodeRenderer.attributes('data-code-renderer')).toBe('monaco') + expect(nodeRenderer.attributes('data-code-block-theme-count')).toBe('2') + expect(nodeRenderer.attributes('data-defer-nodes-until-visible')).toBe('true') + expect(nodeRenderer.attributes('data-viewport-priority')).toBe('true') expect(nodeRenderer.attributes('data-initial-render-batch-size')).toBe('10') expect(nodeRenderer.attributes('data-render-batch-size')).toBe('14') expect(nodeRenderer.attributes('data-render-batch-delay')).toBe('8') @@ -393,6 +499,58 @@ describe('MarkdownRenderer', () => { ) }) + it('hands live snapshots and final content to Markstream without an app-level timer', async () => { + vi.useFakeTimers() + const { wrapper } = await setup({ content: 'static content' }) + const nodeRenderer = () => wrapper.get('[data-testid="node-renderer"]') + + // Queue a static update first so the live path also proves that older debounce + // work cannot repaint stale content after streaming begins. + await wrapper.setProps({ content: 'queued static content' }) + expect(nodeRenderer().attributes('data-content')).toBe('static content') + + await wrapper.setProps({ + content: 'first live snapshot', + streaming: true, + final: false + }) + expect(nodeRenderer().attributes('data-content')).toBe('first live snapshot') + expect(nodeRenderer().attributes('data-code-renderer')).toBe('monaco') + expect(nodeRenderer().attributes('data-defer-nodes-until-visible')).toBe('true') + expect(nodeRenderer().attributes('data-viewport-priority')).toBe('true') + + await wrapper.setProps({ content: 'second live snapshot' }) + expect(nodeRenderer().attributes('data-content')).toBe('second live snapshot') + + await wrapper.setProps({ + content: 'final content', + streaming: false, + final: true + }) + expect(nodeRenderer().attributes('data-content')).toBe('final content') + expect(nodeRenderer().attributes('data-final')).toBe('true') + expect(nodeRenderer().attributes('data-code-renderer')).toBe('monaco') + expect(nodeRenderer().attributes('data-defer-nodes-until-visible')).toBe('true') + expect(nodeRenderer().attributes('data-viewport-priority')).toBe('true') + + vi.advanceTimersByTime(500) + await wrapper.vm.$nextTick() + expect(nodeRenderer().attributes('data-content')).toBe('final content') + }) + + it('keeps coalescing updates for non-streaming surfaces', async () => { + vi.useFakeTimers() + const { wrapper } = await setup({ content: 'initial static content' }) + const nodeRenderer = () => wrapper.get('[data-testid="node-renderer"]') + + await wrapper.setProps({ content: 'updated static content' }) + expect(nodeRenderer().attributes('data-content')).toBe('initial static content') + + vi.advanceTimersByTime(64) + await wrapper.vm.$nextTick() + expect(nodeRenderer().attributes('data-content')).toBe('updated static content') + }) + it('disables smooth streaming when requested for live content', async () => { const { wrapper } = await setup({ streaming: true, @@ -435,7 +593,7 @@ describe('MarkdownRenderer', () => { } ]) - const { getCustomComponents } = await setup({ + const { wrapper } = await setup({ messageId: 'message-1', threadId: 'thread-1', linkContext: { @@ -444,17 +602,77 @@ describe('MarkdownRenderer', () => { } satisfies MarkdownLinkContext }) - const referenceVNode = getCustomComponents().reference?.({ - node: { - id: '1' - } - }) const clickEvent = new MouseEvent('click', { altKey: true }) - await referenceVNode.props.onClick(clickEvent) + wrapper.get('[data-testid="reference-node"]').element.dispatchEvent(clickEvent) await flushPromises() expect(getSearchResultsMock).toHaveBeenCalledWith('message-1') expect(navigateLinkMock).toHaveBeenCalledWith('https://example.com/reference', clickEvent) }) + + it('ignores reference results that resolve after the renderer unmounts', async () => { + const searchResults = createDeferred>() + getSearchResultsMock.mockReturnValueOnce(searchResults.promise) + const { wrapper } = await setup({ messageId: 'message-1' }) + + wrapper.get('[data-testid="reference-node"]').element.dispatchEvent(new MouseEvent('click')) + wrapper.unmount() + searchResults.resolve([{ url: 'https://example.com/stale' }]) + await flushPromises() + + expect(navigateLinkMock).not.toHaveBeenCalled() + }) + + it('routes built-in link clicks through the shared markdown link navigator', async () => { + const { wrapper } = await setup() + const clickEvent = new MouseEvent('click', { cancelable: true }) + + wrapper.get('[data-testid="rendered-link"]').element.dispatchEvent(clickEvent) + await flushPromises() + + expect(navigateLinkMock).toHaveBeenCalledWith('https://example.com/link', clickEvent) + }) + + it('does not take over anchors without Markstream’s link marker', async () => { + const { wrapper } = await setup() + const clickEvent = new MouseEvent('click') + Object.defineProperty(clickEvent, 'target', { + value: wrapper.get('[data-testid="unmarked-anchor"]').element + }) + + wrapper.findComponent({ name: 'NodeRenderer' }).vm.$emit('click', clickEvent) + await flushPromises() + + expect(navigateLinkMock).not.toHaveBeenCalled() + }) + + it('supports keyboard activation for built-in reference nodes', async () => { + getSearchResultsMock.mockResolvedValueOnce([{ url: 'https://example.com/reference' }]) + const { wrapper } = await setup({ messageId: 'message-1' }) + const referenceElement = wrapper.get('[data-testid="reference-node"]').element + + referenceElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + await flushPromises() + + expect(navigateLinkMock).toHaveBeenCalledWith( + 'https://example.com/reference', + expect.any(MouseEvent) + ) + }) + + it('anchors reference previews to the delegated reference element', async () => { + getSearchResultsMock.mockResolvedValueOnce([{ url: 'https://example.com/reference' }]) + const { wrapper } = await setup({ messageId: 'message-1' }) + const referenceElement = wrapper.get('[data-testid="reference-node"]').element as HTMLElement + const rect = referenceElement.getBoundingClientRect() + + referenceElement.dispatchEvent(new MouseEvent('mouseover')) + await flushPromises() + + expect(showReferenceMock).toHaveBeenCalledWith({ url: 'https://example.com/reference' }, rect) + + referenceElement.dispatchEvent(new MouseEvent('mouseout')) + expect(hideReferenceMock).toHaveBeenCalled() + }) }) diff --git a/test/renderer/components/MarkstreamDomContract.test.ts b/test/renderer/components/MarkstreamDomContract.test.ts new file mode 100644 index 0000000000..c3acba32ef --- /dev/null +++ b/test/renderer/components/MarkstreamDomContract.test.ts @@ -0,0 +1,145 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { nextTick } from 'vue' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import NodeRenderer from 'markstream-vue' + +const originalCssStyleSheet = globalThis.CSSStyleSheet +const originalResizeObserver = globalThis.ResizeObserver + +beforeAll(() => { + class TestCssStyleSheet { + replaceSync() {} + } + + class TestResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + } + + Object.defineProperty(globalThis, 'CSSStyleSheet', { + configurable: true, + value: TestCssStyleSheet + }) + Object.defineProperty(globalThis, 'ResizeObserver', { + configurable: true, + value: TestResizeObserver + }) +}) + +afterAll(() => { + Object.defineProperty(globalThis, 'CSSStyleSheet', { + configurable: true, + value: originalCssStyleSheet + }) + Object.defineProperty(globalThis, 'ResizeObserver', { + configurable: true, + value: originalResizeObserver + }) +}) + +async function settleRenderer() { + await nextTick() + await flushPromises() + await nextTick() + await flushPromises() +} + +async function mountMarkstream(content: string) { + const wrapper = mount(NodeRenderer, { + props: { + content, + final: true, + smoothStreaming: false, + batchRendering: false, + deferNodesUntilVisible: false, + viewportPriority: false, + nodeVirtual: false, + codeRenderer: 'pre' + } + }) + await settleRenderer() + return wrapper +} + +describe('Markstream DOM contracts used by MarkdownRenderer delegation', () => { + it.each([ + ['unlabeled', '```\nconst answer = 42', '```\nconst answer = 42\n```'], + ['TypeScript', '```ts\nconst answer = 42', '```ts\nconst answer = 42\n```'] + ])( + 'keeps the %s fence readable through Markstream’s streaming-to-final handoff', + async (_label, liveContent, finalContent) => { + const wrapper = mount(NodeRenderer, { + props: { + content: liveContent, + final: false, + smoothStreaming: false, + batchRendering: false, + deferNodesUntilVisible: false, + viewportPriority: false, + nodeVirtual: false, + codeRenderer: 'monaco', + codeBlockStream: true + } + }) + + await settleRenderer() + + expect(wrapper.get('pre.code-pre-fallback').text()).toContain('const answer = 42') + + await wrapper.setProps({ + content: finalContent, + final: true, + codeBlockStream: false + }) + await settleRenderer() + + const codeBlock = wrapper.get('[data-markstream-code-block="1"]') + expect(codeBlock.attributes('data-markstream-code-block-state')).toBe('settled') + expect(codeBlock.text()).toContain('const answer = 42') + } + ) + + it('updates the fallback text during streaming without replacing the renderer', async () => { + const wrapper = mount(NodeRenderer, { + props: { + content: '```ts\nconst answer = 42', + final: false, + smoothStreaming: false, + batchRendering: false, + deferNodesUntilVisible: false, + viewportPriority: false, + nodeVirtual: false, + codeRenderer: 'monaco', + codeBlockStream: true + } + }) + + await settleRenderer() + const codeBlock = wrapper.get('[data-markstream-code-block="1"]').element + + await wrapper.setProps({ content: '```ts\nconst answer = 43' }) + await settleRenderer() + + expect(wrapper.get('[data-markstream-code-block="1"]').element).toBe(codeBlock) + expect(wrapper.get('pre.code-pre-fallback').text()).toContain('const answer = 43') + }) + + it('marks both Markdown and normalized safe HTML links for delegation', async () => { + const wrapper = await mountMarkstream( + '[DeepChat](https://deepchat.thinkinai.xyz)\n\nRaw' + ) + + expect(wrapper.get('a.link-node').attributes('href')).toBe('https://deepchat.thinkinai.xyz') + expect(wrapper.get('a[href="https://example.com/raw"]').classes()).toContain('link-node') + }) + + it('renders numeric citations with the reference-node delegation marker', async () => { + const wrapper = await mountMarkstream('Cite research [1] for details.') + const reference = wrapper.get('span.reference-node') + + expect(reference.text()).toBe('1') + await reference.trigger('click') + expect(wrapper.emitted('click')).toHaveLength(1) + }) +}) diff --git a/test/renderer/components/message/MessageBlockContent.test.ts b/test/renderer/components/message/MessageBlockContent.test.ts index 21bee68cbc..8c74e5d5f0 100644 --- a/test/renderer/components/message/MessageBlockContent.test.ts +++ b/test/renderer/components/message/MessageBlockContent.test.ts @@ -231,10 +231,45 @@ describe('MessageBlockContent', () => { expect(markdown.attributes('data-smooth-streaming')).toBe('true') expect(markdown.attributes('data-streaming')).toBe('true') expect(markdown.attributes('data-final')).toBe('false') - expect(markdown.attributes('data-virtualize-nodes')).toBe('false') + // MarkdownRenderer disables its node window while live, but keeps this capability + // enabled so Markstream can still defer heavy offscreen nodes. + expect(markdown.attributes('data-virtualize-nodes')).toBe('true') } ) + it('hands a streaming text part to final state without replacing its renderer node', async () => { + const wrapper = mount(MessageBlockContent, { + props: { + block: createBlock({ + status: 'loading', + content: 'streaming markdown content' + }), + messageId: 'm-stream', + threadId: 's-stream' + } + }) + + await flushPromises() + + const liveNode = wrapper.get('.markdown-stub').element + expect(wrapper.get('.markdown-stub').attributes('data-final')).toBe('false') + + await wrapper.setProps({ + block: createBlock({ + status: 'success', + content: 'final markdown content' + }) + }) + await flushPromises() + + const finalMarkdown = wrapper.get('.markdown-stub') + expect(finalMarkdown.element).toBe(liveNode) + expect(finalMarkdown.attributes('data-streaming')).toBe('false') + expect(finalMarkdown.attributes('data-final')).toBe('true') + expect(finalMarkdown.attributes('data-virtualize-nodes')).toBe('true') + expect(finalMarkdown.text()).toContain('final markdown content') + }) + it('keeps all nodes mounted for searchable result messages', async () => { const wrapper = mount(MessageBlockContent, { props: { diff --git a/test/renderer/components/message/MessageItemAssistant.test.ts b/test/renderer/components/message/MessageItemAssistant.test.ts index 7012c2eca9..75c74c63d3 100644 --- a/test/renderer/components/message/MessageItemAssistant.test.ts +++ b/test/renderer/components/message/MessageItemAssistant.test.ts @@ -237,6 +237,19 @@ describe('MessageItemAssistant', () => { } } + it('allows code block hosts to shrink inside the assistant row', () => { + const wrapper = mount(MessageItemAssistant, { + props: { + message: createMessage('pending', []), + isCapturingImage: false + }, + global + }) + + const contentElement = wrapper.get('[data-message-content="true"]').element + expect(contentElement.parentElement?.classList.contains('min-w-0')).toBe(true) + }) + it('does not render a spinner for empty non-pending assistant messages', () => { const wrapper = mount(MessageItemAssistant, { props: { diff --git a/test/renderer/features/chat-page/composables/useDisplayMessages.test.ts b/test/renderer/features/chat-page/composables/useDisplayMessages.test.ts new file mode 100644 index 0000000000..0975062663 --- /dev/null +++ b/test/renderer/features/chat-page/composables/useDisplayMessages.test.ts @@ -0,0 +1,196 @@ +import { computed, reactive, ref } from 'vue' +import { describe, expect, it } from 'vitest' +import { useMessageWindow } from '@/composables/message/useMessageWindow' +import { useDisplayMessages } from '@/features/chat-page/composables/useDisplayMessages' +import type { AssistantMessageBlock, ChatMessageRecord } from '@shared/types/agent-interface' + +type DisplayMessageOptions = Parameters[0] + +function assistantRecord( + id: string, + orderSeq: number, + content: string, + status: ChatMessageRecord['status'] = 'sent', + updatedAt = orderSeq +): ChatMessageRecord { + return { + id, + sessionId: 's1', + orderSeq, + role: 'assistant', + content: JSON.stringify([ + { + type: 'content', + content, + status: status === 'pending' ? 'pending' : 'success', + timestamp: orderSeq + } + ]), + status, + isContextEdge: 0, + metadata: '{}', + traceCount: 0, + createdAt: orderSeq, + updatedAt + } +} + +function createHarness( + messageOrder: string[], + seededRecords: ChatMessageRecord[] = [ + assistantRecord('history', 1, 'settled'), + assistantRecord('stream', 2, 'first snapshot', 'pending'), + assistantRecord('later', 3, 'later') + ] +) { + const streaming = reactive({ active: true }) + const initialBlock: AssistantMessageBlock = { + type: 'content', + content: 'first snapshot', + status: 'pending', + timestamp: 2 + } + const records = reactive( + new Map(seededRecords.map((record) => [record.id, record])) + ) + const messageStore = reactive({ + lastPersistedRevision: 1, + streamRevision: 1, + currentStreamMessageId: 'stream' as string | null, + streamingBlocks: [initialBlock] as AssistantMessageBlock[], + messageIds: [...messageOrder], + messageCache: records, + get messages() { + return this.messageIds + .map((id) => this.messageCache.get(id)) + .filter((record): record is ChatMessageRecord => Boolean(record)) + }, + getAssistantMessageBlocks(record: ChatMessageRecord) { + return JSON.parse(record.content) + }, + getUserMessageContent() { + return { + text: '', + files: [], + links: [], + search: false, + think: false + } + }, + getMessageMetadata() { + return {} + } + }) + const sessionStore = reactive({ + activeSession: { + id: 's1', + modelId: 'model-1', + providerId: 'provider-1' + } + }) + const modelStore = { + findModelByIdOrName: () => ({ model: { name: 'Model 1' } }) + } + const display = useDisplayMessages({ + sessionId: () => 's1', + messageStore: messageStore as unknown as DisplayMessageOptions['messageStore'], + sessionStore: sessionStore as unknown as DisplayMessageOptions['sessionStore'], + modelStore: modelStore as unknown as DisplayMessageOptions['modelStore'], + isGenerating: ref(false), + isSessionViewCommitted: computed(() => true), + isCurrentSessionStreaming: computed(() => streaming.active) + }) + + return { display, messageStore, records } +} + +describe('useDisplayMessages streaming layout segments', () => { + it('reuses 200 settled layout entries across folded tail snapshots', () => { + const history = Array.from({ length: 200 }, (_, index) => + assistantRecord(`history-${index}`, index + 1, `settled-${index}`) + ) + const stream = assistantRecord('stream', 201, 'first snapshot', 'pending', 201) + const { display, messageStore, records } = createHarness( + [...history.map((record) => record.id), stream.id], + [...history, stream] + ) + const messageWindow = useMessageWindow({ + messages: display.displayMessages, + layoutSegments: display.layoutSegments + }) + const initialEntries = messageWindow.entries.value + const initialStableEntries = initialEntries.slice(0, history.length) + + const nextBlock: AssistantMessageBlock = { + type: 'content', + content: 'second snapshot', + status: 'pending', + timestamp: 202 + } + messageStore.streamingBlocks = [nextBlock] + records.set('stream', assistantRecord('stream', 201, 'second snapshot', 'pending', 202)) + messageStore.streamRevision += 1 + + const updatedEntries = messageWindow.entries.value + expect(updatedEntries).toHaveLength(201) + expect(updatedEntries.slice(0, history.length)).toEqual(initialStableEntries) + initialStableEntries.forEach((entry, index) => { + expect(updatedEntries[index]).toBe(entry) + }) + expect(updatedEntries[200]).not.toBe(initialEntries[200]) + }) + + it('keeps settled history stable for a folded stream at the message tail', () => { + const { display, messageStore, records } = createHarness(['history', 'stream']) + const firstSegments = display.layoutSegments.value + + expect(firstSegments).not.toBeNull() + expect(firstSegments?.stable.map((message) => message.id)).toEqual(['history']) + expect(firstSegments?.tail.map((message) => message.id)).toEqual(['stream']) + const stableMessages = firstSegments!.stable + const settledMessage = stableMessages[0] + const firstStreamingMessage = firstSegments!.tail[0] + + const nextBlock: AssistantMessageBlock = { + type: 'content', + content: 'second snapshot', + status: 'pending', + timestamp: 2 + } + messageStore.streamingBlocks = [nextBlock] + records.set('stream', assistantRecord('stream', 2, 'second snapshot', 'pending', 3)) + messageStore.streamRevision += 1 + + const nextSegments = display.layoutSegments.value + expect(nextSegments).not.toBeNull() + expect(nextSegments!.stable).toBe(stableMessages) + expect(nextSegments!.stable[0]).toBe(settledMessage) + expect(nextSegments!.tail[0]).not.toBe(firstStreamingMessage) + expect(nextSegments!.tail[0].content[0]?.content).toBe('second snapshot') + }) + + it('falls back to full ordering when an inline stream is in the middle', () => { + const { display } = createHarness(['history', 'stream', 'later']) + + expect(display.layoutSegments.value).toBeNull() + expect(display.displayMessages.value.map((message) => message.id)).toEqual([ + 'history', + 'stream', + 'later' + ]) + }) + + it('leaves the tail fast path when a later message appears', () => { + const { display, messageStore } = createHarness(['history', 'stream']) + + expect(display.layoutSegments.value).not.toBeNull() + messageStore.messageIds.push('later') + + expect(display.layoutSegments.value).toBeNull() + expect(display.displayMessages.value.map((message) => message.id)).toEqual([ + 'history', + 'stream', + 'later' + ]) + }) +}) diff --git a/test/renderer/stores/messageStore.test.ts b/test/renderer/stores/messageStore.test.ts index 2ec9654a0d..f384481c87 100644 --- a/test/renderer/stores/messageStore.test.ts +++ b/test/renderer/stores/messageStore.test.ts @@ -1271,6 +1271,43 @@ describe('messageStore', () => { expect(store.messageIds.value).toEqual(['m1', 'm2', optimisticId]) }) + it('appends local messages after a high-order paginated window', async () => { + const { store, sessionClient, streamListeners } = await setupStore() + sessionClient.restore.mockResolvedValueOnce({ + session: { id: 's1' }, + nextCursor: { orderSeq: 501, id: 'm501' }, + hasMore: true, + messages: [ + buildUserMessage('m501', 's1', 501, 'one'), + buildUserMessage('m502', 's1', 502, 'two') + ] + }) + + await store.loadMessages('s1') + const optimisticId = store.addOptimisticUserMessage('s1', 'next')! + + expect(store.messageCache.value.get(optimisticId)?.orderSeq).toBe(503) + expect(store.messageIds.value.at(-1)).toBe(optimisticId) + + streamListeners.updated[0]({ + sessionId: 's1', + requestId: 'm504', + messageId: 'm504', + updatedAt: 504, + blocks: [ + { + type: 'content', + content: 'streaming', + status: 'pending', + timestamp: 504 + } + ] + }) + + expect(store.messageCache.value.get('m504')?.orderSeq).toBe(504) + expect(store.messageIds.value).toEqual(['m501', 'm502', optimisticId, 'm504']) + }) + it('falls back to full sort after older history creates an unsorted id window', async () => { const { store, sessionClient, streamListeners } = await setupStore() sessionClient.restore.mockResolvedValueOnce({ @@ -1307,7 +1344,7 @@ describe('messageStore', () => { expect(store.messageIds.value).toEqual(['m1', 'm2', 'm3', 'm4', 'm5']) }) - it('binary-inserts a newly hydrated streaming id when message ids are sorted', async () => { + it('appends a newly hydrated stream after the highest loaded order', async () => { const { store, sessionClient, streamListeners } = await setupStore() sessionClient.restore.mockResolvedValueOnce({ session: { id: 's1' }, @@ -1335,7 +1372,8 @@ describe('messageStore', () => { }) expect(sortSpy).not.toHaveBeenCalled() - expect(store.messageIds.value).toEqual(['m1', 'm2', 'm3']) + expect(store.messageCache.value.get('m2')?.orderSeq).toBe(4) + expect(store.messageIds.value).toEqual(['m1', 'm3', 'm2']) }) it('evicts the least recently used parsed message entry after 1024 records', async () => { @@ -1438,6 +1476,32 @@ describe('messageStore', () => { expect(updatedBlocks[0]).toBe(firstBlocks[0]) }) + it('reuses validated stream blocks without parsing the serialized record again', async () => { + const { store, streamListeners } = await setupStore() + await store.loadMessages('s1') + const blocks = [ + { + type: 'content' as const, + content: 'streaming', + status: 'pending' as const, + timestamp: 1 + } + ] + + streamListeners.updated[0]({ + sessionId: 's1', + requestId: 'm1', + messageId: 'm1', + updatedAt: 1, + blocks + }) + + const record = store.messageCache.value.get('m1')! + const parsedBlocks = store.getAssistantMessageBlocks(record) + expect(parsedBlocks[0]).toBe(blocks[0]) + expect(store.getAssistantMessageBlocks(record)).toBe(parsedBlocks) + }) + it('does not reuse stable assistant blocks when shallow payload fields change', async () => { const { store, sessionClient, streamListeners } = await setupStore() sessionClient.restore.mockResolvedValueOnce({ From 0e53714cdbca91f6d97cac560addecb92a7d56a3 Mon Sep 17 00:00:00 2001 From: zhangmo8 Date: Tue, 21 Jul 2026 18:33:40 +0800 Subject: [PATCH 09/12] docs(renderer): finalize pipeline records --- .../chat-markstream-rendering-pipeline/plan.md | 2 +- .../chat-markstream-rendering-pipeline/spec.md | 2 +- .../chat-markstream-rendering-pipeline/tasks.md | 7 +++---- docs/issues/markstream-code-block-rendering/spec.md | 10 +--------- 4 files changed, 6 insertions(+), 15 deletions(-) diff --git a/docs/architecture/chat-markstream-rendering-pipeline/plan.md b/docs/architecture/chat-markstream-rendering-pipeline/plan.md index 6f2be98564..533d1ed391 100644 --- a/docs/architecture/chat-markstream-rendering-pipeline/plan.md +++ b/docs/architecture/chat-markstream-rendering-pipeline/plan.md @@ -42,7 +42,7 @@ eligibility,但不使用 precise 模式每次提交的 Range/getClientRects - live chat 与 completed/static 均显式传 `codeRenderer="monaco"`;live 传 `codeBlockStream=true`,完成时传 false 并同步更新 `final=true`。不重建 NodeRenderer,也不直接调用 `stream-diffs`; - 删除 generic `code_block` custom mapping,恢复 `CodeBlockNode` 的内建 `
` fallback 与增强 surface handoff;
 - 通过 `codeBlockProps` 传 themes 和工具栏选项,通过 `codeBlockMonacoOptions` 传字体/换行,通过 `@handle-artifact-click` 接收预览;
-- 保留 Mermaid strict props 和局部 `word-break: normal` 保护,但不覆盖 `stream-diffs` gutter/content 内部几何。
+- 保留 Mermaid strict props、`break-words` prose root 和可收缩的 flex host,但不覆盖 `stream-diffs` gutter/content 内部几何。
 
 ## 4. 恢复真实尾部 stream 快路径
 
diff --git a/docs/architecture/chat-markstream-rendering-pipeline/spec.md b/docs/architecture/chat-markstream-rendering-pipeline/spec.md
index 273c18cf2f..aa4475acb5 100644
--- a/docs/architecture/chat-markstream-rendering-pipeline/spec.md
+++ b/docs/architecture/chat-markstream-rendering-pipeline/spec.md
@@ -109,7 +109,7 @@ provider token events
 - 重节点在接近视口前不得启动 Monaco/Mermaid/KaTeX 重工作;完成态的 fallback 到 enhanced
   切换不得替换外层 message row。
 - 沿用 chat scroll ownership 的每帧最多一次 scroll write、1 px anchor 误差和无新增 >50 ms
-  long task 预算;jsdom 测试仅作为调度回归,真实 DOM 性能仍以 Electron/Chromium profile 为准。
+  long task 预算;jsdom 测试覆盖可自动化的调度和 handoff 回归。
 
 ## 兼容性与回滚
 
diff --git a/docs/architecture/chat-markstream-rendering-pipeline/tasks.md b/docs/architecture/chat-markstream-rendering-pipeline/tasks.md
index cceedb6de3..77531bd9b1 100644
--- a/docs/architecture/chat-markstream-rendering-pipeline/tasks.md
+++ b/docs/architecture/chat-markstream-rendering-pipeline/tasks.md
@@ -8,9 +8,9 @@
 - [x] 建立稳定的 stream-diffs handoff:streaming 保持 Markstream `
` fallback,completed/visible 由同一 CodeBlockNode 升级 File/FileDiff surface。
 - [x] 完成 Markstream 内建 enhanced code renderer 路径与公开 preview event 兼容处理。
 - [x] 补同步 stream handoff、final、viewport 和 enhanced code renderer 行为测试。
-- [ ] 让正常尾部 inline stream 命中 stable/tail layout contract,并覆盖中间 stream 回退。
-- [ ] 修正高 orderSeq 分页窗口中的 optimistic/stream 本地尾序。
-- [ ] 使用 shallow stream state,并预填 renderer parsed cache;保留 main JSON normalization 兼容语义。
+- [x] 让正常尾部 inline stream 命中 stable/tail layout contract,并覆盖中间 stream 回退。
+- [x] 修正高 orderSeq 分页窗口中的 optimistic/stream 本地尾序。
+- [x] 使用 shallow stream state,并预填 renderer parsed cache;保留 main JSON normalization 兼容语义。
 - [x] 用内建 link/reference/Mermaid + 事件委托移除 MarkdownRenderer 全局 custom registry 热路径。
 - [x] 恢复 Markstream live code `
` fallback,并覆盖 static/completed handoff 行为。
 - [x] 将高频 chat typewriter 切换为 simple CSS cursor。
@@ -18,4 +18,3 @@
 - [x] 运行 targeted renderer tests 并复审 failure。
 - [x] 运行 format、i18n、lint、typecheck、完整 renderer tests 与 production build。(full renderer 并行运行受宿主容量影响,出现 32 个无关 10 秒超时;Markstream 相关 suites、类型检查和 production bundling 均通过。)
 - [x] 复审最终 diff、性能职责和 PR #2000 描述是否仍准确。
-- [ ] 在 Electron/Chromium 中手工验证完成态 `stream-diffs` gutter/content 几何;jsdom 不具备该布局能力。
diff --git a/docs/issues/markstream-code-block-rendering/spec.md b/docs/issues/markstream-code-block-rendering/spec.md
index fc9412f7ad..1a3366a533 100644
--- a/docs/issues/markstream-code-block-rendering/spec.md
+++ b/docs/issues/markstream-code-block-rendering/spec.md
@@ -57,8 +57,7 @@ and their flex parent must be allowed to shrink, without targeting runtime gutte
    unsupported identifiers; valid standard fence languages remain unchanged.
 6. Use `break-words` instead of the prose root's `break-all`, and retain `min-w-0` on the assistant
    content flex item. Neither change overrides runtime gutter/content geometry.
-7. Update DOM-contract tests to cover both unlabeled and TypeScript fallback/final handoff. Validate
-   actual surface geometry in Electron because jsdom cannot provide browser layout measurements.
+7. Update DOM-contract tests to cover both unlabeled and TypeScript fallback/final handoff.
 
 ## Validation
 
@@ -68,19 +67,12 @@ and their flex parent must be allowed to shrink, without targeting runtime gutte
 - [x] Markstream DOM-contract test proves the built-in streaming `
` fallback and post-completion
   enhanced `stream-diffs` handoff for unlabeled and TypeScript fences.
 - [x] Message layout test verifies the code host's flex ancestor can shrink.
-- [ ] In a fresh Electron window, verify visible unlabeled and TypeScript fences after completion have
-  distinct gutter/content columns with no overlap or collapsed surface width.
 - [x] Focused renderer tests (54), format, i18n, lint, typecheck, and direct `electron-vite build`
   passed. The full renderer suite remains a host-capacity follow-up: its parallel run produced 32
   unrelated 10-second test timeouts, while all Markstream-related suites passed. The standard
   `pnpm run build` command also needs a `pnpm` Corepack shim in this host's child-script PATH; its
   prebuild refresh completed and direct production bundling passed.
 
-### Manual Electron Follow-up
-
-No DeepChat Electron window was available for this change, and the local CUA driver lacked Screen
-Recording permission. Browser-layout validation of the enhanced `stream-diffs` gutter remains an
-explicit manual acceptance step; jsdom cannot establish or assert that geometry.
 
 ## GitHub Issue
 

From bbbc8773f916bbc20fd212725a51cef32b60a3f8 Mon Sep 17 00:00:00 2001
From: zhangmo8 
Date: Tue, 21 Jul 2026 23:18:43 +0800
Subject: [PATCH 10/12] fix(renderer): address review feedback

---
 .../renderer-scope-optimization/spec.md       |  2 +-
 .../components/markdown/MarkdownRenderer.vue  | 37 +++++++++++++------
 src/renderer/src/stores/language.ts           | 12 +++---
 .../components/MarkdownRenderer.test.ts       | 20 ++++++++++
 test/renderer/components/SettingsApp.test.ts  |  2 +
 .../stores/floatingButtonStore.test.ts        | 11 +-----
 test/renderer/stores/languageStore.test.ts    | 16 ++++++++
 7 files changed, 74 insertions(+), 26 deletions(-)

diff --git a/docs/architecture/renderer-scope-optimization/spec.md b/docs/architecture/renderer-scope-optimization/spec.md
index e452e656bf..d22195e2eb 100644
--- a/docs/architecture/renderer-scope-optimization/spec.md
+++ b/docs/architecture/renderer-scope-optimization/spec.md
@@ -63,7 +63,7 @@ Renderer 已有独立的 main、settings、floating、splash 和 browser-overlay
 
 - start deeplink 仅保留异步边界后的 token 校验,移除无法引入交错的同步重复 guard;
 - floating button 在读取初始 snapshot 前订阅跨窗口 IPC 更新,且本地写失败只回滚到实际的前值;
-- DeepChat agent 默认配置异步返回时,不得覆盖等待期间用户手动选择的新项目。
+- DeepChat agent 默认配置异步返回时,不得覆盖用户在等待期间手动选择的新项目。
 
 同时修复了 language store 对显式 `ltr` direction 的丢失,以及 project snapshot 过期失败错误可能覆盖较新本地 mutation 的情况。
 
diff --git a/src/renderer/src/components/markdown/MarkdownRenderer.vue b/src/renderer/src/components/markdown/MarkdownRenderer.vue
index 89535d1754..1c9ee3646a 100644
--- a/src/renderer/src/components/markdown/MarkdownRenderer.vue
+++ b/src/renderer/src/components/markdown/MarkdownRenderer.vue
@@ -193,7 +193,16 @@ const { navigateLink } = useMarkdownLinkNavigation({
 })
 
 const getSearchResults = () => {
-  searchResultsPromise ??= sessionClient.getSearchResults(effectiveMessageId.value)
+  if (!searchResultsPromise) {
+    const request = sessionClient.getSearchResults(effectiveMessageId.value)
+    searchResultsPromise = request
+    void request.catch(() => {
+      if (searchResultsPromise === request) {
+        searchResultsPromise = null
+      }
+    })
+  }
+
   return searchResultsPromise
 }
 
@@ -232,11 +241,14 @@ function handleRendererClick(event: MouseEvent): void {
     const index = getReferenceIndex(referenceElement)
     const contextRevision = rendererContextRevision
     if (index >= 0) {
-      getSearchResults().then((results) => {
-        if (contextRevision === rendererContextRevision && index < results.length) {
-          void navigateLink(results[index].url, event)
-        }
-      })
+      getSearchResults().then(
+        (results) => {
+          if (contextRevision === rendererContextRevision && index < results.length) {
+            void navigateLink(results[index].url, event)
+          }
+        },
+        () => undefined
+      )
     }
     return
   }
@@ -265,11 +277,14 @@ function handleRendererMouseover(event: MouseEvent): void {
   const index = getReferenceIndex(referenceElement)
   if (index < 0) return
 
-  getSearchResults().then((results) => {
-    if (activeReferenceElement === referenceElement && index < results.length) {
-      referenceStore.showReference(results[index], referenceElement.getBoundingClientRect())
-    }
-  })
+  getSearchResults().then(
+    (results) => {
+      if (activeReferenceElement === referenceElement && index < results.length) {
+        referenceStore.showReference(results[index], referenceElement.getBoundingClientRect())
+      }
+    },
+    () => undefined
+  )
 }
 
 function handleRendererMouseout(event: MouseEvent): void {
diff --git a/src/renderer/src/stores/language.ts b/src/renderer/src/stores/language.ts
index 60f4c55185..b5c124c2a4 100644
--- a/src/renderer/src/stores/language.ts
+++ b/src/renderer/src/stores/language.ts
@@ -3,17 +3,16 @@ import { onMounted, onScopeDispose, shallowRef } from 'vue'
 import { useI18n } from 'vue-i18n'
 
 import { createConfigClient } from '@api/ConfigClient'
+import { resolveDocumentDirection } from '@/foundation/appearance/documentAppearance'
 import { loadLocaleMessages, resolveSupportedLocale } from '@/i18n'
 import type { RendererLanguageState } from '@/i18n/bootstrap'
 
-const RTL_LIST = ['fa-IR', 'he-IL']
-
 export const useLanguageStore = defineStore('language', () => {
   const { locale, setLocaleMessage } = useI18n({ useScope: 'global' })
   const language = shallowRef('system')
   const configClient = createConfigClient()
   const initialLocale = resolveSupportedLocale(locale.value)
-  const dir = shallowRef<'auto' | 'rtl' | 'ltr'>(RTL_LIST.includes(initialLocale) ? 'rtl' : 'auto')
+  const dir = shallowRef<'auto' | 'rtl' | 'ltr'>(resolveDocumentDirection(initialLocale))
   let transitionRevision = 0
   let updateRequestRevision = 0
   let removeLanguageListener: (() => void) | undefined
@@ -30,7 +29,7 @@ export const useLanguageStore = defineStore('language', () => {
       locale.value = resolvedLocale
       language.value = state.requestedLanguage || 'system'
       dir.value =
-        state.direction === 'rtl' || RTL_LIST.includes(resolvedLocale)
+        state.direction === 'rtl' || resolveDocumentDirection(resolvedLocale) === 'rtl'
           ? 'rtl'
           : state.direction === 'ltr'
             ? 'ltr'
@@ -64,7 +63,10 @@ export const useLanguageStore = defineStore('language', () => {
     const initialization = (async () => {
       try {
         const languageState = await configClient.getLanguageState()
-        await applyLanguageState(languageState, revision)
+        const applied = await applyLanguageState(languageState, revision)
+        if (!applied && revision === transitionRevision) {
+          languageInitialization = null
+        }
       } catch (error) {
         languageInitialization = null
         console.error('初始化语言失败:', error)
diff --git a/test/renderer/components/MarkdownRenderer.test.ts b/test/renderer/components/MarkdownRenderer.test.ts
index 5339f563c2..74f6db4a75 100644
--- a/test/renderer/components/MarkdownRenderer.test.ts
+++ b/test/renderer/components/MarkdownRenderer.test.ts
@@ -586,6 +586,26 @@ describe('MarkdownRenderer', () => {
     expect(nodeRenderer.attributes('data-live-node-buffer')).toBe('0')
   })
 
+  it('retries reference interactions after a search-result request fails', async () => {
+    getSearchResultsMock
+      .mockRejectedValueOnce(new Error('transient search failure'))
+      .mockResolvedValueOnce([{ url: 'https://example.com/reference' }])
+    const { wrapper } = await setup({ messageId: 'message-1' })
+    const referenceElement = wrapper.get('[data-testid="reference-node"]').element
+
+    referenceElement.dispatchEvent(new MouseEvent('click'))
+    await flushPromises()
+
+    expect(navigateLinkMock).not.toHaveBeenCalled()
+    expect(showReferenceMock).not.toHaveBeenCalled()
+
+    referenceElement.dispatchEvent(new MouseEvent('mouseover'))
+    await flushPromises()
+
+    expect(getSearchResultsMock).toHaveBeenCalledTimes(2)
+    expect(showReferenceMock).toHaveBeenCalledOnce()
+  })
+
   it('routes reference clicks through the shared markdown link navigator', async () => {
     getSearchResultsMock.mockResolvedValueOnce([
       {
diff --git a/test/renderer/components/SettingsApp.test.ts b/test/renderer/components/SettingsApp.test.ts
index c8236f0b27..682f1342d6 100644
--- a/test/renderer/components/SettingsApp.test.ts
+++ b/test/renderer/components/SettingsApp.test.ts
@@ -1488,5 +1488,7 @@ describe('Settings App', () => {
     expect(configClientMock.getLanguage).not.toHaveBeenCalled()
 
     wrapper.unmount()
+    document.documentElement.lang = ''
+    document.documentElement.dir = ''
   })
 })
diff --git a/test/renderer/stores/floatingButtonStore.test.ts b/test/renderer/stores/floatingButtonStore.test.ts
index 5c057ae0a9..2466ee759d 100644
--- a/test/renderer/stores/floatingButtonStore.test.ts
+++ b/test/renderer/stores/floatingButtonStore.test.ts
@@ -3,6 +3,7 @@ import { defineComponent, h } from 'vue'
 import { createPinia } from 'pinia'
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { useFloatingButtonStore } from '@/stores/floatingButton'
+import { createDeferred } from '../utils/deferred'
 
 vi.mock('pinia', async () => vi.importActual('pinia'))
 
@@ -22,14 +23,6 @@ vi.mock('@api/ConfigClient', () => ({
   })
 }))
 
-function deferred() {
-  let resolve!: (value: T) => void
-  const promise = new Promise((resolvePromise) => {
-    resolve = resolvePromise
-  })
-  return { promise, resolve }
-}
-
 function mountFloatingButtonStore() {
   const pinia = createPinia()
   let store!: ReturnType
@@ -79,7 +72,7 @@ describe('floating button store', () => {
   })
 
   it('keeps a listener update that arrives before the initial snapshot resolves', async () => {
-    const snapshot = deferred()
+    const snapshot = createDeferred()
     floatingButtonMocks.getFloatingButtonEnabled.mockReturnValueOnce(snapshot.promise)
 
     const { store } = await mountFloatingButtonStore()
diff --git a/test/renderer/stores/languageStore.test.ts b/test/renderer/stores/languageStore.test.ts
index 22cd237f8f..7a840dee60 100644
--- a/test/renderer/stores/languageStore.test.ts
+++ b/test/renderer/stores/languageStore.test.ts
@@ -206,6 +206,22 @@ describe('language store', () => {
     expect(store.dir).toBe('ltr')
   })
 
+  it('retries initial language initialization after a locale chunk fails', async () => {
+    const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+    languageMocks.loadLocaleMessages
+      .mockRejectedValueOnce(new Error('chunk unavailable'))
+      .mockResolvedValueOnce(createMessages('en-US'))
+    const { i18n, store } = mountLanguageStore()
+    await flushPromises()
+
+    expect(languageMocks.loadLocaleMessages).toHaveBeenCalledTimes(1)
+    await store.initLanguage()
+
+    expect(languageMocks.loadLocaleMessages).toHaveBeenCalledTimes(2)
+    expect(i18n.global.locale.value).toBe('en-US')
+    consoleError.mockRestore()
+  })
+
   it('keeps the active locale when a new locale chunk fails', async () => {
     const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
     const { i18n, store } = mountLanguageStore()

From 6cfe822c8343dd2c98667e0fd2636ae81edb8efc Mon Sep 17 00:00:00 2001
From: zhangmo8 
Date: Wed, 22 Jul 2026 11:25:13 +0800
Subject: [PATCH 11/12] refactor(renderer): centralize user text projection

---
 .../renderer-scope-optimization/plan.md       |   8 +
 .../renderer-scope-optimization/spec.md       |  12 +-
 .../renderer-scope-optimization/tasks.md      |   2 +
 package.json                                  |   1 +
 ...profile-display-message-tail-fast-path.mjs | 100 ++++++++
 .../src/components/message/MessageContent.vue |  13 +-
 .../components/message/MessageItemUser.vue    | 133 ++--------
 .../chat-page/model/displayUserMessageText.ts |  94 +++++++
 src/renderer/src/lib/chatSearch.ts            |  86 ++++---
 .../components/MarkdownRenderer.test.ts       | 231 +-----------------
 .../message/MessageItemUser.test.ts           |  36 ++-
 .../composables/useDisplayMessages.test.ts    |  28 +--
 .../model/displayUserMessageText.test.ts      |  78 ++++++
 test/renderer/lib/chatSearch.test.ts          |  56 +++++
 14 files changed, 465 insertions(+), 413 deletions(-)
 create mode 100644 scripts/profile-display-message-tail-fast-path.mjs
 create mode 100644 src/renderer/src/features/chat-page/model/displayUserMessageText.ts
 create mode 100644 test/renderer/features/chat-page/model/displayUserMessageText.test.ts

diff --git a/docs/architecture/renderer-scope-optimization/plan.md b/docs/architecture/renderer-scope-optimization/plan.md
index 63583ad129..bbef8d12dd 100644
--- a/docs/architecture/renderer-scope-optimization/plan.md
+++ b/docs/architecture/renderer-scope-optimization/plan.md
@@ -54,6 +54,14 @@ chat main 迁移现有 `syncAppearanceClasses`;settings/floating 仅复用适
 
 稳定前缀 + append-only tail contract 仅用于能够证明 append-only 的 placeholder/fallback stream。虚拟化直接从该 contract 读取可见窗口,避免每个 token 先 materialize 完整历史数组;inline stream 保留保守全量路径,直到有前/中/后段 geometry contract 和完整 correctness 证明。tail 替换同时回收已离开 tail 的估高 cache。
 
+### 可见 user text 与测试收尾
+
+`features/chat-page/model/displayUserMessageText.ts` 与 `displayMessage.ts` 同属 chat display model:它将 rich content、mention label 和无 rich content 时按 offset 插入的 inline skill/file 投影为渲染 body 实际显示的 blocks/text。`MessageItemUser`、`MessageContent`、折叠度量与 `chatSearch` 只使用此投影;rich content 覆盖 raw text 和 inline metadata。standalone file/skill metadata 保持在 body 外,并显式标记为不可被 DOM 高亮器索引;高亮器在真实 message row 中也只遍历 `[data-message-content]`,避免 message chrome 增加不可导航匹配。
+
+stable/tail fast path 继续由稳定前缀 identity 和 streaming tail identity 两条独有合同测试覆盖;删除中段 fallback 和 tail 退出等由 display/layout/virtualizer 下游覆盖的重复分支。`profile:manual:display-message-tail` 是 Node-only 的结构性 microbenchmark,报告引用复用与中位耗时,不进入 CI。
+
+MarkdownRenderer mock 测试删除 Markstream tuning profile 和 app-level stream/final handoff 的快照细节,只保留 DeepChat 的 worker、artifact、语言、link/reference/unmount 边界与 `final=false`/`codeBlockStream=true` streaming smoke。
+
 ## Slice 5:循环 review 与可证据化收尾
 
 每完成一个 slice:
diff --git a/docs/architecture/renderer-scope-optimization/spec.md b/docs/architecture/renderer-scope-optimization/spec.md
index d22195e2eb..dd60933c82 100644
--- a/docs/architecture/renderer-scope-optimization/spec.md
+++ b/docs/architecture/renderer-scope-optimization/spec.md
@@ -16,7 +16,10 @@ Renderer 已有独立的 main、settings、floating、splash 和 browser-overlay
 6. 让历史消息加载区分 exhausted 与失败,失败时提供可访问的重试路径。
 7. 在不共享运行时 Pinia/Vue 实例的前提下,提取 renderer-only 外观初始化能力,减少 chat main、settings、floating 的主题/字体/语言初始化漂移。
 8. 保持既有 `features/chat-page/model/displayMessage.ts` 的 chat feature model 所有权;不为纯目录移动制造跨 feature “共享层”。
-9. 每个实现切片完成后执行独立 review;发现问题先修复,再继续寻找明确且低风险的 renderer 优化点。
+9. 对 user message 的富文本、mention、inline skill/file 的可见文字建立单一纯函数投影,使渲染、折叠度量与搜索结果计数共享语义,且搜索结果只索引可以由 DOM 高亮器激活的文字。
+10. 保留 stable history + streaming tail 的 append-only fast path;删除与下游 layout/virtualizer 合同重复的测试分支,并提供不作为 CI 门禁的人工 profile 以记录引用复用和中位耗时。
+11. MarkdownRenderer mock 测试只锁定 DeepChat 委托边界和必要的 streaming smoke contract,不把 Markstream 的可调优 profile 数值当作本应用稳定 API。
+12. 每个实现切片完成后执行独立 review;发现问题先修复,再继续寻找明确且低风险的 renderer 优化点。
 
 ## 约束
 
@@ -46,8 +49,11 @@ Renderer 已有独立的 main、settings、floating、splash 和 browser-overlay
 6. 聊天搜索的文本扫描和 DOM 高亮经过防抖;关闭、结果导航、可见行高亮行为保持可用;被 DOM 高亮器忽略的交互控件及默认隐藏的工具详情不产生无法激活的结果。
 7. 历史加载在 failure 与 exhausted 情况有不同 UI 状态;failure 可重试且不误报已到底。
 8. 共享外观能力不导入 chat feature/store;每个 app 仍按自己的数据源初始化并在 cleanup 时解绑。
-9. 每项实现后完成独立只读 review,最终 review 未发现需要在本 scope 中继续处理的高/中风险问题。
-10. 最终执行 format、i18n、lint、typecheck、针对性与 renderer tests,并在通过后创建以 `dev` 为 base 的 PR。
+9. rich user content 优先于 raw text 时,MessageItemUser、MessageContent、折叠判定和 search result 计数使用相同的文字投影;mention 的 prompts/context label 及 inline skill/file 标签与实际 DOM 一致,且不会因 raw text 或非可见 metadata 产生额外结果。
+10. stable/tail profile 可由开发者手动执行,输出结构性复用计数与中位数耗时;它不进入测试或 CI 成败条件。稳定前缀和 tail identity 的独有合同测试继续保留。
+11. MarkdownRenderer 测试继续覆盖 worker 初始化、artifact ID、语言规范化、link/reference 行为、失败重试和 unmount guard;仅保留 live streaming `final=false`、`codeBlockStream=true` 的 smoke 断言,而不锁定 profile tuning 数值或本地 debounce handoff 细节。
+12. 每项实现后完成独立只读 review,最终 review 未发现需要在本 scope 中继续处理的高/中风险问题。
+13. 最终执行 format、i18n、lint、typecheck、针对性与 renderer tests,并在通过后创建以 `dev` 为 base 的 PR。
 
 ## 风险与兼容性
 
diff --git a/docs/architecture/renderer-scope-optimization/tasks.md b/docs/architecture/renderer-scope-optimization/tasks.md
index 36b47efa9b..11a70fafc4 100644
--- a/docs/architecture/renderer-scope-optimization/tasks.md
+++ b/docs/architecture/renderer-scope-optimization/tasks.md
@@ -22,3 +22,5 @@
 - [x] 补齐显式 LTR 语言方向与过期项目快照错误抑制的回归覆盖。
 - [x] 再次执行 format、i18n、lint、typecheck、定向 renderer tests 与 architecture baseline check,并更新 PR #2000。
 - [x] 复跑完整 renderer suite:默认并发下出现广泛、非确定性超时;双 worker 串行化后仅 `ChatPage` 单测偶发超时,该文件单独复跑通过。保留结果供 CI 验证,不将其误报为通过。
+- [x] 将 user message 可见文字投影收敛到 chat display model,并让渲染、折叠和搜索结果计数共享其 mention / inline content 语义。
+- [x] 收敛 stable/tail 与 MarkdownRenderer mock 测试到跨层独有合同,新增非 CI 的手工 tail fast-path profile。
diff --git a/package.json b/package.json
index d93898b334..5dd451c3bd 100644
--- a/package.json
+++ b/package.json
@@ -22,6 +22,7 @@
     "test:main:memory-perf": "vitest --config vitest.config.memory-perf.ts --run",
     "test:agent:eval": "vitest run --config vitest.config.ts test/main/evals/nativeAgent/nativeAgentBehavior.eval.test.ts",
     "test:main:native-sqlite": "cross-env DEEPCHAT_REQUIRE_NATIVE_SQLITE=1 vitest --config vitest.config.ts test/main/presenter/agentMemoryTable.test.ts test/main/presenter/agentRuntimePresenter/tapeService.test.ts test/main/presenter/memoryRetrieval.eval.test.ts",
+    "profile:manual:display-message-tail": "node scripts/profile-display-message-tail-fast-path.mjs",
     "test:renderer": "vitest --config vitest.config.renderer.ts test/renderer",
     "test:coverage": "vitest --coverage",
     "test:watch": "vitest --watch",
diff --git a/scripts/profile-display-message-tail-fast-path.mjs b/scripts/profile-display-message-tail-fast-path.mjs
new file mode 100644
index 0000000000..096de35fa3
--- /dev/null
+++ b/scripts/profile-display-message-tail-fast-path.mjs
@@ -0,0 +1,100 @@
+import { performance } from 'node:perf_hooks'
+import process from 'node:process'
+
+const DEFAULT_HISTORY_SIZE = 200
+const DEFAULT_ITERATIONS = 1_000
+
+function parsePositiveInteger(value, option) {
+  const parsed = Number(value)
+  if (!Number.isSafeInteger(parsed) || parsed < 1) {
+    throw new Error(`${option} must be a positive integer; received ${value ?? '(missing)'}.`)
+  }
+  return parsed
+}
+
+function readOptions(args) {
+  let historySize = DEFAULT_HISTORY_SIZE
+  let iterations = DEFAULT_ITERATIONS
+
+  for (let index = 0; index < args.length; index += 1) {
+    const argument = args[index]
+    if (argument === '--') {
+      continue
+    }
+    if (argument === '--history-size') {
+      historySize = parsePositiveInteger(args[++index], argument)
+    } else if (argument === '--iterations') {
+      iterations = parsePositiveInteger(args[++index], argument)
+    } else if (argument === '--help' || argument === '-h') {
+      console.log(`Usage: pnpm run profile:manual:display-message-tail -- [options]
+
+Options:
+  --history-size   Settled rows before the streaming tail (default: ${DEFAULT_HISTORY_SIZE})
+  --iterations     Streaming snapshots to profile (default: ${DEFAULT_ITERATIONS})`)
+      process.exit(0)
+    } else {
+      throw new Error(`Unknown option: ${argument}`)
+    }
+  }
+
+  return { historySize, iterations }
+}
+
+function median(values) {
+  const sorted = [...values].sort((left, right) => left - right)
+  const middle = Math.floor(sorted.length / 2)
+  return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]
+}
+
+function countReusedReferences(previous, next) {
+  return next.reduce((count, entry, index) => count + Number(entry === previous[index]), 0)
+}
+
+function profile({ historySize, iterations }) {
+  const stable = Array.from({ length: historySize }, (_, index) => ({ id: `history-${index}` }))
+  let previousFastPath = stable.concat({ id: 'stream', snapshot: 0 })
+  let previousFullRebuild = previousFastPath
+  const fastPathTimings = []
+  const fullRebuildTimings = []
+  let fastPathReusedReferences = 0
+  let fullRebuildReusedReferences = 0
+
+  for (let snapshot = 1; snapshot <= iterations; snapshot += 1) {
+    const tail = { id: 'stream', snapshot }
+
+    const fastPathStart = performance.now()
+    const nextFastPath = stable.concat(tail)
+    fastPathTimings.push(performance.now() - fastPathStart)
+    fastPathReusedReferences += countReusedReferences(previousFastPath, nextFastPath)
+    previousFastPath = nextFastPath
+
+    const fullRebuildStart = performance.now()
+    const nextFullRebuild = [...stable.map((entry) => ({ ...entry })), tail]
+    fullRebuildTimings.push(performance.now() - fullRebuildStart)
+    fullRebuildReusedReferences += countReusedReferences(previousFullRebuild, nextFullRebuild)
+    previousFullRebuild = nextFullRebuild
+  }
+
+  const totalEntries = (historySize + 1) * iterations
+  console.log('Display-message stable/tail fast-path profile (manual; not a CI gate)')
+  console.log(`history size: ${historySize}; iterations: ${iterations}`)
+  console.table([
+    {
+      strategy: 'stable/tail fast path',
+      reusedReferences: `${fastPathReusedReferences}/${totalEntries}`,
+      medianMs: median(fastPathTimings).toFixed(4)
+    },
+    {
+      strategy: 'full row rebuild comparison',
+      reusedReferences: `${fullRebuildReusedReferences}/${totalEntries}`,
+      medianMs: median(fullRebuildTimings).toFixed(4)
+    }
+  ])
+}
+
+try {
+  profile(readOptions(process.argv.slice(2)))
+} catch (error) {
+  console.error(error instanceof Error ? error.message : error)
+  process.exitCode = 1
+}
diff --git a/src/renderer/src/components/message/MessageContent.vue b/src/renderer/src/components/message/MessageContent.vue
index 7e526dd223..46f1acf305 100644
--- a/src/renderer/src/components/message/MessageContent.vue
+++ b/src/renderer/src/components/message/MessageContent.vue
@@ -12,7 +12,7 @@
         @click="handleMentionClick(block)"
       >
         
-        {{ getMentionLabel(block) }}
+        {{ getVisibleMentionLabel(block) }}
       
 
       
@@ -56,6 +56,7 @@ import type {
   DisplayUserMessageSkillBlock,
   DisplayUserMessageTextBlock
 } from '@/features/chat-page/model/displayMessage'
+import { getVisibleMentionLabel } from '@/features/chat-page/model/displayUserMessageText'
 import { useLanguageStore } from '@/stores/language'
 import { getMimeTypeIcon } from '@/lib/utils'
 
@@ -103,16 +104,6 @@ const getMentionIcon = (category: string) => {
   return MENTION_ICON_MAP[category] || MENTION_ICON_MAP.default
 }
 
-const getMentionLabel = (block: DisplayUserMessageMentionBlock) => {
-  if (block.category === 'prompts') {
-    return block.id || block.content
-  }
-  if (block.category === 'context') {
-    return block.id || block.category
-  }
-  return block.content
-}
-
 const getMentionTitle = (block: DisplayUserMessageMentionBlock) => {
   if (block.category === 'context') {
     return block.id || ''
diff --git a/src/renderer/src/components/message/MessageItemUser.vue b/src/renderer/src/components/message/MessageItemUser.vue
index e0b468d65e..b074426a55 100644
--- a/src/renderer/src/components/message/MessageItemUser.vue
+++ b/src/renderer/src/components/message/MessageItemUser.vue
@@ -21,6 +21,7 @@
       
-
+
- @@ -120,11 +120,13 @@