Skip to content

feat: multimodal-safe model switch, persisted model, composer drafts, desktop links - #145

Merged
cnjack merged 1 commit into
mainfrom
feat/multimodal-switch-web-ux
Jul 17, 2026
Merged

feat: multimodal-safe model switch, persisted model, composer drafts, desktop links#145
cnjack merged 1 commit into
mainfrom
feat/multimodal-switch-web-ux

Conversation

@cnjack

@cnjack cnjack commented Jul 17, 2026

Copy link
Copy Markdown
Owner

概述

四个 web/desktop 体验修复(相互独立但同域,故同 PR):

1. 多模态 → 文本模型切换自动过滤图片

问题:从多模态模型切到纯文本模型后,历史消息中的 image parts 原样提交,provider 400:messages.content.type 参数非法,取值范围 ['text']

修复NewChatModelFromProvider 新增 provider 参数,vision 解析顺序:显式 pc.Vision 覆盖 → registry modalities → 默认开启(未知/自定义模型保持原行为)。模型切换本就会重建 chatModel,已有的 toOpenAIMessage 剥离逻辑随之对整个历史生效。新增 RegistryModel.SupportsImageInput() 并去重 web/acp 三处 modality 循环。

2. web 运行错误美化

问题[NodeRunError] error, status code: 400, ... node path: [node_1, ChatModel] 原文直接刷在时间线。

修复:新增 model.SummarizeRunError —— 剥离 eino/go-openai 包装,输出单行摘要(API error 400: …,400 图片拒绝时附加 "this model may not support image input" 提示),原文进 WebDoneData.detail。前端透传后由 jcode-ui Message 组件渲染为摘要 + 可折叠 details。

3. 模型切换持久化到 config

问题:web/desktop 切换模型只写内存,重启回退。

修复handleSwitchModel 切换成功后按 handleSetSmallModel 的锁纪律(cfgMumu、失败回滚)写 cfg.Model + SaveConfig,与 TUI 选择器行为对齐。

4. 输入框草稿按会话记忆

新增 web/src/lib/drafts.ts(localStorage 按 session id,try/catch 防御 hardened webview),ChatInput 懒初始化恢复、会话切换 flush/恢复、发送后清除、卸载兜底。重启 app 草稿保留。

5. desktop 外链走系统浏览器

问题:desktop 内点聊天链接直接在 app webview 里导航(target=_blank 则因 Tauri 默认拒绝新窗口成为死链)。

修复initExternalLinks() 委托捕获阶段点击监听,外部 http(s) 链接经 tauri-plugin-opener 走系统浏览器(浏览器模式新开标签页);同源与 loopback(Bearer 鉴权的 sidecar API)放行。插件与 capability 已存在,零 Rust 改动。

测试

  • 新增单测:vision 推导(含显式覆盖优先级)+ 错误摘要器共 12 用例
  • go test ./... 通过(26 包)
  • golangci-lint 0 issues
  • make lint-web(tsc × 3)+ make build-web 通过

Summary by CodeRabbit

  • New Features

    • Composer text is saved as a per-session draft and restored when returning to a conversation.
    • External links now open in the system browser instead of the app window.
    • Selected model changes persist across sessions.
    • Image support is detected more accurately for configured models.
  • Bug Fixes

    • Improved provider/model selection across chat, web, workflow, and agent sessions.
    • Agent errors now show concise summaries with expandable technical details.
    • Added clearer guidance for image-related request failures.

… desktop links

Four web/desktop UX fixes:

1. Strip image parts for text-only models. NewChatModelFromProvider now
   derives the vision flag from registry modalities (explicit pc.Vision
   still wins; unknown/custom models keep the permissive default), so
   switching from a multimodal to a text-only model no longer 400s with
   "messages.content.type 参数非法". Adds RegistryModel.SupportsImageInput
   and dedups the three modality loops (web health/models, acp caps).

2. Friendlier run errors in web. SummarizeRunError unwraps eino
   NodeRunError / go-openai API errors into a one-line summary plus the
   raw detail; WebDoneData carries both and the timeline renders the
   summary with a collapsible details block (and an image-support hint
   on 400 content.type rejections).

3. Persist web/desktop model switches. handleSwitchModel now writes
   cfg.Model + SaveConfig (cfgMu discipline, rollback on failure), so a
   restart resumes the selected model — matching the TUI picker.

4. Remember composer drafts per conversation. drafts.ts persists unsent
   input keyed by session id in localStorage; ChatInput restores on
   conversation switch/remount and clears on send.

5. Open external links in the system browser on desktop. A delegated
   capture-phase click interceptor routes external http(s) anchors via
   tauri-plugin-opener (new tab in browser mode); same-origin and
   loopback sidecar links are untouched.

Tests: vision derivation + error summarizer unit tests; go test ./...
passes; golangci-lint clean; web tsc + vite build pass.
@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR makes chat-model construction provider-aware, derives vision support from static registry metadata, summarizes agent errors with optional details, persists per-session composer drafts, and routes external HTTP(S) links through the desktop opener.

Changes

Model capabilities and provider wiring

Layer / File(s) Summary
Registry capabilities and vision derivation
internal/model/registry.go, internal/model/chatmodel.go, internal/web/models.go, internal/web/server.go, internal/command/acp.go, internal/model/*vision_test.go
Static model lookup and SupportsImageInput() centralize image capability detection and derive vision defaults for known models.
Provider-aware model construction
internal/command/*.go, internal/model/factory.go, internal/tools/subagent_model_routing_test.go
Model construction paths now pass provider and model identifiers separately.
Model selection persistence
internal/web/models.go
Web model switches persist configuration and restore the previous selection when saving fails.

Run-error delivery

Layer / File(s) Summary
Error summarization and completion payload
internal/model/error_summary.go, internal/model/error_summary_test.go, internal/handler/web.go
Run errors are converted into bounded summaries with optional raw details, which are emitted in completion events.
WebSocket and client error state
web/src/lib/ws.ts, web/src/app/wsBridge.ts, web/src/app/store.ts
Completion error details are carried through WebSocket handling and stored in client system messages.

Composer draft persistence

Layer / File(s) Summary
Session draft storage and lifecycle
web/src/lib/drafts.ts, web/src/components/ChatInput.tsx
Composer text is saved per session, restored on session changes, flushed on unmount, and cleared after sending.

External link handling

Layer / File(s) Summary
External-link bootstrap routing
web/src/lib/useDesktop.ts, web/src/main.tsx
External HTTP(S) links bypass in-app navigation unless they are same-origin or loopback URLs, then open through the desktop URL opener.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ChatInput
  participant drafts
  participant localStorage
  ChatInput->>drafts: readDraft(sessionId)
  drafts->>localStorage: get session draft
  localStorage-->>drafts: draft text
  drafts-->>ChatInput: restore composer text
  ChatInput->>drafts: writeDraft(sessionId, text)
  drafts->>localStorage: save or remove session draft
Loading

Possibly related PRs

  • cnjack/jcode#47: Earlier image-support transport and UI work connected to the capability reporting changes.
  • cnjack/jcode#79: Related agent completion and cancellation error flow.
  • cnjack/jcode#103: Related provider-aware chat-model factory wiring.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 68.42% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately reflects the main changes: safer model switching, persisted model state, draft storage, and desktop link handling.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/multimodal-switch-web-ux

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/model/error_summary.go`:
- Around line 66-68: Update apiMessagePattern to enable dotall matching with
(?s) and prefix the message tag with a greedy .* so multiline messages are
supported and FindAllStringSubmatch can select the final message: occurrence in
nested errors. Preserve the existing trailing-content capture and last-match
extraction behavior.

In `@internal/web/models.go`:
- Around line 125-149: Move the eng.applyModelSwitch call to after the
configuration persistence and persistErr failure return in the model-switch
handler. Ensure it runs only after config.SaveConfig succeeds, while preserving
the existing rollback and HTTP 500 behavior for save failures.

In `@web/src/components/ChatInput.tsx`:
- Around line 308-309: Move the draftLiveRef.current assignment out of the
ChatInput render body and into a useEffect that depends on currentSessionId and
input. Keep the ref initialized with the existing initial draft value, and
ensure the effect updates both sessionId and text after commit.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5c652ab1-3154-4863-a620-7c80f2ec749d

📥 Commits

Reviewing files that changed from the base of the PR and between c2a3e49 and 9956657.

📒 Files selected for processing (21)
  • internal/command/acp.go
  • internal/command/interactive.go
  • internal/command/web.go
  • internal/command/workflow.go
  • internal/handler/web.go
  • internal/model/chatmodel.go
  • internal/model/chatmodel_vision_test.go
  • internal/model/error_summary.go
  • internal/model/error_summary_test.go
  • internal/model/factory.go
  • internal/model/registry.go
  • internal/tools/subagent_model_routing_test.go
  • internal/web/models.go
  • internal/web/server.go
  • web/src/app/store.ts
  • web/src/app/wsBridge.ts
  • web/src/components/ChatInput.tsx
  • web/src/lib/drafts.ts
  • web/src/lib/useDesktop.ts
  • web/src/lib/ws.ts
  • web/src/main.tsx

Comment on lines +66 to +68
// apiMessagePattern captures the trailing "message: …" segment produced by
// go-openai's APIError.Error(); the last occurrence wins for nested wraps.
apiMessagePattern = regexp.MustCompile(`message: (.+?)\s*$`)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Fix apiMessagePattern to support multiline messages and properly extract the last occurrence.

The current pattern message: (.+?)\s*$ has two flaws:

  1. The . wildcard does not match newlines by default in Go. If an API message spans multiple lines (e.g., formatted JSON or a bulleted list), the regex will fail entirely, causing the summary to fall back to the noisy full error text.
  2. The \s*$ anchor forces the match to the end of the text. Consequently, FindAllStringSubmatch will only ever yield a single match, meaning the "last occurrence wins for nested wraps" logic does not work as intended (it will instead capture the first message: and everything after it).

To fix both issues, use (?s) for multiline matching and prefix with a greedy .* to scan up to the last message: tag.

🐛 Proposed fix
-	// apiMessagePattern captures the trailing "message: …" segment produced by
-	// go-openai's APIError.Error(); the last occurrence wins for nested wraps.
-	apiMessagePattern = regexp.MustCompile(`message: (.+?)\s*$`)
+	// apiMessagePattern captures the trailing "message: …" segment produced by
+	// go-openai's APIError.Error(). Using (?s) supports multiline messages,
+	// and the greedy .* ensures the last occurrence wins for nested wraps.
+	apiMessagePattern = regexp.MustCompile(`(?s).*message:\s*(.*)$`)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// apiMessagePattern captures the trailing "message: …" segment produced by
// go-openai's APIError.Error(); the last occurrence wins for nested wraps.
apiMessagePattern = regexp.MustCompile(`message: (.+?)\s*$`)
// apiMessagePattern captures the trailing "message: …" segment produced by
// go-openai's APIError.Error(). Using (?s) supports multiline messages,
// and the greedy .* ensures the last occurrence wins for nested wraps.
apiMessagePattern = regexp.MustCompile(`(?s).*message:\s*(.*)$`)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/model/error_summary.go` around lines 66 - 68, Update
apiMessagePattern to enable dotall matching with (?s) and prefix the message tag
with a greedy .* so multiline messages are supported and FindAllStringSubmatch
can select the final message: occurrence in nested errors. Preserve the existing
trailing-content capture and last-match extraction behavior.

Comment thread internal/web/models.go
Comment on lines 125 to +149
eng.applyModelSwitch(ag, req.Provider, req.Model)

// Persist the selection so a restart resumes on this model — matches the
// TUI model picker, which writes cfg.Model on every switch. In-place on the
// shared cfg under cfgMu (same discipline as handleSetSmallModel).
s.cfgMu.Lock()
s.mu.Lock()
var persistErr error
if s.cfg != nil {
prevModel := s.cfg.Model
s.cfg.Model = req.Provider + "/" + req.Model
if err := config.SaveConfig(s.cfg); err != nil {
// Keep memory consistent with disk: a failed save must not leave the
// live config advertising a value that won't survive a restart.
s.cfg.Model = prevModel
persistErr = err
}
}
s.mu.Unlock()
s.cfgMu.Unlock()
if persistErr != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save config: " + persistErr.Error()})
return
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Prevent state inconsistency on config save failure.

Currently, eng.applyModelSwitch is called before persisting the configuration. If config.SaveConfig fails, the API returns an HTTP 500 error and skips the WebSocket broadcast, leading the client to believe the switch failed. However, the agent's internal state has already been updated to the new model, causing a mismatch between the UI, the config, and the active session.

Move the eng.applyModelSwitch call after the persistence block to ensure the session state is only updated if the config is successfully saved.

💡 Proposed fix
 	if err != nil {
 		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
 		return
 	}
-	eng.applyModelSwitch(ag, req.Provider, req.Model)
 
 	// Persist the selection so a restart resumes on this model — matches the
 	// TUI model picker, which writes cfg.Model on every switch. In-place on the
 	// shared cfg under cfgMu (same discipline as handleSetSmallModel).
 	s.cfgMu.Lock()
 	s.mu.Lock()
 	var persistErr error
 	if s.cfg != nil {
 		prevModel := s.cfg.Model
 		s.cfg.Model = req.Provider + "/" + req.Model
 		if err := config.SaveConfig(s.cfg); err != nil {
 			// Keep memory consistent with disk: a failed save must not leave the
 			// live config advertising a value that won't survive a restart.
 			s.cfg.Model = prevModel
 			persistErr = err
 		}
 	}
 	s.mu.Unlock()
 	s.cfgMu.Unlock()
 	if persistErr != nil {
 		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save config: " + persistErr.Error()})
 		return
 	}
+
+	eng.applyModelSwitch(ag, req.Provider, req.Model)
 
 	// Track in recent models.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
eng.applyModelSwitch(ag, req.Provider, req.Model)
// Persist the selection so a restart resumes on this model — matches the
// TUI model picker, which writes cfg.Model on every switch. In-place on the
// shared cfg under cfgMu (same discipline as handleSetSmallModel).
s.cfgMu.Lock()
s.mu.Lock()
var persistErr error
if s.cfg != nil {
prevModel := s.cfg.Model
s.cfg.Model = req.Provider + "/" + req.Model
if err := config.SaveConfig(s.cfg); err != nil {
// Keep memory consistent with disk: a failed save must not leave the
// live config advertising a value that won't survive a restart.
s.cfg.Model = prevModel
persistErr = err
}
}
s.mu.Unlock()
s.cfgMu.Unlock()
if persistErr != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save config: " + persistErr.Error()})
return
}
// Persist the selection so a restart resumes on this model — matches the
// TUI model picker, which writes cfg.Model on every switch. In-place on the
// shared cfg under cfgMu (same discipline as handleSetSmallModel).
s.cfgMu.Lock()
s.mu.Lock()
var persistErr error
if s.cfg != nil {
prevModel := s.cfg.Model
s.cfg.Model = req.Provider + "/" + req.Model
if err := config.SaveConfig(s.cfg); err != nil {
// Keep memory consistent with disk: a failed save must not leave the
// live config advertising a value that won't survive a restart.
s.cfg.Model = prevModel
persistErr = err
}
}
s.mu.Unlock()
s.cfgMu.Unlock()
if persistErr != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save config: " + persistErr.Error()})
return
}
eng.applyModelSwitch(ag, req.Provider, req.Model)
// Track in recent models.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/web/models.go` around lines 125 - 149, Move the eng.applyModelSwitch
call to after the configuration persistence and persistErr failure return in the
model-switch handler. Ensure it runs only after config.SaveConfig succeeds,
while preserving the existing rollback and HTTP 500 behavior for save failures.

Comment on lines +308 to +309
const draftLiveRef = useRef({ sessionId: currentSessionId, text: input })
draftLiveRef.current = { sessionId: currentSessionId, text: input }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Avoid mutating refs during the render phase.

React requires render functions to be pure. Mutating draftLiveRef.current directly in the component body can cause unpredictable behavior in Strict Mode or Concurrent React if a render is aborted or restarted. Wrap the assignment in a useEffect to ensure it only mutates after the DOM is committed.

♻️ Proposed fix
   const draftLiveRef = useRef({ sessionId: currentSessionId, text: input })
-  draftLiveRef.current = { sessionId: currentSessionId, text: input }
+  useEffect(() => {
+    draftLiveRef.current = { sessionId: currentSessionId, text: input }
+  }, [currentSessionId, input])
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const draftLiveRef = useRef({ sessionId: currentSessionId, text: input })
draftLiveRef.current = { sessionId: currentSessionId, text: input }
const draftLiveRef = useRef({ sessionId: currentSessionId, text: input })
useEffect(() => {
draftLiveRef.current = { sessionId: currentSessionId, text: input }
}, [currentSessionId, input])
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/components/ChatInput.tsx` around lines 308 - 309, Move the
draftLiveRef.current assignment out of the ChatInput render body and into a
useEffect that depends on currentSessionId and input. Keep the ref initialized
with the existing initial draft value, and ensure the effect updates both
sessionId and text after commit.

@cnjack
cnjack merged commit 6bb025a into main Jul 17, 2026
3 checks passed
@cnjack
cnjack deleted the feat/multimodal-switch-web-ux branch July 17, 2026 01:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant