Improve Windows setup, Muti-model extension, API auth, sessions, and project recovery (#154)
Some checks failed
Lint / lint (push) Has been cancelled
* Improve Windows setup, API auth, sessions, and project recovery
* Restore recent project on startup
* Extract PDF attachment text with MuPDF
* Add OpenAI-compatible provider mode
* Add tool loop for OpenAI-compatible providers
* Stream OpenAI-compatible provider responses
* Fallback when compatible providers reject tools
* Surface compatible provider reasoning content
* Support legacy function calls for compatible providers
* Add OpenAI-compatible provider presets
* Add planning and listing tools for compatible providers
* Show direct provider model in chat composer
* Expose direct provider status fields
* Add direct provider task compatibility tools
* Persist direct provider task state
* Read PDF sidecars in direct provider
* Create PDF sidecars for project attachments
* Sanitize direct provider reasoning history
* Allow API provider setup without Claude CLI
* Repair auth settings when saving API keys
* Preserve DeepSeek reasoning history for direct provider
* Restore direct provider reasoning from sessions
* Parse compatible provider token usage
* Keep direct provider sessions consistent
* Pair cancelled direct tool calls
* Verify compatible provider keys before saving
* Support ModelGate Claude proxy setup
* Reject mismatched ModelGate proxy URLs
* Allow changing configured AI provider
* Expose AI provider management when ready
* Replace provider dropdowns with setup cards
* Support multiple direct provider credentials
* Fix provider credential string normalization
* Show provider logos in model selectors
* Add Anthropic and Zhipu provider logos
* Simplify OpenAI-compatible provider presets
* Derive provider labels from configured endpoints
* Remove custom Claude proxy preset
* Support chat provider and model switching
* Use Anthropic icon for Claude Code selector
* Add provider setup from chat picker
* Prevent provider dialog overflow
* Expand direct provider tool compatibility
* Bring direct provider runtime closer to Claude Code
* Raise Rust macro recursion limit
* Show direct provider API errors
* Force final answer after direct provider tool loops
* feat: add OpenAI-compatible provider support and polish desktop UX
- Add project-level OpenAI-compatible provider configuration for Qwen, DeepSeek, GLM, Gemini, Moonshot, and OpenAI
- Support provider/model switching inside the chat composer
- Preserve chat continuity when switching between direct providers
- Bridge direct-provider conversation context when switching back to Claude Code
- Improve direct-provider tool compatibility, Python/uv context, skills behavior, and PDF text extraction
- Replace app icons with the new ClaudePrism prism icon
- Improve project naming, rename handling, recent project/session migration, and session deletion UX
- Polish provider icons, chat error spacing, uv dialog layout, and light-mode native title bar
* feat: add OpenAI-compatible provider support and polish desktop UX
- Add project-level OpenAI-compatible provider configuration for Qwen, DeepSeek, GLM, Gemini, Moonshot, and OpenAI
- Support provider/model switching inside the chat composer
- Preserve chat continuity when switching between direct providers
- Bridge direct-provider conversation context when switching back to Claude Code
- Improve direct-provider tool compatibility, Python/uv context, skills behavior, and PDF text extraction
- Replace app icons with the new ClaudePrism prism icon
- Improve project naming, rename handling, recent project/session migration, and session deletion UX
- Polish provider icons, chat error spacing, uv dialog layout, and light-mode native title bar
* fix: resolve Dependabot security vulnerabilities
NPM (via pnpm.overrides):
- ws 8.19.0 → 8.21.0 (uninitialized memory disclosure)
- postcss 8.5.6 → 8.5.15 (XSS via unescaped style tags)
- uuid 10.0.0 → 14.0.0 (missing buffer bounds check)
Cargo (via tectonic 0.15 → 0.16 + cargo update):
- rand 0.8.5 → 0.8.6, 0.9.2 → 0.9.4 (unsound with custom logger)
- gix-validate 0.7.7 → 0.10.1 (submodule name validation bypass)
- gix-fs 0.18.2 remains at 0.18.x (upstream constraint, cannot
reach 0.21.1 until watchexec/ignore-files updates gix deps)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: bump Linux vcpkg cache key for tectonic 0.16 compat
Force fresh vcpkg rebuild on Linux CI to resolve graphite2
gr_label_destroy linker error after tectonic 0.15 → 0.16 upgrade.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use static vcpkg triplet for Linux build
The default x64-linux triplet builds dynamic libraries, causing
graphite2's gr_label_destroy symbol to be hidden and unlinkable
after tectonic 0.15 → 0.16 upgrade.
Switch to a custom x64-linux-static triplet (static library linkage)
to match the approach used by macOS and Windows builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: set VCPKGRS_TRIPLET for Linux static build
tectonic's vcpkg dep_support looks for packages under the default
x64-linux triplet, but we install them under x64-linux-static.
Set VCPKGRS_TRIPLET=x64-linux-static so the Rust vcpkg crate
finds libpng and other dependencies in the correct location.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: revert Linux to x64-linux vcpkg triplet and add system graphite2
Revert the custom static triplet (didn't fix the issue) and instead
install libgraphite2-dev via apt. The vcpkg-built graphite2 marks
gr_label_destroy as a hidden symbol, but tectonic 0.16's bridge
crate references it. The system graphite2 package exports it properly.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Expand provider support and refine desktop UX
Add OpenAI-compatible provider workflows, improve Claude/provider switching behavior, refresh desktop window theming and app icons, smooth PDF toolbar and touchpad zoom handling, and add Windows-friendly build scripts.
* fix: revert tectonic to 0.15 to fix Linux build
tectonic 0.16 causes undefined hidden symbol gr_label_destroy on
Linux due to graphite2 visibility issues in vcpkg builds. Revert to
tectonic 0.15 which works correctly with the existing vcpkg setup.
Security fixes retained:
- rand 0.8.5 → 0.8.6, 0.9.2 → 0.9.4
Workflow: revert to x64-linux triplet with libgraphite2-dev, fresh
cache key v3.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: update render scale debounce time and enhance zoom anchor functionality in PDF viewer
* fix: revert tectonic to 0.15, keep tauri 2.11.1
Previous commit reverted Cargo.lock too far, bringing tauri back to
2.10.3 which mismatches NPM @tauri-apps/api 2.11.0. This commit
keeps tauri at 2.11.1 while reverting tectonic to 0.15.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: update tauri plugin crates to match NPM packages
tauri-plugin-dialog 2.6.0 → 2.7.1, tauri-plugin-fs 2.4.5 → 2.5.1
to match their NPM counterparts and fix version mismatch build error.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: restore Linux vcpkg cache key to v1
The graphite2 hidden symbol issue is caused by fresh vcpkg builds,
not by tectonic version. The old v1 cache had a working graphite2
build. Restore the cache key to reuse it.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: remove vcpkg graphite2 to use system library on Linux
vcpkg builds graphite2 with hidden symbol visibility, causing
undefined symbol errors at link time. Remove vcpkg's graphite2
before building so the linker falls back to the system
libgraphite2-dev which exports all symbols properly.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: symlink system graphite2 into vcpkg path on Linux
tectonic's dep_support requires graphite2 at the vcpkg path.
Replace vcpkg's hidden-visibility graphite2 with symlinks to
the system libgraphite2 (from libgraphite2-dev) which exports
all symbols properly.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use objcopy to globalize hidden graphite2 symbols on Linux
vcpkg builds graphite2 with -fvisibility=hidden, making gr_* API
symbols invisible to the linker. Use objcopy --globalize-symbol to
make them visible again before the Tauri build.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: extract .a archive before objcopy to fix graphite2 symbols
objcopy cannot modify symbols inside .a archives directly. Extract
individual .o files with ar, globalize gr_* symbols in each, then
repack the archive.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: avoid process substitution in objcopy graphite2 fix
Use a temp file for symbol list instead of <() process substitution,
which fails in GitHub Actions shell. Also add explicit shell: bash.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: prevent grep exit code 1 from aborting objcopy script
grep returns exit 1 when no match found, which aborts the script
under set -e. Wrap with { ... || true; } to prevent this.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use ld.bfd instead of lld for Linux build
rust-lld treats hidden symbol references as hard errors, but ld.bfd
resolves them. vcpkg's graphite2 has STV_HIDDEN on gr_* symbols which
lld refuses to link. Switch to ld.bfd via RUSTFLAGS.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Address provider security review and desktop UX regressions
* Polish workspace toolbar alignment
* Improve chat session recovery and titles
* Improve direct provider parity and session UX
Add Claude Code-style context compaction for OpenAI-compatible direct providers, including no-tools summaries and retry handling.
Generate cached model-backed session titles and auto-resume the latest project chat without delaying on title generation.
Polish editor search theming and keyboard navigation.
* feat(provider): run OpenAI-compatible models through Claude Code proxy
Replace the hand-written direct-provider runtime with a local Anthropic-to-OpenAI-compatible proxy so Qwen, DeepSeek, GLM, Gemini, and other compatible providers use the Claude Code CLI tool/session experience.
Normalize strict OpenAI tool_call/tool_result pairing to avoid provider 400 errors, keep session titles stable and synced, allow switching sessions while another chat is streaming, show running indicators, add provider deletion with confirmation, and auto-refresh the file sidebar with refresh feedback.
* feat(provider): run OpenAI-compatible models through Claude Code proxy
Replace the hand-written direct-provider runtime with a local Anthropic-to-OpenAI-compatible proxy so Qwen, DeepSeek, GLM, Gemini, and other compatible providers use the Claude Code CLI tool/session experience.
Normalize strict OpenAI tool_call/tool_result pairing to avoid provider 400 errors, keep session titles stable and synced, allow switching sessions while another chat is streaming, show running indicators, add provider deletion with confirmation, and auto-refresh the file sidebar with refresh feedback.
* fix(chat): enforce minimum composer drawer height
* feat(provider): add Ollama local model support
* feat(provider): add Ollama local model support
* chore: update version to 1.2.0 in package.json and Cargo files
* fix(BashWidget): update styles for terminal and result display
* feat(MarkdownRenderer): enhance table and horizontal rule rendering
* feat: stream compatible providers through Claude proxy
Stream OpenAI-compatible provider responses through the local Anthropic proxy instead of buffering full responses. Map reasoning and tool-call deltas into Claude Code-compatible SSE events, and cache generated session titles so opening history does not regenerate them.
* feat: align compatible proxy with Claude Code Router
Keep OpenAI-compatible providers on the native Claude Code pipeline while moving proxy transforms into dedicated modules. Add CCR-style message, provider, stream, and tool handling for thinking signatures, ExitTool fallback, streamed tool argument repair, cache cleanup, stream usage, and provider token compatibility.
* chore: bump version to 1.3.0 across all relevant files
* feat(chat): support queued guidance during active runs
Extract Claude CLI process lifecycle management into a dedicated claude_process module and expose an interrupt command for guided continuations.
Add per-tab queued guidance state so users can type follow-up direction while a response is streaming, force a selected guidance item into the next turn, and preserve the tab's provider when resuming.
Render pending guidance inline in the conversation, keep the composer send/stop behavior ergonomic during streaming, allow individual guidance removal, and polish the compact guidance row spacing and hover states.
Cover the queue, display, consume, and forced-guidance flows with store tests.
* fix(chat): allow multiple guided follow-ups
Keep guided follow-ups additive when users select multiple queued items during an active run instead of replacing the previously displayed guidance.
Prefer already-displayed guidance when continuing the next turn, preserve the first forced guidance item, and keep the forced queue state consistent when entries are removed or interrupted.
Add store coverage for displaying multiple guidance entries and consuming displayed guidance before ordinary queued items.
* fix(chat): interrupt active runs for guided follow-ups
Terminate the current Claude process on Windows when a guided follow-up is selected so queued guidance can take effect immediately instead of waiting for the active run to finish naturally.
Handle forced queued guidance before snapshot and file refresh work in the completion event, then resume the same tab with the preserved provider context.
* feat(messages): enhance handling of tool result images in OpenAI requests
* feat: add provider model capability metadata
Return OpenAI-compatible model metadata from the backend and preserve provider model ordering.
Add chat/vision capability badges, metadata-driven model filtering, Qwen vision fallbacks, and tests.
Keep pasted chat images as temporary context and block image sends for text-only provider models.
* fix: guard unsupported proxy image payloads
Send Anthropic image blocks as OpenAI image_url parts with high detail.
Detect endpoints known to reject OpenAI-style image parts and fail early with a clear provider error.
* feat: add multi-select file tree actions
Preserve app context menus while suppressing the browser menu elsewhere.
Support Ctrl/Cmd multi-select, batch drag moves, Delete/Backspace deletion, and a themed confirmation dialog for file tree items.
* fix: disable global webview zoom shortcuts
Disable Tauri zoom hotkeys for normal and debug windows.
Reset stale persisted app zoom on startup and consume Ctrl/Cmd zoom shortcuts without changing the whole webview scale.
* feat: implement cleanup for temporary files in chat composer and event hooks
* feat: enhance message handling to sanitize stored user prompts and improve context display
* refactor: clean up formatting and improve regex matching in chat store
* fix: restore smooth PDF touchpad and wheel zoom
Enable WebView2 pinch gestures by allowing zoom hotkeys at the native layer, while keeping app-level zoom shortcuts guarded in the frontend.
Rework PDF preview zoom handling to distinguish touchpad pinch from Ctrl/Cmd mouse-wheel zoom, preserve the cursor anchor, and make mouse wheel zoom respond on the first notch.
* refactor: remove PDF sidecar extraction and related logic from project attachments
* feat(providers): align compatible models with Claude Code runtime
Route DeepSeek and Qwen through native Anthropic-compatible Claude Code endpoints, including canonical base URL handling and model environment overrides.
Add CCR-style proxy transformer plumbing for remaining OpenAI-compatible providers and harden tool argument repair in the local Anthropic proxy.
Preserve per-session provider behavior, improve stop/guidance flow handling, and update setup/chat store tests for provider routing.
* feat(providers): route Kimi through Claude Code
Use Moonshot/Kimi Anthropic-compatible endpoints for the native Claude Code runtime.
Canonicalize legacy Moonshot OpenAI URLs to /anthropic while keeping model discovery on the provider's /v1/models endpoint.
Add setup-store and Rust route coverage for domestic and international Moonshot endpoints.
* fix(security): harden Claude Code provider routing
Remove the global Claude Code permission bypass so native Anthropic provider runs rely on Claude Code's normal approval model.
Clear inherited ANTHROPIC_* variables before proxy or native provider launches to prevent credential and endpoint leakage across routes.
Require HTTPS for known cloud provider endpoints, tighten DeepSeek proxy matching, and harden manual code-block shell execution with cwd validation, timeouts, and output limits.
* feat(pdf-viewer): enhance zoom functionality and add permission skip option
* feat(stream): improve error handling for provider stream and add SSE error rendering
* fix: align Kimi Claude Code native route
* feat(layout): enhance workspace components with responsive design adjustments
* refactor(tests): normalize recent project paths and improve readability
refactor(proposed-changes-panel): simplify JSX structure for added/removed indicators
refactor(pdf-viewer): format function parameters for better readability
fix(project-name): enhance validation for project names to prevent invalid characters
refactor(build): improve environment flag handling for better clarity
refactor(dev): streamline environment flag appending logic
* refactor(tests): update file paths in project and chat prompt tests for consistency
* feat(install): update install_claude_cli to return success status and handle timeout errors
* feat(skills): add timeout handling for skills installation and improve download logic
* feat(onboarding): add close button to the dialog in scientific skills onboarding
* feat: enhance skills management with import and delete functionality
- Added `import_skill_from_folder` command to import skills from a local directory.
- Implemented `delete_installed_skill` command to remove specific skills.
- Updated the skills installation process to provide detailed download progress.
- Refactored skills loading and management logic for better organization and efficiency.
- Enhanced the UI to support skill importing and deletion with appropriate user feedback.
* feat: enhance skills downloading and proxy configuration
* Add workspace layout controls
* Keep PDF preview for non-TeX selections
* Refine project picker experience
* Polish sidebar collapse animations
* Polish home and workspace interactions
* feat: enhance Claude setup with embedded variant and error handling
- Updated `useClaudeSetupStore` to require Claude Code installation before saving provider credentials.
- Modified `ClaudeSetup` component to support an embedded variant with improved UI for installation and configuration.
- Added detailed status messages for missing Git and installation prompts.
- Refactored project picker to include theme toggle and version display.
- Improved layout and styling for settings and environment status sections.
* feat: enhance anthropic response handling to include "thinking" block type
* Add required environment onboarding
* Polish environment onboarding completion flow
* Fix chat provider and attachment interactions
* Scope chat sessions by project
* feat: add file attachment functionality and improve message actions in chat
* style: update ChatComposer component styles for improved UI consistency
---------
Co-authored-by: Hanjin Bae <iwdhanjin@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Hanjin Bae <82585834+delibae@users.noreply.github.com>
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@claude-prism/desktop",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 40 KiB |
82
apps/desktop/src-tauri/Cargo.lock
generated
|
|
@ -502,7 +502,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "claude-prism-desktop"
|
||||
version = "1.1.7"
|
||||
version = "1.3.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
|
|
@ -532,7 +532,7 @@ dependencies = [
|
|||
"tokio",
|
||||
"uuid",
|
||||
"which 7.0.3",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
"winreg 0.55.0",
|
||||
]
|
||||
|
||||
|
|
@ -2172,9 +2172,11 @@ dependencies = [
|
|||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2 0.5.10",
|
||||
"system-configuration 0.7.0",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4054,7 +4056,7 @@ dependencies = [
|
|||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper 0.1.2",
|
||||
"system-configuration",
|
||||
"system-configuration 0.5.1",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tower-service",
|
||||
|
|
@ -4074,6 +4076,7 @@ dependencies = [
|
|||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http 1.4.2",
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
|
|
@ -4093,12 +4096,14 @@ dependencies = [
|
|||
"sync_wrapper 1.0.2",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams 0.4.2",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
|
@ -4138,7 +4143,7 @@ dependencies = [
|
|||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"wasm-streams 0.5.0",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
|
|
@ -4965,7 +4970,18 @@ checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7"
|
|||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"core-foundation 0.9.4",
|
||||
"system-configuration-sys",
|
||||
"system-configuration-sys 0.5.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"core-foundation 0.9.4",
|
||||
"system-configuration-sys 0.6.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4978,6 +4994,16 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration-sys"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-deps"
|
||||
version = "6.2.2"
|
||||
|
|
@ -6520,6 +6546,19 @@ dependencies = [
|
|||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.5.0"
|
||||
|
|
@ -6860,8 +6899,8 @@ dependencies = [
|
|||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link 0.1.3",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
"windows-result 0.3.4",
|
||||
"windows-strings 0.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -6919,6 +6958,17 @@ dependencies = [
|
|||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.3.4"
|
||||
|
|
@ -6928,6 +6978,15 @@ dependencies = [
|
|||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.4.2"
|
||||
|
|
@ -6937,6 +6996,15 @@ dependencies = [
|
|||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.45.0"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "claude-prism-desktop"
|
||||
version = "1.2.0"
|
||||
version = "1.3.0"
|
||||
description = "AI-powered LaTeX writing workspace"
|
||||
edition = "2021"
|
||||
|
||||
|
|
@ -23,7 +23,7 @@ serde_json = "1"
|
|||
serde_yaml = "0.9"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
dirs = "5"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "system-proxy", "socks"] }
|
||||
hmac = "0.12"
|
||||
sha1 = "0.10"
|
||||
base64 = "0.22"
|
||||
|
|
@ -54,7 +54,7 @@ objc2-foundation = { version = "0.3", features = ["NSData"] }
|
|||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
winreg = "0.55"
|
||||
windows-sys = { version = "0.52", features = ["Win32_System_Environment"] }
|
||||
windows-sys = { version = "0.61", features = ["Win32_System_Environment"] }
|
||||
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tauri-plugin-updater = "2"
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 8 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.8 KiB |
BIN
apps/desktop/src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
apps/desktop/src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
apps/desktop/src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
apps/desktop/src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
apps/desktop/src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
apps/desktop/src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
apps/desktop/src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
apps/desktop/src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 3 KiB |
BIN
apps/desktop/src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
apps/desktop/src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
apps/desktop/src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
BIN
apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 196 KiB |
BIN
apps/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 957 B |
BIN
apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
apps/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
apps/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
apps/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
apps/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
apps/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
apps/desktop/src-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 778 KiB |
BIN
apps/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
apps/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
apps/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
apps/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
apps/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
614
apps/desktop/src-tauri/src/anthropic_proxy.rs
Normal file
|
|
@ -0,0 +1,614 @@
|
|||
mod messages;
|
||||
mod providers;
|
||||
mod stream;
|
||||
mod tools;
|
||||
mod transformers;
|
||||
|
||||
use self::messages::{anthropic_to_openai_request, openai_to_anthropic_message};
|
||||
use self::providers::apply_provider_request_transforms;
|
||||
use self::stream::{sse_response, stream_openai_sse_to_anthropic};
|
||||
use self::transformers::ProxyTransformerChain;
|
||||
use serde_json::{json, Value};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct OpenAiProxyCredential {
|
||||
pub(crate) api_key: String,
|
||||
pub(crate) base_url: String,
|
||||
pub(crate) model: String,
|
||||
pub(crate) transformers: Vec<String>,
|
||||
pub(crate) model_transformers: Vec<String>,
|
||||
}
|
||||
|
||||
pub(crate) async fn start_openai_anthropic_proxy(
|
||||
credential: OpenAiProxyCredential,
|
||||
) -> Result<String, String> {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0))
|
||||
.await
|
||||
.map_err(|err| format!("Failed to start local provider proxy: {}", err))?;
|
||||
let addr = listener
|
||||
.local_addr()
|
||||
.map_err(|err| format!("Failed to read local provider proxy address: {}", err))?;
|
||||
let credential = Arc::new(credential);
|
||||
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((stream, _)) = listener.accept().await else {
|
||||
break;
|
||||
};
|
||||
let credential = Arc::clone(&credential);
|
||||
tokio::spawn(async move {
|
||||
if let Err(err) = handle_connection(stream, credential).await {
|
||||
eprintln!("[anthropic-proxy] request failed: {}", err);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Ok(format!("http://{}", addr))
|
||||
}
|
||||
|
||||
async fn handle_connection(
|
||||
mut stream: TcpStream,
|
||||
credential: Arc<OpenAiProxyCredential>,
|
||||
) -> Result<(), String> {
|
||||
let request = read_http_request(&mut stream).await?;
|
||||
let path = request_path_without_query(&request.path);
|
||||
if request.method == "POST" && is_messages_path(path) {
|
||||
match handle_messages_to_stream(&request, &credential, &mut stream).await {
|
||||
Ok(()) => {
|
||||
let _ = stream.shutdown().await;
|
||||
return Ok(());
|
||||
}
|
||||
Err(err) => {
|
||||
let response = json_response(
|
||||
502,
|
||||
&json!({
|
||||
"type": "error",
|
||||
"error": {
|
||||
"type": "api_error",
|
||||
"message": err,
|
||||
},
|
||||
}),
|
||||
);
|
||||
stream
|
||||
.write_all(response.as_bytes())
|
||||
.await
|
||||
.map_err(|err| format!("Failed to write proxy error response: {}", err))?;
|
||||
let _ = stream.shutdown().await;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let response = route_request(&request).await;
|
||||
stream
|
||||
.write_all(response.as_bytes())
|
||||
.await
|
||||
.map_err(|err| format!("Failed to write proxy response: {}", err))?;
|
||||
let _ = stream.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct HttpRequest {
|
||||
method: String,
|
||||
path: String,
|
||||
body: Vec<u8>,
|
||||
}
|
||||
|
||||
async fn read_http_request(stream: &mut TcpStream) -> Result<HttpRequest, String> {
|
||||
let mut buffer = Vec::new();
|
||||
let mut temp = [0_u8; 8192];
|
||||
let header_end = loop {
|
||||
let n = stream
|
||||
.read(&mut temp)
|
||||
.await
|
||||
.map_err(|err| format!("Failed to read proxy request: {}", err))?;
|
||||
if n == 0 {
|
||||
return Err("Connection closed before HTTP headers were received".to_string());
|
||||
}
|
||||
buffer.extend_from_slice(&temp[..n]);
|
||||
if let Some(index) = find_header_end(&buffer) {
|
||||
break index;
|
||||
}
|
||||
if buffer.len() > 1024 * 1024 {
|
||||
return Err("Proxy request headers are too large".to_string());
|
||||
}
|
||||
};
|
||||
|
||||
let header_text = String::from_utf8_lossy(&buffer[..header_end]);
|
||||
let mut lines = header_text.lines();
|
||||
let request_line = lines
|
||||
.next()
|
||||
.ok_or_else(|| "Proxy request is missing request line".to_string())?;
|
||||
let mut request_parts = request_line.split_whitespace();
|
||||
let method = request_parts
|
||||
.next()
|
||||
.ok_or_else(|| "Proxy request is missing method".to_string())?
|
||||
.to_string();
|
||||
let path = request_parts
|
||||
.next()
|
||||
.ok_or_else(|| "Proxy request is missing path".to_string())?
|
||||
.to_string();
|
||||
|
||||
let content_length = lines
|
||||
.filter_map(|line| line.split_once(':'))
|
||||
.find(|(key, _)| key.eq_ignore_ascii_case("content-length"))
|
||||
.and_then(|(_, value)| value.trim().parse::<usize>().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
let body_start = header_end + 4;
|
||||
let mut body = buffer.get(body_start..).unwrap_or_default().to_vec();
|
||||
while body.len() < content_length {
|
||||
let n = stream
|
||||
.read(&mut temp)
|
||||
.await
|
||||
.map_err(|err| format!("Failed to read proxy request body: {}", err))?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
body.extend_from_slice(&temp[..n]);
|
||||
}
|
||||
body.truncate(content_length);
|
||||
|
||||
Ok(HttpRequest { method, path, body })
|
||||
}
|
||||
|
||||
fn find_header_end(buffer: &[u8]) -> Option<usize> {
|
||||
buffer.windows(4).position(|window| window == b"\r\n\r\n")
|
||||
}
|
||||
|
||||
async fn route_request(request: &HttpRequest) -> String {
|
||||
let path = request_path_without_query(&request.path);
|
||||
|
||||
if request.method == "GET" && path == "/" {
|
||||
return json_response(
|
||||
200,
|
||||
&json!({ "ok": true, "service": "claude-prism-anthropic-proxy" }),
|
||||
);
|
||||
}
|
||||
|
||||
if request.method == "POST" && is_count_tokens_path(path) {
|
||||
return handle_count_tokens(request);
|
||||
}
|
||||
|
||||
json_response(
|
||||
400,
|
||||
&json!({
|
||||
"type": "error",
|
||||
"error": {
|
||||
"type": "invalid_request_error",
|
||||
"message": format!("Unsupported Anthropic proxy endpoint: {} {}", request.method, request.path),
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn request_path_without_query(path: &str) -> &str {
|
||||
path.split_once('?').map(|(path, _)| path).unwrap_or(path)
|
||||
}
|
||||
|
||||
fn is_count_tokens_path(path: &str) -> bool {
|
||||
path.ends_with("/count_tokens")
|
||||
}
|
||||
|
||||
fn is_messages_path(path: &str) -> bool {
|
||||
path.ends_with("/messages")
|
||||
}
|
||||
|
||||
fn handle_count_tokens(request: &HttpRequest) -> String {
|
||||
let body = serde_json::from_slice::<Value>(&request.body).unwrap_or(Value::Null);
|
||||
let approx_chars = body.to_string().chars().count();
|
||||
json_response(
|
||||
200,
|
||||
&json!({
|
||||
"input_tokens": (approx_chars / 4).max(1),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async fn handle_messages_to_stream(
|
||||
request: &HttpRequest,
|
||||
credential: &OpenAiProxyCredential,
|
||||
stream: &mut TcpStream,
|
||||
) -> Result<(), String> {
|
||||
let anthropic_request: Value = serde_json::from_slice(&request.body)
|
||||
.map_err(|err| format!("Claude Code sent invalid Anthropic JSON: {}", err))?;
|
||||
let wants_stream = anthropic_request
|
||||
.get("stream")
|
||||
.and_then(|value| value.as_bool())
|
||||
.unwrap_or(false);
|
||||
let transformers = ProxyTransformerChain::for_credential(credential, wants_stream);
|
||||
let mut openai_request =
|
||||
anthropic_to_openai_request(&anthropic_request, credential, &transformers)?;
|
||||
openai_request["stream"] = Value::Bool(wants_stream);
|
||||
apply_provider_request_transforms(
|
||||
&mut openai_request,
|
||||
&anthropic_request,
|
||||
credential,
|
||||
wants_stream,
|
||||
&transformers,
|
||||
);
|
||||
if request_contains_openai_image_parts(&openai_request)
|
||||
&& provider_rejects_openai_image_parts(credential)
|
||||
{
|
||||
return Err(format!(
|
||||
"{} does not accept OpenAI-style image_url message parts. Switch to Claude Code or a vision-capable OpenAI-compatible endpoint for image questions.",
|
||||
credential.model
|
||||
));
|
||||
}
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(300))
|
||||
.build()
|
||||
.map_err(|err| format!("Failed to create provider client: {}", err))?;
|
||||
let request = client
|
||||
.post(openai_chat_completions_url(&credential.base_url))
|
||||
.header("Content-Type", "application/json")
|
||||
.body(openai_request.to_string());
|
||||
let response = with_optional_bearer_auth(request, &credential.api_key)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("Provider request failed: {}", err))?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let response_text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|err| format!("Failed to read provider error response: {}", err))?;
|
||||
return Err(format!(
|
||||
"Provider returned HTTP {}: {}",
|
||||
status,
|
||||
compact_error_text(&response_text)
|
||||
));
|
||||
}
|
||||
|
||||
if wants_stream {
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
if content_type.contains("stream") {
|
||||
stream_openai_sse_to_anthropic(stream, response, &anthropic_request, credential).await
|
||||
} else {
|
||||
let response_text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|err| format!("Failed to read provider response: {}", err))?;
|
||||
let openai_response: Value = serde_json::from_str(&response_text)
|
||||
.map_err(|err| format!("Provider returned invalid JSON: {}", err))?;
|
||||
let anthropic_response =
|
||||
openai_to_anthropic_message(&anthropic_request, &openai_response, credential)?;
|
||||
stream
|
||||
.write_all(sse_response(&anthropic_response).as_bytes())
|
||||
.await
|
||||
.map_err(|err| format!("Failed to write proxy SSE response: {}", err))
|
||||
}
|
||||
} else {
|
||||
let response_text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|err| format!("Failed to read provider response: {}", err))?;
|
||||
let openai_response: Value = serde_json::from_str(&response_text)
|
||||
.map_err(|err| format!("Provider returned invalid JSON: {}", err))?;
|
||||
let anthropic_response =
|
||||
openai_to_anthropic_message(&anthropic_request, &openai_response, credential)?;
|
||||
stream
|
||||
.write_all(json_response(200, &anthropic_response).as_bytes())
|
||||
.await
|
||||
.map_err(|err| format!("Failed to write proxy JSON response: {}", err))
|
||||
}
|
||||
}
|
||||
|
||||
fn openai_chat_completions_url(base_url: &str) -> String {
|
||||
let clean = base_url.trim_end_matches('/');
|
||||
if clean.ends_with("/chat/completions") {
|
||||
clean.to_string()
|
||||
} else if openai_compatible_base_url_has_chat_root(clean) {
|
||||
format!("{}/chat/completions", clean)
|
||||
} else {
|
||||
format!("{}/v1/chat/completions", clean)
|
||||
}
|
||||
}
|
||||
|
||||
fn with_optional_bearer_auth(
|
||||
request: reqwest::RequestBuilder,
|
||||
api_key: &str,
|
||||
) -> reqwest::RequestBuilder {
|
||||
if api_key.trim().is_empty() {
|
||||
request
|
||||
} else {
|
||||
request.bearer_auth(api_key)
|
||||
}
|
||||
}
|
||||
|
||||
fn request_contains_openai_image_parts(value: &Value) -> bool {
|
||||
match value {
|
||||
Value::Array(values) => values.iter().any(request_contains_openai_image_parts),
|
||||
Value::Object(object) => {
|
||||
object.get("type").and_then(Value::as_str) == Some("image_url")
|
||||
|| object.values().any(request_contains_openai_image_parts)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_rejects_openai_image_parts(credential: &OpenAiProxyCredential) -> bool {
|
||||
let base_url = credential.base_url.to_ascii_lowercase();
|
||||
base_url == "https://api.deepseek.com" || base_url.starts_with("https://api.deepseek.com/")
|
||||
}
|
||||
|
||||
fn openai_compatible_base_url_has_chat_root(base_url: &str) -> bool {
|
||||
let lower = base_url.to_ascii_lowercase();
|
||||
if lower == "https://api.deepseek.com" {
|
||||
return true;
|
||||
}
|
||||
|
||||
let path = lower
|
||||
.split_once("://")
|
||||
.and_then(|(_, rest)| rest.split_once('/').map(|(_, path)| path))
|
||||
.unwrap_or("")
|
||||
.trim_matches('/');
|
||||
if path.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let segments = path.split('/').collect::<Vec<_>>();
|
||||
let last = segments.last().copied().unwrap_or_default();
|
||||
matches!(last, "v1" | "v2" | "v3" | "v4" | "beta")
|
||||
|| path.ends_with("/openai")
|
||||
|| path.ends_with("compatible-mode/v1")
|
||||
}
|
||||
|
||||
fn json_response(status: u16, value: &Value) -> String {
|
||||
http_response(
|
||||
status,
|
||||
"application/json; charset=utf-8",
|
||||
&value.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
fn http_response(status: u16, content_type: &str, body: &str) -> String {
|
||||
let reason = match status {
|
||||
200 => "OK",
|
||||
400 => "Bad Request",
|
||||
404 => "Not Found",
|
||||
502 => "Bad Gateway",
|
||||
_ => "Internal Server Error",
|
||||
};
|
||||
format!(
|
||||
"HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
status,
|
||||
reason,
|
||||
content_type,
|
||||
body.as_bytes().len(),
|
||||
body
|
||||
)
|
||||
}
|
||||
|
||||
fn compact_error_text(text: &str) -> String {
|
||||
let compact = text.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if compact.chars().count() <= 1000 {
|
||||
compact
|
||||
} else {
|
||||
format!("{}...", compact.chars().take(1000).collect::<String>())
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn _assert_local_addr(_: SocketAddr) {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::transformers::ProxyTransformerChain;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn recognizes_anthropic_messages_paths_with_query_strings() {
|
||||
let path = request_path_without_query("/v1/messages?beta=tools");
|
||||
assert_eq!(path, "/v1/messages");
|
||||
assert!(is_messages_path(path));
|
||||
assert!(is_count_tokens_path("/v1/messages/count_tokens"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_openai_image_parts_for_provider_guard() {
|
||||
assert!(request_contains_openai_image_parts(&json!({
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "text", "text": "what is this?" },
|
||||
{ "type": "image_url", "image_url": { "url": "data:image/png;base64,abc" } }
|
||||
]
|
||||
}]
|
||||
})));
|
||||
assert!(!request_contains_openai_image_parts(&json!({
|
||||
"messages": [{ "role": "user", "content": "text only" }]
|
||||
})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_tool_use_and_tool_result_messages() {
|
||||
let credential = OpenAiProxyCredential {
|
||||
api_key: "sk-test".to_string(),
|
||||
base_url: "https://api.example.com/v1".to_string(),
|
||||
model: "qwen-test".to_string(),
|
||||
transformers: Vec::new(),
|
||||
model_transformers: Vec::new(),
|
||||
};
|
||||
let request = json!({
|
||||
"system": "system prompt",
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_1",
|
||||
"name": "Read",
|
||||
"input": { "file_path": "main.tex" }
|
||||
}]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "toolu_1",
|
||||
"content": "file text"
|
||||
}]
|
||||
}
|
||||
],
|
||||
"tools": [{
|
||||
"name": "Read",
|
||||
"description": "Read a file",
|
||||
"input_schema": { "type": "object" }
|
||||
}]
|
||||
});
|
||||
|
||||
let converted = anthropic_to_openai_request(
|
||||
&request,
|
||||
&credential,
|
||||
&ProxyTransformerChain::from_names(&[]),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(converted["model"], "qwen-test");
|
||||
assert_eq!(converted["messages"][0]["role"], "system");
|
||||
assert_eq!(
|
||||
converted["messages"][1]["tool_calls"][0]["function"]["name"],
|
||||
"Read"
|
||||
);
|
||||
assert_eq!(converted["messages"][2]["role"], "tool");
|
||||
assert_eq!(converted["tools"][0]["function"]["name"], "Read");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_tool_results_immediately_after_tool_calls() {
|
||||
let credential = OpenAiProxyCredential {
|
||||
api_key: "sk-test".to_string(),
|
||||
base_url: "https://api.example.com/v1".to_string(),
|
||||
model: "qwen-test".to_string(),
|
||||
transformers: Vec::new(),
|
||||
model_transformers: Vec::new(),
|
||||
};
|
||||
let request = json!({
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_1",
|
||||
"name": "Read",
|
||||
"input": { "file_path": "main.tex" }
|
||||
}]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Now explain it."
|
||||
},
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "toolu_1",
|
||||
"content": "file text"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let converted = anthropic_to_openai_request(
|
||||
&request,
|
||||
&credential,
|
||||
&ProxyTransformerChain::from_names(&[]),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(converted["messages"][0]["role"], "assistant");
|
||||
assert_eq!(converted["messages"][1]["role"], "tool");
|
||||
assert_eq!(converted["messages"][1]["tool_call_id"], "toolu_1");
|
||||
assert_eq!(converted["messages"][2]["role"], "user");
|
||||
assert_eq!(converted["messages"][2]["content"], "Now explain it.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthesizes_missing_tool_results_before_user_messages() {
|
||||
let credential = OpenAiProxyCredential {
|
||||
api_key: "sk-test".to_string(),
|
||||
base_url: "https://api.example.com/v1".to_string(),
|
||||
model: "qwen-test".to_string(),
|
||||
transformers: Vec::new(),
|
||||
model_transformers: Vec::new(),
|
||||
};
|
||||
let request = json!({
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_missing",
|
||||
"name": "Read",
|
||||
"input": { "file_path": "main.tex" }
|
||||
}]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "continue"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let converted = anthropic_to_openai_request(
|
||||
&request,
|
||||
&credential,
|
||||
&ProxyTransformerChain::from_names(&[]),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(converted["messages"][0]["role"], "assistant");
|
||||
assert_eq!(converted["messages"][1]["role"], "tool");
|
||||
assert_eq!(converted["messages"][1]["tool_call_id"], "toolu_missing");
|
||||
assert_eq!(converted["messages"][2]["role"], "user");
|
||||
assert_eq!(converted["messages"][2]["content"], "continue");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_openai_tool_call_to_anthropic_message() {
|
||||
let credential = OpenAiProxyCredential {
|
||||
api_key: "sk-test".to_string(),
|
||||
base_url: "https://api.example.com/v1".to_string(),
|
||||
model: "deepseek-test".to_string(),
|
||||
transformers: Vec::new(),
|
||||
model_transformers: Vec::new(),
|
||||
};
|
||||
let request = json!({ "model": "claude-sonnet-4" });
|
||||
let response = json!({
|
||||
"id": "chatcmpl_1",
|
||||
"choices": [{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": null,
|
||||
"tool_calls": [{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "Grep",
|
||||
"arguments": "{\"pattern\":\"FastVID\"}"
|
||||
}
|
||||
}]
|
||||
},
|
||||
"finish_reason": "tool_calls"
|
||||
}],
|
||||
"usage": { "prompt_tokens": 10, "completion_tokens": 3 }
|
||||
});
|
||||
|
||||
let converted = openai_to_anthropic_message(&request, &response, &credential).unwrap();
|
||||
assert_eq!(converted["stop_reason"], "tool_use");
|
||||
assert_eq!(converted["content"][0]["type"], "tool_use");
|
||||
assert_eq!(converted["content"][0]["input"]["pattern"], "FastVID");
|
||||
}
|
||||
}
|
||||
899
apps/desktop/src-tauri/src/anthropic_proxy/messages.rs
Normal file
|
|
@ -0,0 +1,899 @@
|
|||
use super::tools::{normalized_tool_call_id, repair_tool_arguments, repaired_tool_arguments_value};
|
||||
use super::transformers::ProxyTransformerChain;
|
||||
use super::OpenAiProxyCredential;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
const EXIT_TOOL_NAME: &str = "ExitTool";
|
||||
|
||||
pub(super) fn anthropic_to_openai_request(
|
||||
request: &Value,
|
||||
credential: &OpenAiProxyCredential,
|
||||
transformers: &ProxyTransformerChain,
|
||||
) -> Result<Value, String> {
|
||||
let mut messages = Vec::new();
|
||||
if let Some(system) = request.get("system").and_then(flatten_anthropic_content) {
|
||||
if !system.trim().is_empty() {
|
||||
messages.push(json!({ "role": "system", "content": system }));
|
||||
}
|
||||
}
|
||||
|
||||
for message in request
|
||||
.get("messages")
|
||||
.and_then(|value| value.as_array())
|
||||
.ok_or_else(|| "Anthropic request is missing messages[]".to_string())?
|
||||
{
|
||||
append_openai_messages_for_anthropic_message(&mut messages, message);
|
||||
}
|
||||
let messages = normalize_openai_tool_message_pairs(messages);
|
||||
|
||||
let mut body = json!({
|
||||
"model": credential.model,
|
||||
"messages": messages,
|
||||
"stream": false,
|
||||
});
|
||||
copy_number_field(request, &mut body, "temperature");
|
||||
copy_number_field(request, &mut body, "top_p");
|
||||
copy_number_field(request, &mut body, "top_k");
|
||||
copy_number_field(request, &mut body, "max_tokens");
|
||||
if let Some(stop) = request.get("stop_sequences") {
|
||||
body["stop"] = stop.clone();
|
||||
}
|
||||
|
||||
if let Some(tools) = request.get("tools").and_then(|value| value.as_array()) {
|
||||
let converted = tools
|
||||
.iter()
|
||||
.filter_map(anthropic_tool_to_openai_tool)
|
||||
.collect::<Vec<_>>();
|
||||
if !converted.is_empty() {
|
||||
let tool_choice = if transformers.has_tooluse() {
|
||||
Value::String("required".to_string())
|
||||
} else {
|
||||
openai_tool_choice(request.get("tool_choice"))
|
||||
};
|
||||
let mut converted = converted;
|
||||
if tool_choice == Value::String("required".to_string()) {
|
||||
append_exit_tool(&mut converted);
|
||||
append_exit_tool_reminder(&mut body);
|
||||
}
|
||||
body["tools"] = Value::Array(converted);
|
||||
body["tool_choice"] = tool_choice;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
pub(super) fn openai_to_anthropic_message(
|
||||
anthropic_request: &Value,
|
||||
openai_response: &Value,
|
||||
credential: &OpenAiProxyCredential,
|
||||
) -> Result<Value, String> {
|
||||
let message = openai_response
|
||||
.pointer("/choices/0/message")
|
||||
.ok_or_else(|| "Provider response is missing choices[0].message".to_string())?;
|
||||
let mut content = Vec::new();
|
||||
|
||||
if let Some(reasoning) = openai_message_thinking(message) {
|
||||
content.push(json!({ "type": "thinking", "thinking": reasoning }));
|
||||
}
|
||||
|
||||
if let Some(text) = openai_message_text(message).filter(|value| !value.trim().is_empty()) {
|
||||
content.push(json!({ "type": "text", "text": text }));
|
||||
}
|
||||
|
||||
if let Some(tool_calls) = message.get("tool_calls").and_then(|value| value.as_array()) {
|
||||
for call in tool_calls {
|
||||
let function = call.get("function").unwrap_or(&Value::Null);
|
||||
let name = function
|
||||
.get("name")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("unknown");
|
||||
let arguments = function
|
||||
.get("arguments")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("{}");
|
||||
if name == EXIT_TOOL_NAME {
|
||||
if let Some(response) = exit_tool_response(arguments) {
|
||||
content.push(json!({ "type": "text", "text": response }));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let input = repaired_tool_arguments_value(arguments);
|
||||
let id = normalized_tool_call_id(call.get("id").and_then(|value| value.as_str()));
|
||||
content.push(json!({
|
||||
"type": "tool_use",
|
||||
"id": id,
|
||||
"name": name,
|
||||
"input": input,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if content.is_empty() {
|
||||
content.push(json!({ "type": "text", "text": "" }));
|
||||
}
|
||||
|
||||
let finish_reason = openai_response
|
||||
.pointer("/choices/0/finish_reason")
|
||||
.and_then(|value| value.as_str());
|
||||
let stop_reason = if content
|
||||
.iter()
|
||||
.any(|block| block.get("type").and_then(|value| value.as_str()) == Some("tool_use"))
|
||||
{
|
||||
"tool_use"
|
||||
} else {
|
||||
match finish_reason {
|
||||
Some("length") => "max_tokens",
|
||||
Some("tool_calls") if !contains_only_exit_tool(message) => "tool_use",
|
||||
_ => "end_turn",
|
||||
}
|
||||
};
|
||||
|
||||
let usage = openai_response.get("usage").unwrap_or(&Value::Null);
|
||||
Ok(json!({
|
||||
"id": openai_response
|
||||
.get("id")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| format!("msg_{}", uuid::Uuid::new_v4().simple())),
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": anthropic_request
|
||||
.get("model")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or(&credential.model),
|
||||
"content": content,
|
||||
"stop_reason": stop_reason,
|
||||
"stop_sequence": Value::Null,
|
||||
"usage": {
|
||||
"input_tokens": usage_token(usage, &["prompt_tokens", "input_tokens", "prompt_token_count"]),
|
||||
"output_tokens": usage_token(usage, &["completion_tokens", "output_tokens", "completion_token_count"]),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
fn append_openai_messages_for_anthropic_message(messages: &mut Vec<Value>, message: &Value) {
|
||||
let role = message
|
||||
.get("role")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("user");
|
||||
let content = message.get("content").unwrap_or(&Value::Null);
|
||||
|
||||
if role == "assistant" {
|
||||
let (text, tool_calls, thinking) = assistant_content_to_openai(content);
|
||||
let mut openai_message = json!({
|
||||
"role": "assistant",
|
||||
"content": if text.trim().is_empty() { Value::Null } else { Value::String(text) },
|
||||
});
|
||||
if !tool_calls.is_empty() {
|
||||
openai_message["tool_calls"] = Value::Array(tool_calls);
|
||||
}
|
||||
if let Some(thinking) = thinking {
|
||||
openai_message["thinking"] = thinking;
|
||||
}
|
||||
messages.push(openai_message);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(blocks) = content.as_array() {
|
||||
let content_parts = user_content_blocks_to_openai_parts(blocks);
|
||||
if !content_parts.is_empty() {
|
||||
let content = if content_parts.len() == 1
|
||||
&& content_parts[0]
|
||||
.get("type")
|
||||
.and_then(|value| value.as_str())
|
||||
== Some("text")
|
||||
{
|
||||
content_parts[0]
|
||||
.get("text")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!(""))
|
||||
} else {
|
||||
Value::Array(content_parts)
|
||||
};
|
||||
messages.push(json!({ "role": role, "content": content }));
|
||||
}
|
||||
|
||||
for block in blocks {
|
||||
if block.get("type").and_then(|value| value.as_str()) != Some("tool_result") {
|
||||
continue;
|
||||
}
|
||||
let tool_call_id = block
|
||||
.get("tool_use_id")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("toolu_unknown");
|
||||
let (content, image_parts) =
|
||||
tool_result_content_to_openai(block.get("content").unwrap_or(&Value::Null));
|
||||
let content = if content.trim().is_empty() && !image_parts.is_empty() {
|
||||
"Tool returned image content.".to_string()
|
||||
} else {
|
||||
content
|
||||
};
|
||||
messages.push(json!({
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call_id,
|
||||
"content": content,
|
||||
}));
|
||||
if !image_parts.is_empty() {
|
||||
let mut content_parts = vec![json!({
|
||||
"type": "text",
|
||||
"text": format!(
|
||||
"Tool result for {} included image content. Use the attached image when answering.",
|
||||
tool_call_id
|
||||
),
|
||||
})];
|
||||
content_parts.extend(image_parts);
|
||||
messages.push(json!({
|
||||
"role": "user",
|
||||
"content": content_parts,
|
||||
}));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let text = content
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| content.to_string());
|
||||
messages.push(json!({ "role": role, "content": text }));
|
||||
}
|
||||
|
||||
fn user_content_blocks_to_openai_parts(blocks: &[Value]) -> Vec<Value> {
|
||||
blocks
|
||||
.iter()
|
||||
.filter_map(
|
||||
|block| match block.get("type").and_then(|value| value.as_str()) {
|
||||
Some("text") => block
|
||||
.get("text")
|
||||
.and_then(|value| value.as_str())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|text| json!({ "type": "text", "text": text })),
|
||||
Some("image") => anthropic_image_block_to_openai_part(block),
|
||||
_ => None,
|
||||
},
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn anthropic_image_block_to_openai_part(block: &Value) -> Option<Value> {
|
||||
let source = block.get("source")?;
|
||||
let url = match source.get("type").and_then(|value| value.as_str()) {
|
||||
Some("base64") => {
|
||||
let media_type = source
|
||||
.get("media_type")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("image/png");
|
||||
let data = source.get("data").and_then(|value| value.as_str())?;
|
||||
format!("data:{};base64,{}", media_type, data)
|
||||
}
|
||||
Some("url") => source
|
||||
.get("url")
|
||||
.and_then(|value| value.as_str())?
|
||||
.to_string(),
|
||||
_ => return None,
|
||||
};
|
||||
Some(json!({
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": url,
|
||||
"detail": "high",
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
fn normalize_openai_tool_message_pairs(messages: Vec<Value>) -> Vec<Value> {
|
||||
let mut normalized = Vec::with_capacity(messages.len());
|
||||
let mut consumed = vec![false; messages.len()];
|
||||
|
||||
for index in 0..messages.len() {
|
||||
if consumed[index] {
|
||||
continue;
|
||||
}
|
||||
|
||||
let message = &messages[index];
|
||||
let tool_call_ids = openai_assistant_tool_call_ids(message);
|
||||
if !tool_call_ids.is_empty() {
|
||||
consumed[index] = true;
|
||||
normalized.push(message.clone());
|
||||
|
||||
for tool_call_id in tool_call_ids {
|
||||
if let Some(tool_index) =
|
||||
find_following_tool_message(&messages, &consumed, index + 1, &tool_call_id)
|
||||
{
|
||||
consumed[tool_index] = true;
|
||||
normalized.push(messages[tool_index].clone());
|
||||
} else {
|
||||
normalized.push(json!({
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call_id,
|
||||
"content": "Tool result unavailable in the prior Claude Code transcript.",
|
||||
}));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
consumed[index] = true;
|
||||
if openai_message_role(message) == Some("tool") {
|
||||
normalized.push(orphan_tool_message_to_user_message(message));
|
||||
} else {
|
||||
normalized.push(message.clone());
|
||||
}
|
||||
}
|
||||
|
||||
normalized
|
||||
}
|
||||
|
||||
fn openai_assistant_tool_call_ids(message: &Value) -> Vec<String> {
|
||||
if openai_message_role(message) != Some("assistant") {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
message
|
||||
.get("tool_calls")
|
||||
.and_then(|value| value.as_array())
|
||||
.map(|tool_calls| {
|
||||
tool_calls
|
||||
.iter()
|
||||
.filter_map(|tool_call| tool_call.get("id").and_then(|value| value.as_str()))
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn find_following_tool_message(
|
||||
messages: &[Value],
|
||||
consumed: &[bool],
|
||||
start: usize,
|
||||
tool_call_id: &str,
|
||||
) -> Option<usize> {
|
||||
for index in start..messages.len() {
|
||||
if consumed[index] {
|
||||
continue;
|
||||
}
|
||||
let message = &messages[index];
|
||||
if openai_message_role(message) == Some("assistant") {
|
||||
break;
|
||||
}
|
||||
if openai_tool_message_id(message) == Some(tool_call_id) {
|
||||
return Some(index);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn orphan_tool_message_to_user_message(message: &Value) -> Value {
|
||||
let tool_call_id = openai_tool_message_id(message).unwrap_or("unknown");
|
||||
let content = message
|
||||
.get("content")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| {
|
||||
message
|
||||
.get("content")
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null)
|
||||
.to_string()
|
||||
});
|
||||
|
||||
json!({
|
||||
"role": "user",
|
||||
"content": format!("Tool result for {}:\n{}", tool_call_id, content),
|
||||
})
|
||||
}
|
||||
|
||||
fn openai_message_role(message: &Value) -> Option<&str> {
|
||||
message.get("role").and_then(|value| value.as_str())
|
||||
}
|
||||
|
||||
fn openai_tool_message_id(message: &Value) -> Option<&str> {
|
||||
if openai_message_role(message) != Some("tool") {
|
||||
return None;
|
||||
}
|
||||
message.get("tool_call_id").and_then(|value| value.as_str())
|
||||
}
|
||||
|
||||
fn flatten_anthropic_content(value: &Value) -> Option<String> {
|
||||
if let Some(text) = value.as_str() {
|
||||
return Some(text.to_string());
|
||||
}
|
||||
|
||||
value.as_array().map(|blocks| {
|
||||
blocks
|
||||
.iter()
|
||||
.filter_map(|block| {
|
||||
block
|
||||
.get("text")
|
||||
.and_then(|value| value.as_str())
|
||||
.or_else(|| block.get("content").and_then(|value| value.as_str()))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n")
|
||||
})
|
||||
}
|
||||
|
||||
fn assistant_content_to_openai(content: &Value) -> (String, Vec<Value>, Option<Value>) {
|
||||
let Some(blocks) = content.as_array() else {
|
||||
return (
|
||||
content
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| content.to_string()),
|
||||
Vec::new(),
|
||||
None,
|
||||
);
|
||||
};
|
||||
|
||||
let mut text = Vec::new();
|
||||
let mut tool_calls = Vec::new();
|
||||
let mut thinking = None;
|
||||
for block in blocks {
|
||||
match block.get("type").and_then(|value| value.as_str()) {
|
||||
Some("text") => {
|
||||
if let Some(value) = block.get("text").and_then(|value| value.as_str()) {
|
||||
text.push(value);
|
||||
}
|
||||
}
|
||||
Some("tool_use") => {
|
||||
let id = block
|
||||
.get("id")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("toolu_unknown");
|
||||
let name = block
|
||||
.get("name")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("unknown");
|
||||
let input = block.get("input").cloned().unwrap_or_else(|| json!({}));
|
||||
tool_calls.push(json!({
|
||||
"id": id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": name,
|
||||
"arguments": input.to_string(),
|
||||
},
|
||||
}));
|
||||
}
|
||||
Some("thinking") => {
|
||||
if let Some(value) = block.get("thinking").and_then(|value| value.as_str()) {
|
||||
let mut thinking_value = json!({ "content": value });
|
||||
if let Some(signature) = block.get("signature").and_then(|value| value.as_str())
|
||||
{
|
||||
thinking_value["signature"] = Value::String(signature.to_string());
|
||||
}
|
||||
thinking = Some(thinking_value);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
(text.join("\n\n"), tool_calls, thinking)
|
||||
}
|
||||
|
||||
fn tool_result_content_to_openai(content: &Value) -> (String, Vec<Value>) {
|
||||
if let Some(text) = content.as_str() {
|
||||
return (text.to_string(), Vec::new());
|
||||
}
|
||||
if let Some(blocks) = content.as_array() {
|
||||
let mut text = Vec::new();
|
||||
let mut image_parts = Vec::new();
|
||||
for block in blocks {
|
||||
if let Some(value) = block
|
||||
.get("text")
|
||||
.and_then(|value| value.as_str())
|
||||
.or_else(|| block.get("content").and_then(|value| value.as_str()))
|
||||
{
|
||||
text.push(value);
|
||||
continue;
|
||||
}
|
||||
if block.get("type").and_then(|value| value.as_str()) == Some("image") {
|
||||
if let Some(part) = anthropic_image_block_to_openai_part(block) {
|
||||
image_parts.push(part);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (text.join("\n\n"), image_parts);
|
||||
}
|
||||
(content.to_string(), Vec::new())
|
||||
}
|
||||
|
||||
fn anthropic_tool_to_openai_tool(tool: &Value) -> Option<Value> {
|
||||
let name = tool.get("name")?.as_str()?;
|
||||
let description = tool
|
||||
.get("description")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or_default();
|
||||
let parameters = tool
|
||||
.get("input_schema")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!({ "type": "object", "properties": {} }));
|
||||
|
||||
Some(json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": name,
|
||||
"description": description,
|
||||
"parameters": parameters,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
fn append_exit_tool(tools: &mut Vec<Value>) {
|
||||
if tools.iter().any(|tool| {
|
||||
tool.pointer("/function/name")
|
||||
.and_then(|value| value.as_str())
|
||||
== Some(EXIT_TOOL_NAME)
|
||||
}) {
|
||||
return;
|
||||
}
|
||||
tools.push(json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": EXIT_TOOL_NAME,
|
||||
"description": "Use this when tool mode is active and no remaining tool call is needed. This is the valid way to exit tool mode with a final answer.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"response": {
|
||||
"type": "string",
|
||||
"description": "Final response to show the user exactly as written."
|
||||
}
|
||||
},
|
||||
"required": ["response"]
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
fn append_exit_tool_reminder(body: &mut Value) {
|
||||
let Some(messages) = body
|
||||
.get_mut("messages")
|
||||
.and_then(|value| value.as_array_mut())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
messages.push(json!({
|
||||
"role": "system",
|
||||
"content": "<system-reminder>Tool mode is active. The user expects you to proactively execute the most suitable tool to help complete the task. Before invoking a tool, carefully evaluate whether it matches the current task. If no available tool is appropriate, or the task is complete, call ExitTool with the final response instead of inventing another tool call.</system-reminder>",
|
||||
}));
|
||||
}
|
||||
|
||||
fn openai_tool_choice(choice: Option<&Value>) -> Value {
|
||||
let Some(choice) = choice else {
|
||||
return Value::String("auto".to_string());
|
||||
};
|
||||
match choice.get("type").and_then(|value| value.as_str()) {
|
||||
Some("auto") => Value::String("auto".to_string()),
|
||||
Some("any") => Value::String("required".to_string()),
|
||||
Some("tool") => {
|
||||
let name = choice
|
||||
.get("name")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or_default();
|
||||
json!({
|
||||
"type": "function",
|
||||
"function": { "name": name },
|
||||
})
|
||||
}
|
||||
_ => Value::String("auto".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_number_field(source: &Value, target: &mut Value, key: &str) {
|
||||
if let Some(value) = source.get(key).filter(|value| value.is_number()) {
|
||||
target[key] = value.clone();
|
||||
}
|
||||
}
|
||||
|
||||
fn openai_message_text(message: &Value) -> Option<String> {
|
||||
let content = message.get("content")?;
|
||||
if let Some(text) = content.as_str() {
|
||||
return Some(text.to_string());
|
||||
}
|
||||
content.as_array().map(|parts| {
|
||||
parts
|
||||
.iter()
|
||||
.filter_map(|part| {
|
||||
part.get("text")
|
||||
.and_then(|value| value.as_str())
|
||||
.or_else(|| {
|
||||
if part.get("type").and_then(|value| value.as_str()) == Some("text") {
|
||||
part.get("content").and_then(|value| value.as_str())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
})
|
||||
}
|
||||
|
||||
fn openai_message_thinking(message: &Value) -> Option<String> {
|
||||
message
|
||||
.get("reasoning_content")
|
||||
.or_else(|| message.get("reasoning"))
|
||||
.and_then(|value| value.as_str())
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(str::to_string)
|
||||
.or_else(|| {
|
||||
message
|
||||
.get("thinking")
|
||||
.and_then(|value| {
|
||||
value
|
||||
.get("content")
|
||||
.and_then(|content| content.as_str())
|
||||
.or_else(|| value.as_str())
|
||||
})
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(str::to_string)
|
||||
})
|
||||
}
|
||||
|
||||
fn exit_tool_response(arguments: &str) -> Option<String> {
|
||||
let repaired = repair_tool_arguments(arguments);
|
||||
serde_json::from_str::<Value>(&repaired)
|
||||
.ok()
|
||||
.and_then(|value| {
|
||||
value
|
||||
.get("response")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(str::to_string)
|
||||
})
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
}
|
||||
|
||||
fn contains_only_exit_tool(message: &Value) -> bool {
|
||||
let Some(tool_calls) = message.get("tool_calls").and_then(|value| value.as_array()) else {
|
||||
return false;
|
||||
};
|
||||
!tool_calls.is_empty()
|
||||
&& tool_calls.iter().all(|call| {
|
||||
call.pointer("/function/name")
|
||||
.and_then(|value| value.as_str())
|
||||
== Some(EXIT_TOOL_NAME)
|
||||
})
|
||||
}
|
||||
|
||||
fn usage_token(usage: &Value, keys: &[&str]) -> u64 {
|
||||
keys.iter()
|
||||
.find_map(|key| usage.get(*key).and_then(|value| value.as_u64()))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn credential() -> OpenAiProxyCredential {
|
||||
OpenAiProxyCredential {
|
||||
api_key: "sk-test".to_string(),
|
||||
base_url: "https://api.example.com/v1".to_string(),
|
||||
model: "qwen-test".to_string(),
|
||||
transformers: Vec::new(),
|
||||
model_transformers: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn transformers(names: &[&str]) -> ProxyTransformerChain {
|
||||
ProxyTransformerChain::from_names(names)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_anthropic_image_blocks_as_openai_image_url_parts() {
|
||||
let request = json!({
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "text", "text": "what is this?" },
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/png",
|
||||
"data": "abcd"
|
||||
}
|
||||
}
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let converted =
|
||||
anthropic_to_openai_request(&request, &credential(), &transformers(&[])).unwrap();
|
||||
|
||||
assert_eq!(converted["messages"][0]["content"][0]["type"], "text");
|
||||
assert_eq!(converted["messages"][0]["content"][1]["type"], "image_url");
|
||||
assert_eq!(
|
||||
converted["messages"][0]["content"][1]["image_url"]["url"],
|
||||
"data:image/png;base64,abcd"
|
||||
);
|
||||
assert_eq!(
|
||||
converted["messages"][0]["content"][1]["image_url"]["detail"],
|
||||
"high"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_tool_result_images_as_follow_up_user_image_parts() {
|
||||
let request = json!({
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_read_image",
|
||||
"name": "Read",
|
||||
"input": { "file_path": "attachments/figure.png" }
|
||||
}]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "toolu_read_image",
|
||||
"content": [
|
||||
{ "type": "text", "text": "Image read successfully." },
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/png",
|
||||
"data": "abcd"
|
||||
}
|
||||
}
|
||||
]
|
||||
}]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let converted =
|
||||
anthropic_to_openai_request(&request, &credential(), &transformers(&[])).unwrap();
|
||||
|
||||
assert_eq!(converted["messages"][0]["role"], "assistant");
|
||||
assert_eq!(converted["messages"][1]["role"], "tool");
|
||||
assert_eq!(converted["messages"][1]["tool_call_id"], "toolu_read_image");
|
||||
assert_eq!(
|
||||
converted["messages"][1]["content"],
|
||||
"Image read successfully."
|
||||
);
|
||||
assert_eq!(converted["messages"][2]["role"], "user");
|
||||
assert_eq!(converted["messages"][2]["content"][0]["type"], "text");
|
||||
assert_eq!(converted["messages"][2]["content"][1]["type"], "image_url");
|
||||
assert_eq!(
|
||||
converted["messages"][2]["content"][1]["image_url"]["url"],
|
||||
"data:image/png;base64,abcd"
|
||||
);
|
||||
assert_eq!(
|
||||
converted["messages"][2]["content"][1]["image_url"]["detail"],
|
||||
"high"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_assistant_thinking_for_provider_context() {
|
||||
let request = json!({
|
||||
"messages": [{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "thinking",
|
||||
"thinking": "I inspected the files.",
|
||||
"signature": "sig_1"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Done."
|
||||
}
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let converted =
|
||||
anthropic_to_openai_request(&request, &credential(), &transformers(&[])).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
converted["messages"][0]["thinking"]["content"],
|
||||
"I inspected the files."
|
||||
);
|
||||
assert_eq!(converted["messages"][0]["thinking"]["signature"], "sig_1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adds_exit_tool_when_tool_choice_requires_a_tool() {
|
||||
let request = json!({
|
||||
"messages": [{ "role": "user", "content": "finish" }],
|
||||
"tool_choice": { "type": "any" },
|
||||
"tools": [{
|
||||
"name": "Read",
|
||||
"description": "Read a file",
|
||||
"input_schema": { "type": "object" }
|
||||
}]
|
||||
});
|
||||
|
||||
let converted =
|
||||
anthropic_to_openai_request(&request, &credential(), &transformers(&[])).unwrap();
|
||||
let tool_names = converted["tools"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|tool| {
|
||||
tool.pointer("/function/name")
|
||||
.and_then(|value| value.as_str())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert!(tool_names.contains(&"Read"));
|
||||
assert!(tool_names.contains(&EXIT_TOOL_NAME));
|
||||
assert_eq!(converted["tool_choice"], "required");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tooluse_transformer_forces_exit_tool_like_ccr() {
|
||||
let request = json!({
|
||||
"messages": [{ "role": "user", "content": "finish" }],
|
||||
"tools": [{
|
||||
"name": "Read",
|
||||
"description": "Read a file",
|
||||
"input_schema": { "type": "object" }
|
||||
}]
|
||||
});
|
||||
|
||||
let converted =
|
||||
anthropic_to_openai_request(&request, &credential(), &transformers(&["tooluse"]))
|
||||
.unwrap();
|
||||
let tool_names = converted["tools"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|tool| {
|
||||
tool.pointer("/function/name")
|
||||
.and_then(|value| value.as_str())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(converted["tool_choice"], "required");
|
||||
assert!(tool_names.contains(&"Read"));
|
||||
assert!(tool_names.contains(&EXIT_TOOL_NAME));
|
||||
assert!(converted["messages"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|message| message
|
||||
.get("content")
|
||||
.and_then(|value| value.as_str())
|
||||
.is_some_and(|content| content.contains("Tool mode is active"))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_exit_tool_response_to_final_text() {
|
||||
let request = json!({ "model": "claude-sonnet-4" });
|
||||
let response = json!({
|
||||
"id": "chatcmpl_1",
|
||||
"choices": [{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": null,
|
||||
"tool_calls": [{
|
||||
"id": "call_exit",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "ExitTool",
|
||||
"arguments": "{\"response\":\"done\"}"
|
||||
}
|
||||
}]
|
||||
},
|
||||
"finish_reason": "tool_calls"
|
||||
}],
|
||||
"usage": { "prompt_tokens": 5, "completion_tokens": 2 }
|
||||
});
|
||||
|
||||
let converted = openai_to_anthropic_message(&request, &response, &credential()).unwrap();
|
||||
|
||||
assert_eq!(converted["stop_reason"], "end_turn");
|
||||
assert_eq!(converted["content"][0]["type"], "text");
|
||||
assert_eq!(converted["content"][0]["text"], "done");
|
||||
}
|
||||
}
|
||||
214
apps/desktop/src-tauri/src/anthropic_proxy/providers.rs
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
use super::transformers::ProxyTransformerChain;
|
||||
use super::OpenAiProxyCredential;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
const DEEPSEEK_MAX_TOKENS: u64 = 8192;
|
||||
|
||||
pub(super) fn apply_provider_request_transforms(
|
||||
openai_request: &mut Value,
|
||||
anthropic_request: &Value,
|
||||
credential: &OpenAiProxyCredential,
|
||||
wants_stream: bool,
|
||||
transformers: &ProxyTransformerChain,
|
||||
) {
|
||||
if transformers.has_cleancache() {
|
||||
clean_cache_control(openai_request);
|
||||
}
|
||||
|
||||
if wants_stream && transformers.has_streamoptions() {
|
||||
openai_request["stream_options"] = json!({ "include_usage": true });
|
||||
}
|
||||
|
||||
if transformers.has_deepseek() {
|
||||
cap_number_field(openai_request, "max_tokens", DEEPSEEK_MAX_TOKENS);
|
||||
}
|
||||
|
||||
apply_reasoning_budget(openai_request, anthropic_request);
|
||||
apply_max_completion_tokens_compat(openai_request, credential);
|
||||
clean_null_optional_fields(openai_request);
|
||||
}
|
||||
|
||||
fn cap_number_field(body: &mut Value, key: &str, max: u64) {
|
||||
let Some(value) = body.get(key).and_then(|value| value.as_u64()) else {
|
||||
return;
|
||||
};
|
||||
if value > max {
|
||||
body[key] = Value::Number(max.into());
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_reasoning_budget(openai_request: &mut Value, anthropic_request: &Value) {
|
||||
let Some(thinking) = anthropic_request.get("thinking") else {
|
||||
return;
|
||||
};
|
||||
if thinking.get("type").and_then(|value| value.as_str()) != Some("enabled") {
|
||||
return;
|
||||
}
|
||||
let Some(budget_tokens) = thinking
|
||||
.get("budget_tokens")
|
||||
.and_then(|value| value.as_u64())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if budget_tokens > 0 {
|
||||
openai_request["reasoning"] = json!({ "max_tokens": budget_tokens });
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_max_completion_tokens_compat(
|
||||
openai_request: &mut Value,
|
||||
credential: &OpenAiProxyCredential,
|
||||
) {
|
||||
if !uses_max_completion_tokens(credential) {
|
||||
return;
|
||||
}
|
||||
let Some(max_tokens) = openai_request.get("max_tokens").cloned() else {
|
||||
return;
|
||||
};
|
||||
openai_request["max_completion_tokens"] = max_tokens;
|
||||
if let Some(object) = openai_request.as_object_mut() {
|
||||
object.remove("max_tokens");
|
||||
}
|
||||
}
|
||||
|
||||
fn uses_max_completion_tokens(credential: &OpenAiProxyCredential) -> bool {
|
||||
let base_url = credential.base_url.to_ascii_lowercase();
|
||||
let model = credential.model.to_ascii_lowercase();
|
||||
let is_openai = base_url.contains("api.openai.com") || base_url.contains("openai.azure.com");
|
||||
is_openai
|
||||
&& (model.starts_with("o1")
|
||||
|| model.starts_with("o3")
|
||||
|| model.starts_with("o4")
|
||||
|| model.starts_with("gpt-5"))
|
||||
}
|
||||
|
||||
fn clean_cache_control(value: &mut Value) {
|
||||
match value {
|
||||
Value::Array(values) => {
|
||||
for value in values {
|
||||
clean_cache_control(value);
|
||||
}
|
||||
}
|
||||
Value::Object(object) => {
|
||||
object.remove("cache_control");
|
||||
for value in object.values_mut() {
|
||||
clean_cache_control(value);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn clean_null_optional_fields(value: &mut Value) {
|
||||
let Some(object) = value.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
for key in ["tool_choice", "stop", "stream_options", "reasoning"] {
|
||||
if object.get(key).is_some_and(Value::is_null) {
|
||||
object.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn credential(base_url: &str, model: &str) -> OpenAiProxyCredential {
|
||||
OpenAiProxyCredential {
|
||||
api_key: "sk-test".to_string(),
|
||||
base_url: base_url.to_string(),
|
||||
model: model.to_string(),
|
||||
transformers: Vec::new(),
|
||||
model_transformers: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adds_usage_stream_options_for_streaming_requests() {
|
||||
let mut body = json!({ "stream": true });
|
||||
apply_provider_request_transforms(
|
||||
&mut body,
|
||||
&json!({}),
|
||||
&credential("https://api.example.com/v1", "qwen-test"),
|
||||
true,
|
||||
&ProxyTransformerChain::from_names(&["streamoptions"]),
|
||||
);
|
||||
|
||||
assert_eq!(body["stream_options"]["include_usage"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caps_deepseek_max_tokens() {
|
||||
let mut body = json!({ "max_tokens": 20000 });
|
||||
apply_provider_request_transforms(
|
||||
&mut body,
|
||||
&json!({}),
|
||||
&credential("https://api.deepseek.com", "deepseek-chat"),
|
||||
false,
|
||||
&ProxyTransformerChain::from_names(&["deepseek"]),
|
||||
);
|
||||
|
||||
assert_eq!(body["max_tokens"], DEEPSEEK_MAX_TOKENS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copies_anthropic_thinking_budget_as_reasoning() {
|
||||
let mut body = json!({});
|
||||
apply_provider_request_transforms(
|
||||
&mut body,
|
||||
&json!({
|
||||
"thinking": {
|
||||
"type": "enabled",
|
||||
"budget_tokens": 4096
|
||||
}
|
||||
}),
|
||||
&credential("https://api.example.com/v1", "qwen-test"),
|
||||
false,
|
||||
&ProxyTransformerChain::from_names(&[]),
|
||||
);
|
||||
|
||||
assert_eq!(body["reasoning"]["max_tokens"], 4096);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_openai_reasoning_models_to_max_completion_tokens() {
|
||||
let mut body = json!({ "max_tokens": 12000 });
|
||||
apply_provider_request_transforms(
|
||||
&mut body,
|
||||
&json!({}),
|
||||
&credential("https://api.openai.com/v1", "o3"),
|
||||
false,
|
||||
&ProxyTransformerChain::from_names(&[]),
|
||||
);
|
||||
|
||||
assert!(body.get("max_tokens").is_none());
|
||||
assert_eq!(body["max_completion_tokens"], 12000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_cache_control_from_openai_compatible_requests() {
|
||||
let mut body = json!({
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": "hello",
|
||||
"cache_control": { "type": "ephemeral" }
|
||||
}]
|
||||
}]
|
||||
});
|
||||
|
||||
apply_provider_request_transforms(
|
||||
&mut body,
|
||||
&json!({}),
|
||||
&credential("https://api.example.com/v1", "qwen-test"),
|
||||
false,
|
||||
&ProxyTransformerChain::from_names(&["cleancache"]),
|
||||
);
|
||||
|
||||
assert!(body["messages"][0]["content"][0]
|
||||
.get("cache_control")
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
925
apps/desktop/src-tauri/src/anthropic_proxy/stream.rs
Normal file
|
|
@ -0,0 +1,925 @@
|
|||
use super::tools::{normalized_tool_call_id, repair_tool_arguments};
|
||||
use super::{http_response, OpenAiProxyCredential};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
#[derive(Default)]
|
||||
struct OpenAiStreamState {
|
||||
message_started: bool,
|
||||
completed: bool,
|
||||
message_id: Option<String>,
|
||||
model: Option<String>,
|
||||
next_block_index: usize,
|
||||
text_block_index: Option<usize>,
|
||||
thinking_block_index: Option<usize>,
|
||||
tool_blocks: HashMap<i64, StreamToolBlock>,
|
||||
stop_reason: Option<String>,
|
||||
output_tokens: u64,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct StreamToolBlock {
|
||||
id: Option<String>,
|
||||
name: Option<String>,
|
||||
buffered_arguments: String,
|
||||
}
|
||||
|
||||
pub(super) async fn stream_openai_sse_to_anthropic(
|
||||
stream: &mut TcpStream,
|
||||
mut response: reqwest::Response,
|
||||
anthropic_request: &Value,
|
||||
credential: &OpenAiProxyCredential,
|
||||
) -> Result<(), String> {
|
||||
stream
|
||||
.write_all(streaming_http_headers().as_bytes())
|
||||
.await
|
||||
.map_err(|err| format!("Failed to write proxy stream headers: {}", err))?;
|
||||
|
||||
let mut state = OpenAiStreamState::default();
|
||||
let mut buffer = String::new();
|
||||
while let Some(chunk) = match response.chunk().await {
|
||||
Ok(chunk) => chunk,
|
||||
Err(err) => {
|
||||
let rendered =
|
||||
anthropic_stream_error_sse(&format!("Provider stream ended unexpectedly: {}", err));
|
||||
let _ = write_stream_body(stream, &rendered, "provider stream error").await;
|
||||
return Ok(());
|
||||
}
|
||||
} {
|
||||
buffer.push_str(&String::from_utf8_lossy(&chunk));
|
||||
while let Some((event, rest)) = take_next_sse_event(&buffer) {
|
||||
buffer = rest;
|
||||
let rendered =
|
||||
openai_sse_event_to_anthropic(&mut state, &event, anthropic_request, credential);
|
||||
if !write_stream_body(stream, &rendered, "proxy stream event").await {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !buffer.trim().is_empty() {
|
||||
let rendered =
|
||||
openai_sse_event_to_anthropic(&mut state, &buffer, anthropic_request, credential);
|
||||
if !write_stream_body(stream, &rendered, "final proxy stream event").await {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let rendered = finish_anthropic_stream(&mut state);
|
||||
let _ = write_stream_body(stream, &rendered, "proxy stream completion").await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn streaming_http_headers() -> String {
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream; charset=utf-8\r\nCache-Control: no-cache\r\nConnection: close\r\n\r\n"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn write_stream_body(stream: &mut TcpStream, body: &str, context: &str) -> bool {
|
||||
if body.is_empty() {
|
||||
return true;
|
||||
}
|
||||
match stream.write_all(body.as_bytes()).await {
|
||||
Ok(()) => true,
|
||||
Err(err) => {
|
||||
eprintln!("[anthropic-proxy] failed to write {}: {}", context, err);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn anthropic_stream_error_sse(message: &str) -> String {
|
||||
let mut body = String::new();
|
||||
push_sse(
|
||||
&mut body,
|
||||
"error",
|
||||
&json!({
|
||||
"type": "error",
|
||||
"error": {
|
||||
"type": "api_error",
|
||||
"message": message,
|
||||
},
|
||||
}),
|
||||
);
|
||||
body
|
||||
}
|
||||
|
||||
fn take_next_sse_event(buffer: &str) -> Option<(String, String)> {
|
||||
if let Some(index) = buffer.find("\n\n") {
|
||||
let event = buffer[..index].to_string();
|
||||
let rest = buffer[index + 2..].to_string();
|
||||
return Some((event, rest));
|
||||
}
|
||||
if let Some(index) = buffer.find("\r\n\r\n") {
|
||||
let event = buffer[..index].to_string();
|
||||
let rest = buffer[index + 4..].to_string();
|
||||
return Some((event, rest));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn openai_sse_event_to_anthropic(
|
||||
state: &mut OpenAiStreamState,
|
||||
event: &str,
|
||||
anthropic_request: &Value,
|
||||
credential: &OpenAiProxyCredential,
|
||||
) -> String {
|
||||
let Some(data) = sse_event_data(event) else {
|
||||
return String::new();
|
||||
};
|
||||
if data.trim() == "[DONE]" {
|
||||
return finish_anthropic_stream(state);
|
||||
}
|
||||
let Ok(chunk) = serde_json::from_str::<Value>(&data) else {
|
||||
return String::new();
|
||||
};
|
||||
openai_stream_chunk_to_anthropic(state, &chunk, anthropic_request, credential)
|
||||
}
|
||||
|
||||
fn sse_event_data(event: &str) -> Option<String> {
|
||||
let mut parts = Vec::new();
|
||||
for line in event.lines() {
|
||||
let line = line.trim_end_matches('\r');
|
||||
if let Some(data) = line.strip_prefix("data:") {
|
||||
parts.push(data.trim_start());
|
||||
}
|
||||
}
|
||||
if parts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(parts.join("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
fn openai_stream_chunk_to_anthropic(
|
||||
state: &mut OpenAiStreamState,
|
||||
chunk: &Value,
|
||||
anthropic_request: &Value,
|
||||
credential: &OpenAiProxyCredential,
|
||||
) -> String {
|
||||
let mut body = String::new();
|
||||
ensure_stream_message_started(state, &mut body, chunk, anthropic_request, credential);
|
||||
|
||||
if let Some(usage) = chunk.get("usage") {
|
||||
state.output_tokens = usage_token(
|
||||
usage,
|
||||
&[
|
||||
"completion_tokens",
|
||||
"output_tokens",
|
||||
"completion_token_count",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
let Some(choice) = chunk
|
||||
.get("choices")
|
||||
.and_then(|value| value.as_array())
|
||||
.and_then(|choices| choices.first())
|
||||
else {
|
||||
return body;
|
||||
};
|
||||
let delta = choice.get("delta").unwrap_or(&Value::Null);
|
||||
|
||||
if let Some(reasoning) = delta_text(delta, &["reasoning_content", "reasoning"]) {
|
||||
push_stream_text_delta(state, &mut body, "thinking", &reasoning);
|
||||
}
|
||||
if let Some(thinking) = delta
|
||||
.get("thinking")
|
||||
.and_then(|value| {
|
||||
value
|
||||
.get("content")
|
||||
.and_then(|content| content.as_str())
|
||||
.or_else(|| value.as_str())
|
||||
})
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
push_stream_text_delta(state, &mut body, "thinking", thinking);
|
||||
}
|
||||
if let Some(content) = delta_text(delta, &["content"]) {
|
||||
push_stream_text_delta(state, &mut body, "text", &content);
|
||||
}
|
||||
if let Some(tool_calls) = delta.get("tool_calls").and_then(|value| value.as_array()) {
|
||||
for call in tool_calls {
|
||||
push_stream_tool_delta(state, call);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(finish_reason) = choice.get("finish_reason").and_then(|value| value.as_str()) {
|
||||
if !finish_reason.is_empty() {
|
||||
state.stop_reason = Some(map_openai_finish_reason(finish_reason).to_string());
|
||||
}
|
||||
}
|
||||
|
||||
body
|
||||
}
|
||||
|
||||
fn ensure_stream_message_started(
|
||||
state: &mut OpenAiStreamState,
|
||||
body: &mut String,
|
||||
chunk: &Value,
|
||||
anthropic_request: &Value,
|
||||
credential: &OpenAiProxyCredential,
|
||||
) {
|
||||
if state.message_started {
|
||||
return;
|
||||
}
|
||||
state.message_started = true;
|
||||
state.message_id = chunk
|
||||
.get("id")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(str::to_string)
|
||||
.or_else(|| Some(format!("msg_{}", uuid::Uuid::new_v4().simple())));
|
||||
state.model = anthropic_request
|
||||
.get("model")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(str::to_string)
|
||||
.or_else(|| {
|
||||
chunk
|
||||
.get("model")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(str::to_string)
|
||||
})
|
||||
.or_else(|| Some(credential.model.clone()));
|
||||
|
||||
push_sse(
|
||||
body,
|
||||
"message_start",
|
||||
&json!({
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"id": state.message_id.clone().unwrap_or_else(|| format!("msg_{}", uuid::Uuid::new_v4().simple())),
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": state.model.clone().unwrap_or_else(|| credential.model.clone()),
|
||||
"content": [],
|
||||
"stop_reason": Value::Null,
|
||||
"stop_sequence": Value::Null,
|
||||
"usage": {
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
fn delta_text(delta: &Value, keys: &[&str]) -> Option<String> {
|
||||
keys.iter()
|
||||
.find_map(|key| delta.get(*key).and_then(|value| value.as_str()))
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn push_stream_text_delta(
|
||||
state: &mut OpenAiStreamState,
|
||||
body: &mut String,
|
||||
block_type: &str,
|
||||
text: &str,
|
||||
) {
|
||||
if block_type != "thinking" {
|
||||
close_thinking_block_if_open(state, body);
|
||||
}
|
||||
|
||||
let block_index = if block_type == "thinking" {
|
||||
if let Some(index) = state.thinking_block_index {
|
||||
index
|
||||
} else {
|
||||
let index = state.next_block_index;
|
||||
state.next_block_index += 1;
|
||||
state.thinking_block_index = Some(index);
|
||||
push_sse(
|
||||
body,
|
||||
"content_block_start",
|
||||
&json!({
|
||||
"type": "content_block_start",
|
||||
"index": index,
|
||||
"content_block": {
|
||||
"type": "thinking",
|
||||
"thinking": "",
|
||||
},
|
||||
}),
|
||||
);
|
||||
index
|
||||
}
|
||||
} else if let Some(index) = state.text_block_index {
|
||||
index
|
||||
} else {
|
||||
let index = state.next_block_index;
|
||||
state.next_block_index += 1;
|
||||
state.text_block_index = Some(index);
|
||||
push_sse(
|
||||
body,
|
||||
"content_block_start",
|
||||
&json!({
|
||||
"type": "content_block_start",
|
||||
"index": index,
|
||||
"content_block": {
|
||||
"type": "text",
|
||||
"text": "",
|
||||
},
|
||||
}),
|
||||
);
|
||||
index
|
||||
};
|
||||
|
||||
let (delta_type, key) = if block_type == "thinking" {
|
||||
("thinking_delta", "thinking")
|
||||
} else {
|
||||
("text_delta", "text")
|
||||
};
|
||||
push_sse(
|
||||
body,
|
||||
"content_block_delta",
|
||||
&json!({
|
||||
"type": "content_block_delta",
|
||||
"index": block_index,
|
||||
"delta": {
|
||||
"type": delta_type,
|
||||
key: text,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
fn push_stream_tool_delta(state: &mut OpenAiStreamState, call: &Value) {
|
||||
let openai_index = call
|
||||
.get("index")
|
||||
.and_then(|value| value.as_i64())
|
||||
.unwrap_or(0);
|
||||
let block = state.tool_blocks.entry(openai_index).or_default();
|
||||
if let Some(id) = call.get("id").and_then(|value| value.as_str()) {
|
||||
block.id = Some(normalized_tool_call_id(Some(id)));
|
||||
}
|
||||
let function = call.get("function").unwrap_or(&Value::Null);
|
||||
if let Some(name) = function.get("name").and_then(|value| value.as_str()) {
|
||||
if !name.is_empty() {
|
||||
block.name = Some(name.to_string());
|
||||
}
|
||||
}
|
||||
if let Some(arguments) = function.get("arguments").and_then(|value| value.as_str()) {
|
||||
block.buffered_arguments.push_str(arguments);
|
||||
}
|
||||
}
|
||||
|
||||
fn close_thinking_block_if_open(state: &mut OpenAiStreamState, body: &mut String) {
|
||||
if let Some(index) = state.thinking_block_index.take() {
|
||||
push_sse(
|
||||
body,
|
||||
"content_block_delta",
|
||||
&json!({
|
||||
"type": "content_block_delta",
|
||||
"index": index,
|
||||
"delta": {
|
||||
"type": "signature_delta",
|
||||
"signature": format!("ccr_{}", uuid::Uuid::new_v4().simple()),
|
||||
},
|
||||
}),
|
||||
);
|
||||
push_content_block_stop(body, index);
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_anthropic_stream(state: &mut OpenAiStreamState) -> String {
|
||||
if state.completed {
|
||||
return String::new();
|
||||
}
|
||||
state.completed = true;
|
||||
let mut body = String::new();
|
||||
if !state.message_started {
|
||||
state.message_started = true;
|
||||
let message_id = format!("msg_{}", uuid::Uuid::new_v4().simple());
|
||||
state.message_id = Some(message_id.clone());
|
||||
push_sse(
|
||||
&mut body,
|
||||
"message_start",
|
||||
&json!({
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"id": message_id,
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": state.model.clone().unwrap_or_else(|| "claude-prism-proxy".to_string()),
|
||||
"content": [],
|
||||
"stop_reason": Value::Null,
|
||||
"stop_sequence": Value::Null,
|
||||
"usage": {
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
close_thinking_block_if_open(state, &mut body);
|
||||
if let Some(index) = state.text_block_index.take() {
|
||||
push_content_block_stop(&mut body, index);
|
||||
}
|
||||
|
||||
let mut tool_blocks = state
|
||||
.tool_blocks
|
||||
.iter()
|
||||
.map(|(openai_index, block)| (*openai_index, block))
|
||||
.collect::<Vec<_>>();
|
||||
tool_blocks.sort_by_key(|(openai_index, _)| *openai_index);
|
||||
let exit_tool_response = if tool_blocks
|
||||
.iter()
|
||||
.all(|(_, block)| block.name.as_deref() == Some("ExitTool"))
|
||||
{
|
||||
tool_blocks
|
||||
.iter()
|
||||
.find_map(|(_, block)| exit_tool_response(&block.buffered_arguments))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(response) = exit_tool_response {
|
||||
push_stream_text_delta(state, &mut body, "text", &response);
|
||||
if let Some(index) = state.text_block_index.take() {
|
||||
push_content_block_stop(&mut body, index);
|
||||
}
|
||||
state.tool_blocks.clear();
|
||||
state.stop_reason = Some("end_turn".to_string());
|
||||
}
|
||||
let mut tool_blocks = state
|
||||
.tool_blocks
|
||||
.iter()
|
||||
.map(|(openai_index, block)| (*openai_index, block))
|
||||
.collect::<Vec<_>>();
|
||||
tool_blocks.sort_by_key(|(openai_index, _)| *openai_index);
|
||||
for (_, block) in tool_blocks {
|
||||
let index = state.next_block_index;
|
||||
state.next_block_index += 1;
|
||||
push_sse(
|
||||
&mut body,
|
||||
"content_block_start",
|
||||
&json!({
|
||||
"type": "content_block_start",
|
||||
"index": index,
|
||||
"content_block": {
|
||||
"type": "tool_use",
|
||||
"id": block.id.clone().unwrap_or_else(|| normalized_tool_call_id(None)),
|
||||
"name": block.name.clone().unwrap_or_else(|| "unknown".to_string()),
|
||||
"input": {},
|
||||
},
|
||||
}),
|
||||
);
|
||||
let repaired_arguments = repair_tool_arguments(&block.buffered_arguments);
|
||||
if repaired_arguments != "{}" || !block.buffered_arguments.trim().is_empty() {
|
||||
push_sse(
|
||||
&mut body,
|
||||
"content_block_delta",
|
||||
&json!({
|
||||
"type": "content_block_delta",
|
||||
"index": index,
|
||||
"delta": {
|
||||
"type": "input_json_delta",
|
||||
"partial_json": repaired_arguments,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
push_content_block_stop(&mut body, index);
|
||||
}
|
||||
let stop_reason = if state.tool_blocks.is_empty() {
|
||||
state
|
||||
.stop_reason
|
||||
.clone()
|
||||
.unwrap_or_else(|| "end_turn".to_string())
|
||||
} else {
|
||||
"tool_use".to_string()
|
||||
};
|
||||
push_sse(
|
||||
&mut body,
|
||||
"message_delta",
|
||||
&json!({
|
||||
"type": "message_delta",
|
||||
"delta": {
|
||||
"stop_reason": stop_reason,
|
||||
"stop_sequence": Value::Null,
|
||||
},
|
||||
"usage": {
|
||||
"output_tokens": state.output_tokens,
|
||||
},
|
||||
}),
|
||||
);
|
||||
push_sse(
|
||||
&mut body,
|
||||
"message_stop",
|
||||
&json!({ "type": "message_stop" }),
|
||||
);
|
||||
body
|
||||
}
|
||||
|
||||
fn exit_tool_response(arguments: &str) -> Option<String> {
|
||||
let repaired = repair_tool_arguments(arguments);
|
||||
serde_json::from_str::<Value>(&repaired)
|
||||
.ok()
|
||||
.and_then(|value| {
|
||||
value
|
||||
.get("response")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(str::to_string)
|
||||
})
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
}
|
||||
|
||||
fn push_content_block_stop(body: &mut String, index: usize) {
|
||||
push_sse(
|
||||
body,
|
||||
"content_block_stop",
|
||||
&json!({
|
||||
"type": "content_block_stop",
|
||||
"index": index,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
fn map_openai_finish_reason(reason: &str) -> &str {
|
||||
match reason {
|
||||
"length" => "max_tokens",
|
||||
"tool_calls" => "tool_use",
|
||||
_ => "end_turn",
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn sse_response(message: &Value) -> String {
|
||||
let content = message
|
||||
.get("content")
|
||||
.and_then(|value| value.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let input_tokens = message
|
||||
.pointer("/usage/input_tokens")
|
||||
.and_then(|value| value.as_u64())
|
||||
.unwrap_or(0);
|
||||
let output_tokens = message
|
||||
.pointer("/usage/output_tokens")
|
||||
.and_then(|value| value.as_u64())
|
||||
.unwrap_or(0);
|
||||
|
||||
let start = json!({
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"id": message.get("id").cloned().unwrap_or_else(|| json!(format!("msg_{}", uuid::Uuid::new_v4().simple()))),
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": message.get("model").cloned().unwrap_or_else(|| json!("claude-prism-proxy")),
|
||||
"content": [],
|
||||
"stop_reason": Value::Null,
|
||||
"stop_sequence": Value::Null,
|
||||
"usage": {
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let mut body = String::new();
|
||||
push_sse(&mut body, "message_start", &start);
|
||||
for (index, block) in content.iter().enumerate() {
|
||||
let block_type = block
|
||||
.get("type")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("text");
|
||||
match block_type {
|
||||
"tool_use" => {
|
||||
let content_block = json!({
|
||||
"type": "tool_use",
|
||||
"id": block.get("id").cloned().unwrap_or_else(|| json!(format!("toolu_{}", uuid::Uuid::new_v4().simple()))),
|
||||
"name": block.get("name").cloned().unwrap_or_else(|| json!("unknown")),
|
||||
"input": {},
|
||||
});
|
||||
push_sse(
|
||||
&mut body,
|
||||
"content_block_start",
|
||||
&json!({
|
||||
"type": "content_block_start",
|
||||
"index": index,
|
||||
"content_block": content_block,
|
||||
}),
|
||||
);
|
||||
let input = block.get("input").cloned().unwrap_or_else(|| json!({}));
|
||||
push_sse(
|
||||
&mut body,
|
||||
"content_block_delta",
|
||||
&json!({
|
||||
"type": "content_block_delta",
|
||||
"index": index,
|
||||
"delta": {
|
||||
"type": "input_json_delta",
|
||||
"partial_json": input.to_string(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
"thinking" => {
|
||||
push_text_like_sse_block(
|
||||
&mut body,
|
||||
index,
|
||||
"thinking",
|
||||
block
|
||||
.get("thinking")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
push_text_like_sse_block(
|
||||
&mut body,
|
||||
index,
|
||||
"text",
|
||||
block
|
||||
.get("text")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
}
|
||||
push_content_block_stop(&mut body, index);
|
||||
}
|
||||
push_sse(
|
||||
&mut body,
|
||||
"message_delta",
|
||||
&json!({
|
||||
"type": "message_delta",
|
||||
"delta": {
|
||||
"stop_reason": message.get("stop_reason").cloned().unwrap_or_else(|| json!("end_turn")),
|
||||
"stop_sequence": Value::Null,
|
||||
},
|
||||
"usage": {
|
||||
"output_tokens": output_tokens,
|
||||
},
|
||||
}),
|
||||
);
|
||||
push_sse(
|
||||
&mut body,
|
||||
"message_stop",
|
||||
&json!({ "type": "message_stop" }),
|
||||
);
|
||||
|
||||
http_response(
|
||||
200,
|
||||
"text/event-stream; charset=utf-8",
|
||||
&format!("{}{}", body, "\n"),
|
||||
)
|
||||
}
|
||||
|
||||
fn push_text_like_sse_block(body: &mut String, index: usize, block_type: &str, text: &str) {
|
||||
let content_block = if block_type == "thinking" {
|
||||
json!({
|
||||
"type": "thinking",
|
||||
"thinking": "",
|
||||
})
|
||||
} else {
|
||||
json!({
|
||||
"type": "text",
|
||||
"text": "",
|
||||
})
|
||||
};
|
||||
push_sse(
|
||||
body,
|
||||
"content_block_start",
|
||||
&json!({
|
||||
"type": "content_block_start",
|
||||
"index": index,
|
||||
"content_block": content_block,
|
||||
}),
|
||||
);
|
||||
if !text.is_empty() {
|
||||
let delta_type = if block_type == "thinking" {
|
||||
"thinking_delta"
|
||||
} else {
|
||||
"text_delta"
|
||||
};
|
||||
let delta_key = if block_type == "thinking" {
|
||||
"thinking"
|
||||
} else {
|
||||
"text"
|
||||
};
|
||||
push_sse(
|
||||
body,
|
||||
"content_block_delta",
|
||||
&json!({
|
||||
"type": "content_block_delta",
|
||||
"index": index,
|
||||
"delta": {
|
||||
"type": delta_type,
|
||||
delta_key: text,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
if block_type == "thinking" {
|
||||
push_sse(
|
||||
body,
|
||||
"content_block_delta",
|
||||
&json!({
|
||||
"type": "content_block_delta",
|
||||
"index": index,
|
||||
"delta": {
|
||||
"type": "signature_delta",
|
||||
"signature": format!("ccr_{}", uuid::Uuid::new_v4().simple()),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn push_sse(body: &mut String, event: &str, data: &Value) {
|
||||
body.push_str("event: ");
|
||||
body.push_str(event);
|
||||
body.push('\n');
|
||||
body.push_str("data: ");
|
||||
body.push_str(&data.to_string());
|
||||
body.push_str("\n\n");
|
||||
}
|
||||
|
||||
fn usage_token(usage: &Value, keys: &[&str]) -> u64 {
|
||||
keys.iter()
|
||||
.find_map(|key| usage.get(*key).and_then(|value| value.as_u64()))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn credential() -> OpenAiProxyCredential {
|
||||
OpenAiProxyCredential {
|
||||
api_key: "sk-test".to_string(),
|
||||
base_url: "https://api.example.com/v1".to_string(),
|
||||
model: "qwen-test".to_string(),
|
||||
transformers: Vec::new(),
|
||||
model_transformers: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_provider_stream_errors_as_anthropic_sse_errors() {
|
||||
let rendered = anthropic_stream_error_sse("provider stream broke");
|
||||
|
||||
assert!(rendered.contains("event: error"));
|
||||
assert!(rendered.contains("\"type\":\"error\""));
|
||||
assert!(rendered.contains("\"type\":\"api_error\""));
|
||||
assert!(rendered.contains("\"message\":\"provider stream broke\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streams_openai_text_delta_as_anthropic_sse() {
|
||||
let request = json!({ "model": "claude-sonnet-4" });
|
||||
let mut state = OpenAiStreamState::default();
|
||||
let chunk = json!({
|
||||
"id": "chatcmpl_1",
|
||||
"model": "qwen-test",
|
||||
"choices": [{
|
||||
"delta": { "content": "Hello" },
|
||||
"finish_reason": null
|
||||
}]
|
||||
});
|
||||
|
||||
let first = openai_stream_chunk_to_anthropic(&mut state, &chunk, &request, &credential());
|
||||
let done = finish_anthropic_stream(&mut state);
|
||||
let combined = format!("{}{}", first, done);
|
||||
|
||||
assert!(combined.contains("event: message_start"));
|
||||
assert!(combined.contains("\"model\":\"claude-sonnet-4\""));
|
||||
assert!(combined.contains("\"type\":\"text_delta\""));
|
||||
assert!(combined.contains("\"text\":\"Hello\""));
|
||||
assert!(combined.contains("\"stop_reason\":\"end_turn\""));
|
||||
assert!(finish_anthropic_stream(&mut state).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streams_reasoning_content_as_thinking_delta() {
|
||||
let request = json!({ "model": "claude-sonnet-4" });
|
||||
let mut state = OpenAiStreamState::default();
|
||||
let chunk = json!({
|
||||
"id": "chatcmpl_1",
|
||||
"choices": [{
|
||||
"delta": { "reasoning_content": "I should inspect files." },
|
||||
"finish_reason": null
|
||||
}]
|
||||
});
|
||||
|
||||
let rendered =
|
||||
openai_stream_chunk_to_anthropic(&mut state, &chunk, &request, &credential());
|
||||
|
||||
assert!(rendered.contains("\"type\":\"thinking\""));
|
||||
assert!(rendered.contains("\"type\":\"thinking_delta\""));
|
||||
assert!(rendered.contains("\"thinking\":\"I should inspect files.\""));
|
||||
let done = finish_anthropic_stream(&mut state);
|
||||
assert!(done.contains("\"type\":\"signature_delta\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buffers_and_repairs_streamed_tool_arguments() {
|
||||
let request = json!({ "model": "claude-sonnet-4" });
|
||||
let mut state = OpenAiStreamState::default();
|
||||
let first_chunk = json!({
|
||||
"id": "chatcmpl_1",
|
||||
"choices": [{
|
||||
"delta": {
|
||||
"tool_calls": [{
|
||||
"index": 0,
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "Read",
|
||||
"arguments": "{\"file_path\":"
|
||||
}
|
||||
}]
|
||||
},
|
||||
"finish_reason": null
|
||||
}]
|
||||
});
|
||||
let second_chunk = json!({
|
||||
"id": "chatcmpl_1",
|
||||
"choices": [{
|
||||
"delta": {
|
||||
"tool_calls": [{
|
||||
"index": 0,
|
||||
"function": { "arguments": "\"main.tex\"" }
|
||||
}]
|
||||
},
|
||||
"finish_reason": "tool_calls"
|
||||
}]
|
||||
});
|
||||
|
||||
let first =
|
||||
openai_stream_chunk_to_anthropic(&mut state, &first_chunk, &request, &credential());
|
||||
let second =
|
||||
openai_stream_chunk_to_anthropic(&mut state, &second_chunk, &request, &credential());
|
||||
let done = finish_anthropic_stream(&mut state);
|
||||
let combined = format!("{}{}{}", first, second, done);
|
||||
|
||||
assert!(!first.contains("\"type\":\"tool_use\""));
|
||||
assert!(!second.contains("\"type\":\"tool_use\""));
|
||||
assert!(combined.contains("\"type\":\"tool_use\""));
|
||||
assert!(combined.contains("\"id\":\"call_1\""));
|
||||
assert!(combined.contains("\"name\":\"Read\""));
|
||||
assert!(combined.contains("\"type\":\"input_json_delta\""));
|
||||
assert!(combined.contains("{\\\"file_path\\\":\\\"main.tex\\\"}"));
|
||||
assert!(combined.contains("\"stop_reason\":\"tool_use\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_numeric_streamed_tool_call_ids() {
|
||||
let request = json!({ "model": "claude-sonnet-4" });
|
||||
let mut state = OpenAiStreamState::default();
|
||||
let chunk = json!({
|
||||
"id": "chatcmpl_1",
|
||||
"choices": [{
|
||||
"delta": {
|
||||
"tool_calls": [{
|
||||
"index": 0,
|
||||
"id": "123",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "Read",
|
||||
"arguments": "{\"file_path\":\"main.tex\"}"
|
||||
}
|
||||
}]
|
||||
},
|
||||
"finish_reason": "tool_calls"
|
||||
}]
|
||||
});
|
||||
|
||||
openai_stream_chunk_to_anthropic(&mut state, &chunk, &request, &credential());
|
||||
let done = finish_anthropic_stream(&mut state);
|
||||
|
||||
assert!(done.contains("\"id\":\"call_"));
|
||||
assert!(!done.contains("\"id\":\"123\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streams_exit_tool_as_final_text() {
|
||||
let request = json!({ "model": "claude-sonnet-4" });
|
||||
let mut state = OpenAiStreamState::default();
|
||||
let chunk = json!({
|
||||
"id": "chatcmpl_1",
|
||||
"choices": [{
|
||||
"delta": {
|
||||
"tool_calls": [{
|
||||
"index": 0,
|
||||
"id": "call_exit",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "ExitTool",
|
||||
"arguments": "{\"response\":\"all done\"}"
|
||||
}
|
||||
}]
|
||||
},
|
||||
"finish_reason": "tool_calls"
|
||||
}]
|
||||
});
|
||||
|
||||
openai_stream_chunk_to_anthropic(&mut state, &chunk, &request, &credential());
|
||||
let done = finish_anthropic_stream(&mut state);
|
||||
|
||||
assert!(done.contains("\"type\":\"text_delta\""));
|
||||
assert!(done.contains("\"text\":\"all done\""));
|
||||
assert!(done.contains("\"stop_reason\":\"end_turn\""));
|
||||
assert!(!done.contains("\"type\":\"tool_use\""));
|
||||
}
|
||||
}
|
||||
561
apps/desktop/src-tauri/src/anthropic_proxy/tools.rs
Normal file
|
|
@ -0,0 +1,561 @@
|
|||
use serde_json::{json, Value};
|
||||
|
||||
pub(super) fn repaired_tool_arguments_value(arguments: &str) -> Value {
|
||||
serde_json::from_str::<Value>(&repair_tool_arguments(arguments)).unwrap_or_else(|_| json!({}))
|
||||
}
|
||||
|
||||
pub(super) fn normalized_tool_call_id(id: Option<&str>) -> String {
|
||||
let id = id.unwrap_or_default().trim();
|
||||
if id.is_empty() || id.chars().all(|ch| ch.is_ascii_digit()) {
|
||||
format!("call_{}", uuid::Uuid::new_v4().simple())
|
||||
} else {
|
||||
id.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn repair_tool_arguments(arguments: &str) -> String {
|
||||
let trimmed = trim_code_fence(arguments.trim());
|
||||
if trimmed.is_empty() || trimmed == "{}" {
|
||||
return "{}".to_string();
|
||||
}
|
||||
|
||||
let mut candidates = Vec::new();
|
||||
push_candidate(&mut candidates, trimmed.to_string());
|
||||
if let Some(extracted) = extract_json_like(trimmed) {
|
||||
push_candidate(&mut candidates, extracted);
|
||||
}
|
||||
|
||||
let seeds = candidates.clone();
|
||||
for candidate in seeds {
|
||||
let without_comments = strip_json_comments(&candidate);
|
||||
push_candidate(&mut candidates, without_comments.clone());
|
||||
|
||||
let without_trailing_commas = remove_trailing_commas(&without_comments);
|
||||
push_candidate(&mut candidates, without_trailing_commas.clone());
|
||||
|
||||
let json5_like =
|
||||
normalize_single_quoted_strings("e_unquoted_object_keys(&without_trailing_commas));
|
||||
push_candidate(&mut candidates, json5_like.clone());
|
||||
|
||||
if let Some(with_commas) = insert_missing_commas_between_fields(&json5_like) {
|
||||
push_candidate(&mut candidates, with_commas.clone());
|
||||
if let Some(balanced) = repair_balanced_json(with_commas) {
|
||||
push_candidate(&mut candidates, balanced);
|
||||
}
|
||||
}
|
||||
if let Some(balanced) = repair_balanced_json(json5_like) {
|
||||
push_candidate(&mut candidates, balanced);
|
||||
}
|
||||
if let Some(balanced) = repair_balanced_json(without_trailing_commas) {
|
||||
push_candidate(&mut candidates, balanced);
|
||||
}
|
||||
}
|
||||
|
||||
for candidate in candidates {
|
||||
if let Some(repaired) = parse_tool_arguments_candidate(&candidate) {
|
||||
return repaired;
|
||||
}
|
||||
}
|
||||
|
||||
"{}".to_string()
|
||||
}
|
||||
|
||||
fn push_candidate(candidates: &mut Vec<String>, value: String) {
|
||||
let value = value.trim().to_string();
|
||||
if value.is_empty() || candidates.iter().any(|candidate| candidate == &value) {
|
||||
return;
|
||||
}
|
||||
candidates.push(value);
|
||||
}
|
||||
|
||||
fn parse_tool_arguments_candidate(value: &str) -> Option<String> {
|
||||
serde_json::from_str::<Value>(value)
|
||||
.ok()
|
||||
.or_else(|| serde_yaml::from_str::<Value>(value).ok())
|
||||
.and_then(canonical_tool_arguments)
|
||||
}
|
||||
|
||||
fn canonical_tool_arguments(value: Value) -> Option<String> {
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
if map.keys().any(|key| key.contains(':')) {
|
||||
return None;
|
||||
}
|
||||
Some(Value::Object(map).to_string())
|
||||
}
|
||||
Value::Array(_) => Some(value.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn trim_code_fence(value: &str) -> &str {
|
||||
let value = value.trim();
|
||||
if !value.starts_with("```") {
|
||||
return value;
|
||||
}
|
||||
let Some(first_newline) = value.find('\n') else {
|
||||
return value;
|
||||
};
|
||||
let value = &value[first_newline + 1..];
|
||||
value
|
||||
.strip_suffix("```")
|
||||
.map(str::trim)
|
||||
.unwrap_or(value.trim())
|
||||
}
|
||||
|
||||
fn extract_json_like(value: &str) -> Option<String> {
|
||||
let object_start = value.find('{');
|
||||
let array_start = value.find('[');
|
||||
let start = match (object_start, array_start) {
|
||||
(Some(object), Some(array)) => object.min(array),
|
||||
(Some(object), None) => object,
|
||||
(None, Some(array)) => array,
|
||||
(None, None) => return None,
|
||||
};
|
||||
let end = value.rfind('}').or_else(|| value.rfind(']'))?;
|
||||
if end <= start {
|
||||
return None;
|
||||
}
|
||||
Some(value[start..=end].to_string())
|
||||
}
|
||||
|
||||
fn repair_balanced_json(value: String) -> Option<String> {
|
||||
let mut output = String::with_capacity(value.len() + 8);
|
||||
let mut stack = Vec::new();
|
||||
let mut in_string = false;
|
||||
let mut escaped = false;
|
||||
|
||||
for ch in value.chars() {
|
||||
output.push(ch);
|
||||
if in_string {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if ch == '\\' {
|
||||
escaped = true;
|
||||
} else if ch == '"' {
|
||||
in_string = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
match ch {
|
||||
'"' => in_string = true,
|
||||
'{' => stack.push('}'),
|
||||
'[' => stack.push(']'),
|
||||
'}' | ']' => {
|
||||
if stack.last().copied() == Some(ch) {
|
||||
stack.pop();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if in_string {
|
||||
output.push('"');
|
||||
}
|
||||
while let Some(ch) = stack.pop() {
|
||||
output.push(ch);
|
||||
}
|
||||
Some(output)
|
||||
}
|
||||
|
||||
fn remove_trailing_commas(value: &str) -> String {
|
||||
let mut output = String::with_capacity(value.len());
|
||||
let mut chars = value.chars().peekable();
|
||||
let mut in_string = false;
|
||||
let mut escaped = false;
|
||||
|
||||
while let Some(ch) = chars.next() {
|
||||
if in_string {
|
||||
output.push(ch);
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if ch == '\\' {
|
||||
escaped = true;
|
||||
} else if ch == '"' {
|
||||
in_string = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ch == '"' {
|
||||
in_string = true;
|
||||
output.push(ch);
|
||||
continue;
|
||||
}
|
||||
|
||||
if ch == ',' {
|
||||
let mut lookahead = chars.clone();
|
||||
while matches!(lookahead.peek(), Some(next) if next.is_whitespace()) {
|
||||
lookahead.next();
|
||||
}
|
||||
if matches!(lookahead.peek(), Some('}' | ']')) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
output.push(ch);
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
fn strip_json_comments(value: &str) -> String {
|
||||
let mut output = String::with_capacity(value.len());
|
||||
let mut chars = value.chars().peekable();
|
||||
let mut in_double_string = false;
|
||||
let mut in_single_string = false;
|
||||
let mut escaped = false;
|
||||
|
||||
while let Some(ch) = chars.next() {
|
||||
if in_double_string || in_single_string {
|
||||
output.push(ch);
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if ch == '\\' {
|
||||
escaped = true;
|
||||
} else if in_double_string && ch == '"' {
|
||||
in_double_string = false;
|
||||
} else if in_single_string && ch == '\'' {
|
||||
in_single_string = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
match ch {
|
||||
'"' => {
|
||||
in_double_string = true;
|
||||
output.push(ch);
|
||||
}
|
||||
'\'' => {
|
||||
in_single_string = true;
|
||||
output.push(ch);
|
||||
}
|
||||
'/' if chars.peek() == Some(&'/') => {
|
||||
chars.next();
|
||||
for next in chars.by_ref() {
|
||||
if next == '\n' {
|
||||
output.push('\n');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
'/' if chars.peek() == Some(&'*') => {
|
||||
chars.next();
|
||||
let mut previous = '\0';
|
||||
for next in chars.by_ref() {
|
||||
if previous == '*' && next == '/' {
|
||||
break;
|
||||
}
|
||||
previous = next;
|
||||
}
|
||||
}
|
||||
_ => output.push(ch),
|
||||
}
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
fn quote_unquoted_object_keys(value: &str) -> String {
|
||||
let mut output = String::with_capacity(value.len() + 16);
|
||||
let chars = value.chars().collect::<Vec<_>>();
|
||||
let mut index = 0;
|
||||
let mut in_double_string = false;
|
||||
let mut in_single_string = false;
|
||||
let mut escaped = false;
|
||||
let mut expects_key = false;
|
||||
|
||||
while index < chars.len() {
|
||||
let ch = chars[index];
|
||||
if in_double_string || in_single_string {
|
||||
output.push(ch);
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if ch == '\\' {
|
||||
escaped = true;
|
||||
} else if in_double_string && ch == '"' {
|
||||
in_double_string = false;
|
||||
} else if in_single_string && ch == '\'' {
|
||||
in_single_string = false;
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
match ch {
|
||||
'"' => {
|
||||
in_double_string = true;
|
||||
output.push(ch);
|
||||
expects_key = false;
|
||||
index += 1;
|
||||
}
|
||||
'\'' => {
|
||||
in_single_string = true;
|
||||
output.push(ch);
|
||||
expects_key = false;
|
||||
index += 1;
|
||||
}
|
||||
'{' | ',' => {
|
||||
expects_key = true;
|
||||
output.push(ch);
|
||||
index += 1;
|
||||
}
|
||||
'}' | ']' => {
|
||||
expects_key = false;
|
||||
output.push(ch);
|
||||
index += 1;
|
||||
}
|
||||
ch if expects_key && ch.is_whitespace() => {
|
||||
output.push(ch);
|
||||
index += 1;
|
||||
}
|
||||
ch if expects_key && is_identifier_start(ch) => {
|
||||
let start = index;
|
||||
index += 1;
|
||||
while index < chars.len() && is_identifier_continue(chars[index]) {
|
||||
index += 1;
|
||||
}
|
||||
let mut lookahead = index;
|
||||
while lookahead < chars.len() && chars[lookahead].is_whitespace() {
|
||||
lookahead += 1;
|
||||
}
|
||||
if lookahead < chars.len() && chars[lookahead] == ':' {
|
||||
output.push('"');
|
||||
for key_ch in &chars[start..index] {
|
||||
output.push(*key_ch);
|
||||
}
|
||||
output.push('"');
|
||||
expects_key = false;
|
||||
} else {
|
||||
for key_ch in &chars[start..index] {
|
||||
output.push(*key_ch);
|
||||
}
|
||||
expects_key = false;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
output.push(ch);
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
fn normalize_single_quoted_strings(value: &str) -> String {
|
||||
let mut output = String::with_capacity(value.len());
|
||||
let mut chars = value.chars().peekable();
|
||||
let mut in_double_string = false;
|
||||
let mut in_single_string = false;
|
||||
let mut escaped = false;
|
||||
|
||||
while let Some(ch) = chars.next() {
|
||||
if in_double_string {
|
||||
output.push(ch);
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if ch == '\\' {
|
||||
escaped = true;
|
||||
} else if ch == '"' {
|
||||
in_double_string = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if in_single_string {
|
||||
if escaped {
|
||||
match ch {
|
||||
'\'' => output.push('\''),
|
||||
'"' => {
|
||||
output.push('\\');
|
||||
output.push('"');
|
||||
}
|
||||
'\\' => output.push('\\'),
|
||||
_ => {
|
||||
output.push('\\');
|
||||
output.push(ch);
|
||||
}
|
||||
}
|
||||
escaped = false;
|
||||
} else if ch == '\\' {
|
||||
escaped = true;
|
||||
} else if ch == '\'' {
|
||||
output.push('"');
|
||||
in_single_string = false;
|
||||
} else if ch == '"' {
|
||||
output.push('\\');
|
||||
output.push('"');
|
||||
} else {
|
||||
output.push(ch);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ch == '"' {
|
||||
in_double_string = true;
|
||||
output.push(ch);
|
||||
} else if ch == '\'' {
|
||||
in_single_string = true;
|
||||
output.push('"');
|
||||
} else {
|
||||
output.push(ch);
|
||||
}
|
||||
}
|
||||
|
||||
if in_single_string {
|
||||
output.push('"');
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
fn insert_missing_commas_between_fields(value: &str) -> Option<String> {
|
||||
let mut output = String::with_capacity(value.len() + 8);
|
||||
let chars = value.chars().collect::<Vec<_>>();
|
||||
let mut index = 0;
|
||||
let mut changed = false;
|
||||
let mut in_string = false;
|
||||
let mut escaped = false;
|
||||
|
||||
while index < chars.len() {
|
||||
let ch = chars[index];
|
||||
output.push(ch);
|
||||
if in_string {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if ch == '\\' {
|
||||
escaped = true;
|
||||
} else if ch == '"' {
|
||||
in_string = false;
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ch == '"' {
|
||||
in_string = true;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if matches!(ch, '"' | '}' | ']' | '0'..='9' | 'e' | 'E' | 'l') {
|
||||
let mut lookahead = index + 1;
|
||||
while lookahead < chars.len() && chars[lookahead].is_whitespace() {
|
||||
lookahead += 1;
|
||||
}
|
||||
if lookahead < chars.len()
|
||||
&& chars[lookahead] == '"'
|
||||
&& previous_non_whitespace(&chars, index) != Some(':')
|
||||
{
|
||||
output.push(',');
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
|
||||
changed.then_some(output)
|
||||
}
|
||||
|
||||
fn previous_non_whitespace(chars: &[char], index: usize) -> Option<char> {
|
||||
if index == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut cursor = index - 1;
|
||||
loop {
|
||||
if !chars[cursor].is_whitespace() {
|
||||
return Some(chars[cursor]);
|
||||
}
|
||||
if cursor == 0 {
|
||||
return None;
|
||||
}
|
||||
cursor -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn is_identifier_start(ch: char) -> bool {
|
||||
ch == '_' || ch == '$' || ch.is_ascii_alphabetic()
|
||||
}
|
||||
|
||||
fn is_identifier_continue(ch: char) -> bool {
|
||||
is_identifier_start(ch) || ch.is_ascii_digit() || ch == '-' || ch == '.'
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn repairs_partial_tool_arguments() {
|
||||
assert_eq!(
|
||||
repair_tool_arguments("{\"file_path\":\"main.tex\""),
|
||||
"{\"file_path\":\"main.tex\"}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repairs_fenced_tool_arguments() {
|
||||
assert_eq!(
|
||||
repair_tool_arguments("```json\n{\"pattern\":\"FastVID\",}\n```"),
|
||||
"{\"pattern\":\"FastVID\"}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repairs_json5_style_tool_arguments_like_ccr_enhancetool() {
|
||||
let repaired: Value = serde_json::from_str(&repair_tool_arguments(
|
||||
"{file_path:'main.tex', replace_all:false,}",
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
repaired,
|
||||
json!({ "file_path": "main.tex", "replace_all": false })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repairs_commented_tool_arguments() {
|
||||
let repaired: Value = serde_json::from_str(&repair_tool_arguments(
|
||||
"{\n // target file\n file_path: 'main.tex',\n old_string: 'A',\n new_string: 'B',\n}",
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
repaired,
|
||||
json!({ "file_path": "main.tex", "old_string": "A", "new_string": "B" })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repairs_mixed_quote_tool_arguments() {
|
||||
let repaired: Value = serde_json::from_str(&repair_tool_arguments(
|
||||
"{\"file_path\": 'main.tex', \"pattern\": 'FastVID'}",
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
repaired,
|
||||
json!({ "file_path": "main.tex", "pattern": "FastVID" })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_empty_object_for_unrepairable_arguments() {
|
||||
assert_eq!(repair_tool_arguments("not json at all"), "{}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_numeric_tool_call_ids() {
|
||||
let id = normalized_tool_call_id(Some("123"));
|
||||
|
||||
assert!(id.starts_with("call_"));
|
||||
assert_ne!(id, "123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_provider_tool_call_ids() {
|
||||
assert_eq!(normalized_tool_call_id(Some("call_abc")), "call_abc");
|
||||
}
|
||||
}
|
||||
148
apps/desktop/src-tauri/src/anthropic_proxy/transformers.rs
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
use super::OpenAiProxyCredential;
|
||||
|
||||
const CLEANCACHE: &str = "cleancache";
|
||||
const DEEPSEEK: &str = "deepseek";
|
||||
const ENHANCETOOL: &str = "enhancetool";
|
||||
const STREAMOPTIONS: &str = "streamoptions";
|
||||
const TOOLUSE: &str = "tooluse";
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub(super) struct ProxyTransformerChain {
|
||||
names: Vec<String>,
|
||||
}
|
||||
|
||||
impl ProxyTransformerChain {
|
||||
pub(super) fn for_credential(credential: &OpenAiProxyCredential, wants_stream: bool) -> Self {
|
||||
let mut chain = Self::default();
|
||||
chain.push(CLEANCACHE);
|
||||
if wants_stream {
|
||||
chain.push(STREAMOPTIONS);
|
||||
}
|
||||
if is_deepseek_credential(credential) {
|
||||
chain.push(DEEPSEEK);
|
||||
}
|
||||
|
||||
// ClaudePrism already buffers and repairs tool-call arguments before
|
||||
// returning them to Claude Code. Naming it here keeps the behavior
|
||||
// traceable to Claude Code Router's enhancetool transformer.
|
||||
chain.push(ENHANCETOOL);
|
||||
for name in &credential.transformers {
|
||||
chain.push(name);
|
||||
}
|
||||
for name in &credential.model_transformers {
|
||||
chain.push(name);
|
||||
}
|
||||
for name in configured_transformer_names() {
|
||||
chain.push(&name);
|
||||
}
|
||||
chain
|
||||
}
|
||||
|
||||
pub(super) fn has(&self, name: &str) -> bool {
|
||||
self.names
|
||||
.iter()
|
||||
.any(|candidate| candidate.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
pub(super) fn has_tooluse(&self) -> bool {
|
||||
self.has(TOOLUSE)
|
||||
}
|
||||
|
||||
pub(super) fn has_cleancache(&self) -> bool {
|
||||
self.has(CLEANCACHE)
|
||||
}
|
||||
|
||||
pub(super) fn has_deepseek(&self) -> bool {
|
||||
self.has(DEEPSEEK)
|
||||
}
|
||||
|
||||
pub(super) fn has_streamoptions(&self) -> bool {
|
||||
self.has(STREAMOPTIONS)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn from_names(names: &[&str]) -> Self {
|
||||
let mut chain = Self::default();
|
||||
for name in names {
|
||||
chain.push(name);
|
||||
}
|
||||
chain
|
||||
}
|
||||
|
||||
fn push(&mut self, name: &str) {
|
||||
let name = name.trim();
|
||||
if name.is_empty() || self.has(name) {
|
||||
return;
|
||||
}
|
||||
self.names.push(name.to_ascii_lowercase());
|
||||
}
|
||||
}
|
||||
|
||||
fn configured_transformer_names() -> Vec<String> {
|
||||
std::env::var("CLAUDE_PRISM_PROXY_TRANSFORMERS")
|
||||
.ok()
|
||||
.into_iter()
|
||||
.flat_map(|value| {
|
||||
value
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_deepseek_credential(credential: &OpenAiProxyCredential) -> bool {
|
||||
let base_url = credential.base_url.to_ascii_lowercase();
|
||||
let model = credential.model.to_ascii_lowercase();
|
||||
base_url.contains("deepseek") || model.contains("deepseek")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn credential(base_url: &str, model: &str) -> OpenAiProxyCredential {
|
||||
OpenAiProxyCredential {
|
||||
api_key: "sk-test".to_string(),
|
||||
base_url: base_url.to_string(),
|
||||
model: model.to_string(),
|
||||
transformers: Vec::new(),
|
||||
model_transformers: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn includes_ccr_style_defaults_for_common_provider_adapters() {
|
||||
let chain = ProxyTransformerChain::for_credential(
|
||||
&credential("https://api.deepseek.com", "deepseek-chat"),
|
||||
true,
|
||||
);
|
||||
|
||||
assert!(chain.has_cleancache());
|
||||
assert!(chain.has_streamoptions());
|
||||
assert!(chain.has_deepseek());
|
||||
assert!(chain.has(ENHANCETOOL));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_enable_tooluse_unless_configured() {
|
||||
let chain = ProxyTransformerChain::for_credential(
|
||||
&credential("https://api.example.com/v1", "qwen"),
|
||||
false,
|
||||
);
|
||||
|
||||
assert!(!chain.has_tooluse());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_explicit_model_transformers() {
|
||||
let mut credential = credential("https://api.example.com/v1", "qwen");
|
||||
credential.model_transformers = vec!["tooluse".to_string()];
|
||||
|
||||
let chain = ProxyTransformerChain::for_credential(&credential, false);
|
||||
|
||||
assert!(chain.has_tooluse());
|
||||
}
|
||||
}
|
||||
368
apps/desktop/src-tauri/src/claude_process.rs
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tauri::{Emitter, Manager, WebviewWindow};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::{Child, Command};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[cfg(windows)]
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ClaudeProcessState {
|
||||
pub processes: Arc<Mutex<HashMap<String, Child>>>,
|
||||
}
|
||||
|
||||
impl Default for ClaudeProcessState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
processes: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
struct ClaudeOutputEvent {
|
||||
tab_id: String,
|
||||
data: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
struct ClaudeCompleteEvent {
|
||||
tab_id: String,
|
||||
success: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
struct ClaudeErrorEvent {
|
||||
tab_id: String,
|
||||
data: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SpawnProviderMetadata {
|
||||
pub provider: &'static str,
|
||||
pub provider_credential_id: String,
|
||||
pub model: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ClaudeStopMode {
|
||||
/// User pressed Stop; terminate the run immediately.
|
||||
Terminate,
|
||||
/// User wants to guide the next turn; prefer a graceful interrupt so
|
||||
/// Claude Code can persist session state before the frontend resumes it.
|
||||
Interrupt,
|
||||
}
|
||||
|
||||
fn process_key(window_label: &str, tab_id: &str) -> String {
|
||||
format!("{}:{}", window_label, tab_id)
|
||||
}
|
||||
|
||||
/// Spawn the Claude CLI process and stream output via Tauri events.
|
||||
/// Events are emitted only to the originating window, tagged with tab_id.
|
||||
pub async fn spawn_claude_process(
|
||||
window: WebviewWindow,
|
||||
mut cmd: Command,
|
||||
tab_id: String,
|
||||
stdin_payload: Option<String>,
|
||||
provider_metadata: Option<SpawnProviderMetadata>,
|
||||
) -> Result<(), String> {
|
||||
let window_label = window.label().to_string();
|
||||
let process_key = process_key(&window_label, &tab_id);
|
||||
|
||||
if stdin_payload.is_some() {
|
||||
cmd.stdin(std::process::Stdio::piped());
|
||||
}
|
||||
|
||||
let mut child = cmd.spawn().map_err(|e| {
|
||||
eprintln!(
|
||||
"[claude-spawn] Failed to spawn process for tab {}: {}",
|
||||
tab_id, e
|
||||
);
|
||||
format!(
|
||||
"Failed to spawn Claude process: {}. Is Claude Code CLI installed?",
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
if let Some(payload) = stdin_payload {
|
||||
let mut stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| "Failed to acquire stdin for Claude process".to_string())?;
|
||||
stdin
|
||||
.write_all(payload.as_bytes())
|
||||
.await
|
||||
.map_err(|e| format!("Failed to write prompt to Claude process stdin: {}", e))?;
|
||||
stdin
|
||||
.shutdown()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to close Claude process stdin: {}", e))?;
|
||||
}
|
||||
|
||||
let stdout = child.stdout.take().ok_or("Failed to capture stdout")?;
|
||||
let stderr = child.stderr.take().ok_or("Failed to capture stderr")?;
|
||||
|
||||
let process_arc = window
|
||||
.state::<ClaudeProcessState>()
|
||||
.inner()
|
||||
.processes
|
||||
.clone();
|
||||
|
||||
{
|
||||
let mut processes = process_arc.lock().await;
|
||||
if let Some(mut existing) = processes.remove(&process_key) {
|
||||
let _ = existing.kill().await;
|
||||
}
|
||||
processes.insert(process_key.clone(), child);
|
||||
}
|
||||
|
||||
let stdout_reader = BufReader::new(stdout);
|
||||
let stderr_reader = BufReader::new(stderr);
|
||||
let result_success_holder: Arc<std::sync::Mutex<Option<bool>>> =
|
||||
Arc::new(std::sync::Mutex::new(None));
|
||||
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
let win_stdout = window.clone();
|
||||
let result_success_stdout = result_success_holder.clone();
|
||||
let tab_id_stdout = tab_id.clone();
|
||||
let provider_metadata_stdout = provider_metadata.clone();
|
||||
let stdout_task = tokio::spawn(async move {
|
||||
let mut lines = stdout_reader.lines();
|
||||
let mut line_count: u64 = 0;
|
||||
while let Ok(Some(mut line)) = lines.next_line().await {
|
||||
line_count += 1;
|
||||
let elapsed = start_time.elapsed().as_secs_f64();
|
||||
|
||||
if let Ok(mut msg) = serde_json::from_str::<serde_json::Value>(&line) {
|
||||
let msg_type = msg.get("type").and_then(|v| v.as_str()).unwrap_or("?");
|
||||
let msg_sub = msg.get("subtype").and_then(|v| v.as_str()).unwrap_or("");
|
||||
eprintln!(
|
||||
"[claude-stdout] [{}] +{:.1}s #{} type={} sub={} len={}",
|
||||
tab_id_stdout,
|
||||
elapsed,
|
||||
line_count,
|
||||
msg_type,
|
||||
msg_sub,
|
||||
line.len()
|
||||
);
|
||||
|
||||
if msg.get("type").and_then(|v| v.as_str()) == Some("system")
|
||||
&& msg.get("subtype").and_then(|v| v.as_str()) == Some("init")
|
||||
{
|
||||
if let Some(metadata) = provider_metadata_stdout.as_ref() {
|
||||
if let Some(object) = msg.as_object_mut() {
|
||||
object.insert(
|
||||
"provider".to_string(),
|
||||
serde_json::Value::String(metadata.provider.to_string()),
|
||||
);
|
||||
object.insert(
|
||||
"provider_credential_id".to_string(),
|
||||
serde_json::Value::String(metadata.provider_credential_id.clone()),
|
||||
);
|
||||
object.insert(
|
||||
"model".to_string(),
|
||||
serde_json::Value::String(metadata.model.clone()),
|
||||
);
|
||||
}
|
||||
line = msg.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
if msg.get("type").and_then(|v| v.as_str()) == Some("result") {
|
||||
let is_success = msg.get("subtype").and_then(|v| v.as_str()) == Some("success");
|
||||
if let Ok(mut guard) = result_success_stdout.lock() {
|
||||
*guard = Some(is_success);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = win_stdout.emit(
|
||||
"claude-output",
|
||||
ClaudeOutputEvent {
|
||||
tab_id: tab_id_stdout.clone(),
|
||||
data: line,
|
||||
},
|
||||
);
|
||||
}
|
||||
eprintln!(
|
||||
"[claude-stdout] [{}] stream ended after {} lines ({:.1}s)",
|
||||
tab_id_stdout,
|
||||
line_count,
|
||||
start_time.elapsed().as_secs_f64()
|
||||
);
|
||||
});
|
||||
|
||||
let win_stderr = window.clone();
|
||||
let tab_id_stderr = tab_id.clone();
|
||||
let stderr_task = tokio::spawn(async move {
|
||||
let mut lines = stderr_reader.lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
eprintln!(
|
||||
"[claude-stderr] [{}] +{:.1}s {}",
|
||||
tab_id_stderr,
|
||||
start_time.elapsed().as_secs_f64(),
|
||||
&line[..line.len().min(200)]
|
||||
);
|
||||
let _ = win_stderr.emit(
|
||||
"claude-error",
|
||||
ClaudeErrorEvent {
|
||||
tab_id: tab_id_stderr.clone(),
|
||||
data: line,
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
let process_arc_wait = process_arc.clone();
|
||||
let win_wait = window;
|
||||
let process_key_wait = process_key;
|
||||
let tab_id_wait = tab_id;
|
||||
let result_success_wait = result_success_holder.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = stdout_task.await;
|
||||
let _ = stderr_task.await;
|
||||
|
||||
let mut processes = process_arc_wait.lock().await;
|
||||
let success = if let Some(mut child) = processes.remove(&process_key_wait) {
|
||||
match child.wait().await {
|
||||
Ok(status) => {
|
||||
let exit_success = status.success();
|
||||
let result_success = result_success_wait.lock().ok().and_then(|guard| *guard);
|
||||
let success = exit_success || result_success == Some(true);
|
||||
eprintln!(
|
||||
"[claude-process] [{}] exited with status={} result_success={:?} final_success={} ({:.1}s)",
|
||||
tab_id_wait,
|
||||
status,
|
||||
result_success,
|
||||
success,
|
||||
start_time.elapsed().as_secs_f64()
|
||||
);
|
||||
success
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"[claude-process] [{}] wait error: {} ({:.1}s)",
|
||||
tab_id_wait,
|
||||
e,
|
||||
start_time.elapsed().as_secs_f64()
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
eprintln!(
|
||||
"[claude-process] [{}] no child found in map ({:.1}s)",
|
||||
tab_id_wait,
|
||||
start_time.elapsed().as_secs_f64()
|
||||
);
|
||||
false
|
||||
};
|
||||
drop(processes);
|
||||
|
||||
let _ = win_wait.emit(
|
||||
"claude-complete",
|
||||
ClaudeCompleteEvent {
|
||||
tab_id: tab_id_wait,
|
||||
success,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn stop_claude_process(
|
||||
window: WebviewWindow,
|
||||
tab_id: String,
|
||||
mode: ClaudeStopMode,
|
||||
) -> Result<bool, String> {
|
||||
let window_label = window.label().to_string();
|
||||
let process_key = process_key(&window_label, &tab_id);
|
||||
let claude_state = window.state::<ClaudeProcessState>();
|
||||
let mut processes = claude_state.processes.lock().await;
|
||||
if let Some(mut child) = processes.remove(&process_key) {
|
||||
drop(processes);
|
||||
let stopped = match mode {
|
||||
ClaudeStopMode::Terminate => {
|
||||
terminate_process_tree(&mut child).await;
|
||||
true
|
||||
}
|
||||
ClaudeStopMode::Interrupt => interrupt_or_terminate(&mut child).await,
|
||||
};
|
||||
return Ok(stopped);
|
||||
}
|
||||
drop(processes);
|
||||
|
||||
let _ = window.emit(
|
||||
"claude-complete",
|
||||
ClaudeCompleteEvent {
|
||||
tab_id,
|
||||
success: false,
|
||||
},
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn interrupt_or_terminate(child: &mut Child) -> bool {
|
||||
if let Some(pid) = child.id() {
|
||||
let status = tokio::process::Command::new("kill")
|
||||
.arg("-INT")
|
||||
.arg(pid.to_string())
|
||||
.status()
|
||||
.await;
|
||||
if matches!(status, Ok(status) if status.success()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
terminate_process_tree(child).await;
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
async fn interrupt_or_terminate(child: &mut Child) -> bool {
|
||||
// Windows GUI processes do not have a reliable console-control path from
|
||||
// Tauri without a PTY/ConPTY session. For guided follow-ups, fall back to
|
||||
// terminating the current run so the frontend can immediately continue the
|
||||
// same tab with the queued guidance.
|
||||
terminate_process_tree(child).await;
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
async fn terminate_process_tree(child: &mut Child) {
|
||||
if let Some(pid) = child.id() {
|
||||
let _ = Command::new("taskkill")
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.args(["/PID", &pid.to_string(), "/T", "/F"])
|
||||
.status()
|
||||
.await;
|
||||
}
|
||||
let _ = child.start_kill();
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
async fn terminate_process_tree(child: &mut Child) {
|
||||
let _ = child.start_kill();
|
||||
}
|
||||
|
||||
/// Kill all Claude processes associated with a specific window label.
|
||||
/// Called when a window is destroyed.
|
||||
pub async fn kill_process_for_window(state: &ClaudeProcessState, window_label: &str) {
|
||||
let mut processes = state.processes.lock().await;
|
||||
let prefix = format!("{}:", window_label);
|
||||
let keys_to_remove: Vec<String> = processes
|
||||
.keys()
|
||||
.filter(|k| k.starts_with(&prefix))
|
||||
.cloned()
|
||||
.collect();
|
||||
for key in keys_to_remove {
|
||||
if let Some(mut child) = processes.remove(&key) {
|
||||
let _ = child.kill().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -258,8 +258,7 @@ fn sync_source_files(src: &Path, dst: &Path) -> std::io::Result<()> {
|
|||
let dst_path = dst.join(&file_name);
|
||||
if src_path.is_dir() {
|
||||
let name = file_name.to_string_lossy();
|
||||
if name.starts_with('.')
|
||||
|| matches!(name.as_ref(), "node_modules" | "target" | "dist")
|
||||
if name.starts_with('.') || matches!(name.as_ref(), "node_modules" | "target" | "dist")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
|
@ -507,7 +506,11 @@ fn compile_with_texlive(
|
|||
|
||||
let engine_path = find_texlive_binary(engine_name)?;
|
||||
let env_path = texlive_env_path(&engine_path);
|
||||
eprintln!("[texlive] backend: {} ({})", engine_name, engine_path.display());
|
||||
eprintln!(
|
||||
"[texlive] backend: {} ({})",
|
||||
engine_name,
|
||||
engine_path.display()
|
||||
);
|
||||
let bib_tool = detect_bib_tool(tex_content);
|
||||
|
||||
// Use "." as output-directory since current_dir is already work_dir.
|
||||
|
|
@ -518,11 +521,7 @@ fn compile_with_texlive(
|
|||
// With -halt-on-error, recoverable warnings (e.g. missing font shapes) cause xetex to
|
||||
// exit non-zero, and the xelatex wrapper skips the xdvipdfmx step — producing .xdv but
|
||||
// no .pdf. -interaction=nonstopmode alone is sufficient to avoid interactive prompts.
|
||||
let common_args: Vec<&str> = vec![
|
||||
"-synctex=1",
|
||||
"-interaction=nonstopmode",
|
||||
&output_dir_arg,
|
||||
];
|
||||
let common_args: Vec<&str> = vec!["-synctex=1", "-interaction=nonstopmode", &output_dir_arg];
|
||||
|
||||
let main_file_path = Path::new(main_file);
|
||||
|
||||
|
|
@ -1108,7 +1107,8 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_detect_bib_tool_biber() {
|
||||
let content = "\\documentclass{article}\n\\usepackage{biblatex}\n\\begin{document}\n\\end{document}";
|
||||
let content =
|
||||
"\\documentclass{article}\n\\usepackage{biblatex}\n\\begin{document}\n\\end{document}";
|
||||
assert_eq!(detect_bib_tool(content), BibTool::Biber);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
#![recursion_limit = "512"]
|
||||
|
||||
mod anthropic_proxy;
|
||||
mod claude;
|
||||
mod claude_process;
|
||||
mod history;
|
||||
mod latex;
|
||||
mod skills;
|
||||
|
|
@ -7,8 +11,8 @@ mod uv;
|
|||
mod zotero;
|
||||
|
||||
use std::path::Path;
|
||||
use tauri_plugin_fs::FsExt;
|
||||
use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
|
||||
use tauri_plugin_fs::FsExt;
|
||||
|
||||
/// Entry point for the `--tectonic-compile` subprocess mode.
|
||||
/// Runs tectonic compilation in an isolated process so that C-level global state
|
||||
|
|
@ -183,6 +187,7 @@ fn create_new_window(app: tauri::AppHandle) -> Result<(), String> {
|
|||
.title("ClaudePrism")
|
||||
.inner_size(1400.0, 900.0)
|
||||
.min_inner_size(800.0, 600.0)
|
||||
.zoom_hotkeys_enabled(true)
|
||||
.visible(false);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
|
|
@ -199,6 +204,124 @@ fn create_new_window(app: tauri::AppHandle) -> Result<(), String> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn apply_windows_titlebar_theme(window: &tauri::WebviewWindow, dark: bool) -> Result<(), String> {
|
||||
use std::ffi::c_void;
|
||||
|
||||
#[link(name = "dwmapi")]
|
||||
extern "system" {
|
||||
#[link_name = "DwmSetWindowAttribute"]
|
||||
fn dwm_set_window_attribute(
|
||||
hwnd: isize,
|
||||
dwattribute: u32,
|
||||
pvattribute: *const c_void,
|
||||
cbattribute: u32,
|
||||
) -> i32;
|
||||
}
|
||||
|
||||
#[link(name = "user32")]
|
||||
extern "system" {
|
||||
#[link_name = "SetWindowPos"]
|
||||
fn set_window_pos(
|
||||
hwnd: isize,
|
||||
hwnd_insert_after: isize,
|
||||
x: i32,
|
||||
y: i32,
|
||||
cx: i32,
|
||||
cy: i32,
|
||||
flags: u32,
|
||||
) -> i32;
|
||||
}
|
||||
|
||||
const DWMWA_USE_IMMERSIVE_DARK_MODE: u32 = 20;
|
||||
const SWP_NOSIZE: u32 = 0x0001;
|
||||
const SWP_NOMOVE: u32 = 0x0002;
|
||||
const SWP_NOZORDER: u32 = 0x0004;
|
||||
const SWP_NOACTIVATE: u32 = 0x0010;
|
||||
const SWP_FRAMECHANGED: u32 = 0x0020;
|
||||
|
||||
let hwnd = window
|
||||
.hwnd()
|
||||
.map_err(|e| format!("Failed to resolve native window handle: {}", e))?;
|
||||
let hwnd = hwnd.0 as isize;
|
||||
let dark_value: i32 = if dark { 1 } else { 0 };
|
||||
let attr_size = std::mem::size_of_val(&dark_value) as u32;
|
||||
|
||||
let mut result = unsafe {
|
||||
dwm_set_window_attribute(
|
||||
hwnd,
|
||||
DWMWA_USE_IMMERSIVE_DARK_MODE,
|
||||
&dark_value as *const _ as *const _,
|
||||
attr_size,
|
||||
)
|
||||
};
|
||||
if result < 0 {
|
||||
// Older Windows 10 builds used attribute 19 before Microsoft documented 20.
|
||||
result = unsafe {
|
||||
dwm_set_window_attribute(hwnd, 19, &dark_value as *const _ as *const _, attr_size)
|
||||
};
|
||||
}
|
||||
|
||||
// Windows 11 honors explicit caption/text colors more reliably than the
|
||||
// immersive flag alone, especially after runtime theme switches.
|
||||
const DWMWA_CAPTION_COLOR: u32 = 35;
|
||||
const DWMWA_TEXT_COLOR: u32 = 36;
|
||||
let caption_color: u32 = if dark { 0x0010_1010 } else { 0x00F9_F9F9 };
|
||||
let text_color: u32 = if dark { 0x00FF_FFFF } else { 0x0000_0000 };
|
||||
unsafe {
|
||||
let _ = dwm_set_window_attribute(
|
||||
hwnd,
|
||||
DWMWA_CAPTION_COLOR,
|
||||
&caption_color as *const _ as *const _,
|
||||
std::mem::size_of_val(&caption_color) as u32,
|
||||
);
|
||||
let _ = dwm_set_window_attribute(
|
||||
hwnd,
|
||||
DWMWA_TEXT_COLOR,
|
||||
&text_color as *const _ as *const _,
|
||||
std::mem::size_of_val(&text_color) as u32,
|
||||
);
|
||||
let _ = set_window_pos(
|
||||
hwnd,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED,
|
||||
);
|
||||
}
|
||||
|
||||
if result < 0 {
|
||||
return Err(format!(
|
||||
"Failed to update Windows title bar theme: HRESULT 0x{:08X}",
|
||||
result as u32
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_native_window_theme(window: tauri::WebviewWindow, theme: String) -> Result<(), String> {
|
||||
let theme = theme.trim().to_ascii_lowercase();
|
||||
let dark = theme == "dark";
|
||||
let tauri_theme = if dark {
|
||||
tauri::Theme::Dark
|
||||
} else {
|
||||
tauri::Theme::Light
|
||||
};
|
||||
|
||||
window
|
||||
.set_theme(Some(tauri_theme))
|
||||
.map_err(|e| format!("Failed to set window theme: {}", e))?;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
apply_windows_titlebar_theme(&window, dark)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn allow_project_directory(app: tauri::AppHandle, root_path: String) -> Result<(), String> {
|
||||
let fs_scope = app.fs_scope();
|
||||
|
|
@ -214,6 +337,108 @@ fn allow_project_directory(app: tauri::AppHandle, root_path: String) -> Result<(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct ProjectCandidate {
|
||||
path: String,
|
||||
name: String,
|
||||
last_modified: u64,
|
||||
has_main_tex: bool,
|
||||
}
|
||||
|
||||
fn modified_ms(path: &Path) -> u64 {
|
||||
std::fs::metadata(path)
|
||||
.and_then(|metadata| metadata.modified())
|
||||
.ok()
|
||||
.and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|duration| duration.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn has_tex_file(dir: &Path) -> bool {
|
||||
if dir.join("main.tex").is_file() || dir.join("document.tex").is_file() {
|
||||
return true;
|
||||
}
|
||||
|
||||
std::fs::read_dir(dir)
|
||||
.ok()
|
||||
.into_iter()
|
||||
.flat_map(|entries| entries.flatten())
|
||||
.any(|entry| {
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
return false;
|
||||
}
|
||||
matches!(
|
||||
path.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(|ext| ext.to_ascii_lowercase())
|
||||
.as_deref(),
|
||||
Some("tex" | "ltx")
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn project_modified_ms(dir: &Path) -> u64 {
|
||||
let mut latest = modified_ms(dir);
|
||||
for relative in [
|
||||
"main.tex",
|
||||
"document.tex",
|
||||
".prism/build/main.pdf",
|
||||
".claudeprism/history.git/.git/refs/heads/master",
|
||||
] {
|
||||
latest = latest.max(modified_ms(&dir.join(relative)));
|
||||
}
|
||||
|
||||
if let Ok(entries) = std::fs::read_dir(dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_file() {
|
||||
latest = latest.max(modified_ms(&path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
latest
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn list_default_projects() -> Result<Vec<ProjectCandidate>, String> {
|
||||
let Some(home) = dirs::home_dir() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
let base = home.join("Documents").join("ClaudePrism");
|
||||
if !base.is_dir() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut projects = Vec::new();
|
||||
let entries = std::fs::read_dir(&base)
|
||||
.map_err(|e| format!("Failed to read default project directory: {}", e))?;
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.starts_with('.') || !has_tex_file(&path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
projects.push(ProjectCandidate {
|
||||
path: path.to_string_lossy().to_string(),
|
||||
name,
|
||||
last_modified: project_modified_ms(&path),
|
||||
has_main_tex: path.join("main.tex").is_file() || path.join("document.tex").is_file(),
|
||||
});
|
||||
}
|
||||
|
||||
projects.sort_by(|a, b| b.last_modified.cmp(&a.last_modified));
|
||||
Ok(projects)
|
||||
}
|
||||
|
||||
// --- Debug logging from JS (survives white-screen crashes) ---
|
||||
|
||||
#[tauri::command]
|
||||
|
|
@ -236,6 +461,7 @@ fn open_debug_window(app: tauri::AppHandle) -> Result<(), String> {
|
|||
.title("ClaudePrism — Debug")
|
||||
.inner_size(560.0, 700.0)
|
||||
.min_inner_size(400.0, 400.0)
|
||||
.zoom_hotkeys_enabled(true)
|
||||
.visible(true)
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create debug window: {}", e))?;
|
||||
|
|
@ -353,9 +579,7 @@ pub fn run() {
|
|||
tokio::time::sleep(std::time::Duration::from_secs(8)).await;
|
||||
if let Some(window) = handle.get_webview_window("main") {
|
||||
if !window.is_visible().unwrap_or(true) {
|
||||
eprintln!(
|
||||
"[safety] Main window still hidden after 8s, force-showing"
|
||||
);
|
||||
eprintln!("[safety] Main window still hidden after 8s, force-showing");
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
|
|
@ -365,7 +589,9 @@ pub fn run() {
|
|||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
create_new_window,
|
||||
set_native_window_theme,
|
||||
allow_project_directory,
|
||||
list_default_projects,
|
||||
detect_editors,
|
||||
open_in_editor,
|
||||
js_log,
|
||||
|
|
@ -376,15 +602,27 @@ pub fn run() {
|
|||
claude::check_claude_status,
|
||||
claude::install_claude_cli,
|
||||
claude::login_claude,
|
||||
claude::save_anthropic_api_key,
|
||||
claude::verify_openai_compatible_api_key,
|
||||
claude::list_openai_compatible_models,
|
||||
claude::list_openai_compatible_credential_models,
|
||||
claude::clear_anthropic_api_key,
|
||||
claude::list_openai_compatible_credentials,
|
||||
claude::delete_openai_compatible_credential,
|
||||
claude::set_active_openai_compatible_credential,
|
||||
claude::execute_claude_code,
|
||||
claude::continue_claude_code,
|
||||
claude::resume_claude_code,
|
||||
claude::cancel_claude_execution,
|
||||
claude::interrupt_claude_execution,
|
||||
claude::run_shell_command,
|
||||
claude::migrate_project_sessions,
|
||||
claude::get_claude_fast_mode,
|
||||
claude::set_claude_fast_mode,
|
||||
claude::list_claude_sessions,
|
||||
claude::generate_claude_session_title,
|
||||
claude::load_session_history,
|
||||
claude::delete_claude_session,
|
||||
zotero::zotero_start_oauth,
|
||||
zotero::zotero_complete_oauth,
|
||||
zotero::zotero_cancel_oauth,
|
||||
|
|
@ -402,8 +640,10 @@ pub fn run() {
|
|||
slash_commands::slash_command_delete,
|
||||
skills::install_scientific_skills,
|
||||
skills::install_scientific_skills_global,
|
||||
skills::import_skill_from_folder,
|
||||
skills::check_skills_installed,
|
||||
skills::list_installed_skills,
|
||||
skills::delete_installed_skill,
|
||||
skills::uninstall_scientific_skills,
|
||||
skills::get_skill_categories,
|
||||
skills::get_skill_content,
|
||||
|
|
@ -457,7 +697,7 @@ pub fn run() {
|
|||
let _ = window.eval(
|
||||
"document.body.style.display='none';\
|
||||
document.body.offsetHeight;\
|
||||
document.body.style.display='';"
|
||||
document.body.style.display='';",
|
||||
);
|
||||
}
|
||||
let _ = window.emit("window-focus-restored", ());
|
||||
|
|
|
|||
|
|
@ -200,23 +200,16 @@ fn load_skills_from_dir(dir: &Path, scope: &str) -> Vec<SlashCommand> {
|
|||
return Vec::new();
|
||||
}
|
||||
|
||||
let entries = match fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
let mut skill_dirs = Vec::new();
|
||||
collect_skill_dirs(dir, &mut skill_dirs);
|
||||
skill_dirs.sort();
|
||||
let mut skills = Vec::new();
|
||||
let mut seen_ids = std::collections::HashSet::new();
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
for path in skill_dirs {
|
||||
let Some(skill_md) = find_skill_md(&path) else {
|
||||
continue;
|
||||
}
|
||||
|
||||
let skill_md = path.join("SKILL.md");
|
||||
if !skill_md.exists() {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let content = match fs::read_to_string(&skill_md) {
|
||||
Ok(c) => c,
|
||||
|
|
@ -248,6 +241,9 @@ fn load_skills_from_dir(dir: &Path, scope: &str) -> Vec<SlashCommand> {
|
|||
.map(|d| d.chars().take(200).collect());
|
||||
|
||||
let id = format!("skill-{}", folder_name);
|
||||
if !seen_ids.insert(id.clone()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
skills.push(SlashCommand {
|
||||
id,
|
||||
|
|
@ -269,6 +265,51 @@ fn load_skills_from_dir(dir: &Path, scope: &str) -> Vec<SlashCommand> {
|
|||
skills
|
||||
}
|
||||
|
||||
fn find_skill_md(skill_dir: &Path) -> Option<PathBuf> {
|
||||
for name in ["SKILL.md", "skill.md"] {
|
||||
let candidate = skill_dir.join(name);
|
||||
if candidate.is_file() {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
let entries = fs::read_dir(skill_dir).ok()?;
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_file()
|
||||
&& path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name.eq_ignore_ascii_case("SKILL.md"))
|
||||
{
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn collect_skill_dirs(root: &Path, output: &mut Vec<PathBuf>) {
|
||||
if find_skill_md(root).is_some() {
|
||||
output.push(root.to_path_buf());
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok(entries) = fs::read_dir(root) else {
|
||||
return;
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let Ok(file_type) = entry.file_type() else {
|
||||
continue;
|
||||
};
|
||||
if file_type.is_symlink() || !file_type.is_dir() {
|
||||
continue;
|
||||
}
|
||||
collect_skill_dirs(&entry.path(), output);
|
||||
}
|
||||
}
|
||||
|
||||
fn create_default_commands() -> Vec<SlashCommand> {
|
||||
vec![
|
||||
SlashCommand {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tauri::{Emitter, WebviewWindow};
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
|
||||
|
|
@ -113,6 +113,28 @@ fn venv_python(venv_dir: &std::path::Path) -> PathBuf {
|
|||
}
|
||||
}
|
||||
|
||||
fn venv_pip(venv_dir: &std::path::Path) -> PathBuf {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
venv_bin_dir(venv_dir).join("pip")
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
venv_bin_dir(venv_dir).join("pip.exe")
|
||||
}
|
||||
}
|
||||
|
||||
fn venv_pip_shim(venv_dir: &std::path::Path) -> PathBuf {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
venv_bin_dir(venv_dir).join("pip")
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
venv_bin_dir(venv_dir).join("pip.cmd")
|
||||
}
|
||||
}
|
||||
|
||||
fn path_with_venv(venv_dir: &std::path::Path) -> String {
|
||||
let bin = venv_bin_dir(venv_dir);
|
||||
let current = std::env::var("PATH").unwrap_or_default();
|
||||
|
|
@ -123,6 +145,73 @@ fn path_with_venv(venv_dir: &std::path::Path) -> String {
|
|||
format!("{}{}{}", bin.to_string_lossy(), sep, current)
|
||||
}
|
||||
|
||||
fn write_pip_shim(venv_dir: &Path) -> Result<(), String> {
|
||||
let uv_bin = find_uv_binary().unwrap_or_else(|_| "uv".to_string());
|
||||
let shim_path = venv_pip_shim(venv_dir);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let content = format!(
|
||||
"@echo off\r\nset \"VIRTUAL_ENV={}\"\r\n\"{}\" pip %*\r\n",
|
||||
venv_dir.to_string_lossy(),
|
||||
uv_bin
|
||||
);
|
||||
std::fs::write(&shim_path, &content)
|
||||
.map_err(|e| format!("Failed to create pip shim: {}", e))?;
|
||||
let pip3_path = venv_bin_dir(venv_dir).join("pip3.cmd");
|
||||
let _ = std::fs::write(pip3_path, content);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let content = format!(
|
||||
"#!/bin/sh\nVIRTUAL_ENV=\"{}\" exec \"{}\" pip \"$@\"\n",
|
||||
venv_dir.to_string_lossy(),
|
||||
uv_bin
|
||||
);
|
||||
std::fs::write(&shim_path, content)
|
||||
.map_err(|e| format!("Failed to create pip shim: {}", e))?;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = std::fs::metadata(&shim_path)
|
||||
.map_err(|e| format!("Failed to stat pip shim: {}", e))?
|
||||
.permissions();
|
||||
perms.set_mode(0o755);
|
||||
std::fs::set_permissions(&shim_path, perms)
|
||||
.map_err(|e| format!("Failed to mark pip shim executable: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_venv_pip(venv_dir: &Path) -> Result<(), String> {
|
||||
if venv_pip(venv_dir).exists() || venv_pip_shim(venv_dir).exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let python = venv_python(venv_dir);
|
||||
if !python.exists() {
|
||||
return Err(format!(
|
||||
"Project .venv is missing Python at {}",
|
||||
python.display()
|
||||
));
|
||||
}
|
||||
|
||||
let mut ensure_cmd = tokio::process::Command::new(&python);
|
||||
ensure_cmd.args(["-m", "ensurepip", "--upgrade"]);
|
||||
ensure_cmd.env("VIRTUAL_ENV", venv_dir);
|
||||
ensure_cmd.env("PATH", path_with_venv(venv_dir));
|
||||
ensure_cmd.env("PYTHONNOUSERSITE", "1");
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
ensure_cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
|
||||
match ensure_cmd.output().await {
|
||||
Ok(output) if output.status.success() && venv_pip(venv_dir).exists() => Ok(()),
|
||||
_ => write_pip_shim(venv_dir),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tauri Commands ───
|
||||
|
||||
#[tauri::command]
|
||||
|
|
@ -143,7 +232,6 @@ pub async fn check_uv_status() -> Result<UvStatus, String> {
|
|||
version_cmd.arg("--version");
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
|
||||
version_cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
let version_output = version_cmd.output();
|
||||
|
|
@ -214,7 +302,6 @@ pub async fn install_uv(window: WebviewWindow) -> Result<(), String> {
|
|||
};
|
||||
#[cfg(target_os = "windows")]
|
||||
let mut cmd = {
|
||||
|
||||
let mut c = tokio::process::Command::new("powershell");
|
||||
c.creation_flags(CREATE_NO_WINDOW);
|
||||
c.args([
|
||||
|
|
@ -236,6 +323,7 @@ pub async fn install_uv(window: WebviewWindow) -> Result<(), String> {
|
|||
cmd.env(&key, &value);
|
||||
}
|
||||
}
|
||||
crate::claude::apply_proxy_env_to_command(&mut cmd, Some(&window));
|
||||
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
|
|
@ -289,6 +377,7 @@ pub async fn setup_project_venv(project_path: String) -> Result<VenvInfo, String
|
|||
|
||||
// If venv already exists, just return info
|
||||
if venv_dir.exists() {
|
||||
ensure_venv_pip(&venv_dir).await?;
|
||||
let python = venv_python(&venv_dir);
|
||||
return Ok(VenvInfo {
|
||||
venv_path: venv_dir.to_string_lossy().to_string(),
|
||||
|
|
@ -301,11 +390,11 @@ pub async fn setup_project_venv(project_path: String) -> Result<VenvInfo, String
|
|||
|
||||
// Create venv: uv venv <project_path>/.venv
|
||||
let mut venv_cmd = tokio::process::Command::new(&uv_bin);
|
||||
venv_cmd.args(["venv", &venv_dir.to_string_lossy()]);
|
||||
let venv_arg = venv_dir.to_string_lossy().to_string();
|
||||
venv_cmd.args(["venv", "--seed", venv_arg.as_str()]);
|
||||
venv_cmd.current_dir(project);
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
|
||||
venv_cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
let output = venv_cmd
|
||||
|
|
@ -319,6 +408,7 @@ pub async fn setup_project_venv(project_path: String) -> Result<VenvInfo, String
|
|||
}
|
||||
|
||||
let python = venv_python(&venv_dir);
|
||||
ensure_venv_pip(&venv_dir).await?;
|
||||
|
||||
Ok(VenvInfo {
|
||||
venv_path: venv_dir.to_string_lossy().to_string(),
|
||||
|
|
@ -346,10 +436,11 @@ pub async fn uv_add_packages(
|
|||
pip_cmd.args(&args);
|
||||
pip_cmd.current_dir(&project_path);
|
||||
pip_cmd.env("VIRTUAL_ENV", &venv_dir);
|
||||
pip_cmd.env("UV_PROJECT_ENVIRONMENT", &venv_dir);
|
||||
pip_cmd.env("PYTHONNOUSERSITE", "1");
|
||||
pip_cmd.env("PATH", path_with_venv(&venv_dir));
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
|
||||
pip_cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
let output = pip_cmd
|
||||
|
|
@ -390,10 +481,12 @@ pub async fn uv_run_command(
|
|||
run_cmd.args(args);
|
||||
run_cmd.current_dir(&project_path);
|
||||
run_cmd.env("VIRTUAL_ENV", &venv_dir);
|
||||
run_cmd.env("UV_PROJECT_ENVIRONMENT", &venv_dir);
|
||||
run_cmd.env("PYTHONNOUSERSITE", "1");
|
||||
run_cmd.env("PIP_REQUIRE_VIRTUALENV", "true");
|
||||
run_cmd.env("PATH", path_with_venv(&venv_dir));
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
|
||||
run_cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
let output = run_cmd
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@
|
|||
"$schema": "https://raw.githubusercontent.com/nicegui-unofficial/nicegui-tauri-template/main/src-tauri/tauri.conf-v2-schema.json",
|
||||
"identifier": "com.claude-prism.desktop",
|
||||
"productName": "ClaudePrism",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
"beforeBuildCommand": "pnpm build",
|
||||
"beforeDevCommand": "corepack pnpm dev",
|
||||
"beforeBuildCommand": "corepack pnpm build",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
|
|
@ -23,6 +23,7 @@
|
|||
"visible": false,
|
||||
"titleBarStyle": "Overlay",
|
||||
"hiddenTitle": true,
|
||||
"zoomHotkeysEnabled": true,
|
||||
"trafficLightPosition": {
|
||||
"x": 12,
|
||||
"y": 12
|
||||
|
|
|
|||
5
apps/desktop/src-tauri/tauri.local-build.conf.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"bundle": {
|
||||
"createUpdaterArtifacts": false
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { ThemeProvider } from "next-themes";
|
||||
import { ThemeProvider, useTheme } from "next-themes";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||
|
|
@ -7,16 +7,14 @@ import { useDocumentStore } from "@/stores/document-store";
|
|||
import { useClaudeChatStore } from "@/stores/claude-chat-store";
|
||||
import { ProjectPicker } from "@/components/project-picker";
|
||||
import { WorkspaceLayout } from "@/components/workspace/workspace-layout";
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { lazy, Suspense, useEffect, useRef, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import {
|
||||
ScientificSkillsOnboarding,
|
||||
shouldShowOnboarding,
|
||||
} from "@/components/scientific-skills/scientific-skills-onboarding";
|
||||
import { useUvSetupStore } from "@/stores/uv-setup-store";
|
||||
import { ErrorFallback } from "@/components/error-fallback";
|
||||
import { createLogger } from "@/lib/debug/logger";
|
||||
import { EnvironmentOnboarding } from "@/components/environment-onboarding";
|
||||
|
||||
const log = createLogger("app");
|
||||
|
||||
|
|
@ -26,10 +24,62 @@ const LazyDebugPage = lazy(() =>
|
|||
})),
|
||||
);
|
||||
|
||||
interface ClaudeSessionInfo {
|
||||
session_id: string;
|
||||
title: string;
|
||||
last_modified: number;
|
||||
}
|
||||
|
||||
function NativeWindowThemeBridge() {
|
||||
const { resolvedTheme, theme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
const syncNativeTheme = () => {
|
||||
const isDark =
|
||||
document.documentElement.classList.contains("dark") ||
|
||||
resolvedTheme === "dark";
|
||||
const nativeTheme = isDark ? "dark" : "light";
|
||||
|
||||
document.documentElement.style.colorScheme = nativeTheme;
|
||||
invoke("set_native_window_theme", { theme: nativeTheme })
|
||||
.catch((err) => {
|
||||
log.warn("Failed to sync native window theme via Rust command", {
|
||||
error: String(err),
|
||||
});
|
||||
return getCurrentWindow().setTheme(nativeTheme);
|
||||
})
|
||||
.catch((err) => {
|
||||
log.warn("Failed to sync native window theme via JS API", {
|
||||
error: String(err),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
syncNativeTheme();
|
||||
|
||||
const observer = new MutationObserver(syncNativeTheme);
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
|
||||
const systemThemeQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
systemThemeQuery.addEventListener("change", syncNativeTheme);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
systemThemeQuery.removeEventListener("change", syncNativeTheme);
|
||||
};
|
||||
}, [resolvedTheme, theme]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function WorkspaceWithClaude() {
|
||||
const projectRoot = useDocumentStore((s) => s.projectRoot);
|
||||
const initialized = useDocumentStore((s) => s.initialized);
|
||||
const [showSkillsOnboarding, setShowSkillsOnboarding] = useState(false);
|
||||
const autoResumedProjectRef = useRef<string | null>(null);
|
||||
const chatProjectRef = useRef<string | null>(null);
|
||||
|
||||
// Update window title
|
||||
useEffect(() => {
|
||||
|
|
@ -39,15 +89,11 @@ function WorkspaceWithClaude() {
|
|||
}
|
||||
}, [projectRoot]);
|
||||
|
||||
// Show scientific skills onboarding on first launch
|
||||
useEffect(() => {
|
||||
if (!initialized) return;
|
||||
if (shouldShowOnboarding()) {
|
||||
// Small delay so the workspace renders first
|
||||
const timer = setTimeout(() => setShowSkillsOnboarding(true), 800);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [initialized]);
|
||||
if (chatProjectRef.current === projectRoot) return;
|
||||
chatProjectRef.current = projectRoot;
|
||||
useClaudeChatStore.getState().resetForProject(projectRoot ?? null);
|
||||
}, [projectRoot]);
|
||||
|
||||
// Auto-setup Python venv when project opens
|
||||
useEffect(() => {
|
||||
|
|
@ -66,6 +112,59 @@ function WorkspaceWithClaude() {
|
|||
});
|
||||
}, [initialized, projectRoot]);
|
||||
|
||||
// Open the most recent chat when entering a project.
|
||||
useEffect(() => {
|
||||
if (!projectRoot) {
|
||||
autoResumedProjectRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (!initialized) return;
|
||||
if (autoResumedProjectRef.current === projectRoot) return;
|
||||
|
||||
const chatState = useClaudeChatStore.getState();
|
||||
if (chatState.pendingInitialPrompt) return;
|
||||
|
||||
autoResumedProjectRef.current = projectRoot;
|
||||
let cancelled = false;
|
||||
|
||||
invoke<ClaudeSessionInfo[]>("list_claude_sessions", {
|
||||
projectPath: projectRoot,
|
||||
generateTitles: false,
|
||||
})
|
||||
.then((sessions) => {
|
||||
if (cancelled) return;
|
||||
const latest = sessions
|
||||
.slice()
|
||||
.sort((a, b) => b.last_modified - a.last_modified)[0];
|
||||
|
||||
const current = useClaudeChatStore.getState();
|
||||
if (current.pendingInitialPrompt || current.isStreaming) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!latest?.session_id) {
|
||||
current.newSession();
|
||||
return;
|
||||
}
|
||||
|
||||
current.resumeSession(latest.session_id, latest.title).catch((err) => {
|
||||
log.warn("Failed to auto-resume latest chat session", {
|
||||
sessionId: latest.session_id,
|
||||
error: String(err),
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
log.warn("Failed to auto-resume latest chat session", {
|
||||
error: String(err),
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [initialized, projectRoot]);
|
||||
|
||||
// Consume pending initial prompt from project wizard
|
||||
useEffect(() => {
|
||||
if (!initialized) return;
|
||||
|
|
@ -81,16 +180,7 @@ function WorkspaceWithClaude() {
|
|||
return () => clearTimeout(timer);
|
||||
}, [initialized]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<WorkspaceLayout />
|
||||
{showSkillsOnboarding && (
|
||||
<ScientificSkillsOnboarding
|
||||
onClose={() => setShowSkillsOnboarding(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return <WorkspaceLayout />;
|
||||
}
|
||||
|
||||
export function App({ onReady }: { onReady?: () => void }) {
|
||||
|
|
@ -100,10 +190,28 @@ export function App({ onReady }: { onReady?: () => void }) {
|
|||
// Register global keyboard shortcuts (Cmd+S, Cmd+N) at the app level
|
||||
useKeyboardShortcuts();
|
||||
|
||||
useEffect(() => {
|
||||
const preventNativeContextMenu = (event: MouseEvent) => {
|
||||
if (event.defaultPrevented) return;
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
document.addEventListener("contextmenu", preventNativeContextMenu);
|
||||
return () => {
|
||||
document.removeEventListener("contextmenu", preventNativeContextMenu);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
onReady?.();
|
||||
}, [onReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectRoot) {
|
||||
getCurrentWindow().setTitle("ClaudePrism");
|
||||
}
|
||||
}, [projectRoot]);
|
||||
|
||||
// Listen for debug panel toggle (Ctrl+Shift+D)
|
||||
useEffect(() => {
|
||||
const handler = () => setShowDebug((prev) => !prev);
|
||||
|
|
@ -113,14 +221,16 @@ export function App({ onReady }: { onReady?: () => void }) {
|
|||
|
||||
return (
|
||||
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
||||
<ThemeProvider attribute="class" defaultTheme="light" enableSystem>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<TooltipProvider>
|
||||
<NativeWindowThemeBridge />
|
||||
{/* Global macOS titlebar drag region — sits above all content */}
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="fixed inset-x-0 top-0 z-[9999] h-[var(--titlebar-height)]"
|
||||
/>
|
||||
{projectRoot ? <WorkspaceWithClaude /> : <ProjectPicker />}
|
||||
<EnvironmentOnboarding />
|
||||
{showDebug && (
|
||||
<div className="fixed inset-0 z-[9998] flex items-end justify-center">
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -47,13 +47,14 @@ describe("app zoom", () => {
|
|||
expect(localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe("1.25");
|
||||
});
|
||||
|
||||
it("restores the saved zoom on startup", async () => {
|
||||
it("resets stale global zoom on startup", async () => {
|
||||
const webview = getCurrentWebview();
|
||||
localStorage.setItem(APP_ZOOM_STORAGE_KEY, "1.4");
|
||||
|
||||
await initializeAppZoom();
|
||||
|
||||
expect(webview.setZoom).toHaveBeenCalledWith(1.4);
|
||||
expect(webview.setZoom).toHaveBeenCalledWith(DEFAULT_APP_ZOOM);
|
||||
expect(localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it("zooms in, zooms out, and resets around the stored value", async () => {
|
||||
|
|
|
|||
67
apps/desktop/src/__tests__/lib/model-capabilities.test.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getModelCapabilities,
|
||||
isChatModelOption,
|
||||
} from "@/lib/model-capabilities";
|
||||
|
||||
describe("model capabilities", () => {
|
||||
it("uses provider metadata for vision capability", () => {
|
||||
expect(
|
||||
getModelCapabilities({
|
||||
model: "custom-model",
|
||||
metadata: { model_info: { supports_vision: true } },
|
||||
}).vision,
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
getModelCapabilities({
|
||||
label: "Qwen",
|
||||
model: "qwen3.6-flash",
|
||||
metadata: { supports_vision: false },
|
||||
}).vision,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("recognizes recent Qwen chat models as vision-capable when metadata is missing", () => {
|
||||
expect(
|
||||
getModelCapabilities({
|
||||
label: "Qwen",
|
||||
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
model: "qwen3.5-flash",
|
||||
}).vision,
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
getModelCapabilities({
|
||||
label: "Qwen",
|
||||
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
model: "qwen3.6-flash",
|
||||
}).vision,
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
getModelCapabilities({
|
||||
label: "Qwen",
|
||||
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
model: "qwen3.6-plus",
|
||||
}).vision,
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
getModelCapabilities({
|
||||
label: "Qwen",
|
||||
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
model: "qwen3.5-coder",
|
||||
}).vision,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("filters non-chat model families", () => {
|
||||
expect(
|
||||
isChatModelOption({
|
||||
label: "Qwen",
|
||||
model: "text-embedding-v4",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
52
apps/desktop/src/__tests__/lib/project-attachments.test.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
copyFileToProject: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/tauri/fs", () => ({
|
||||
copyFileToProject: mocks.copyFileToProject,
|
||||
}));
|
||||
|
||||
import {
|
||||
buildReferenceFilesSection,
|
||||
importReferenceFiles,
|
||||
} from "@/lib/project-attachments";
|
||||
|
||||
describe("project attachment helpers", () => {
|
||||
beforeEach(() => {
|
||||
mocks.copyFileToProject.mockReset();
|
||||
});
|
||||
|
||||
it("imports PDFs without creating extracted text files", async () => {
|
||||
mocks.copyFileToProject.mockResolvedValueOnce("attachments/paper.pdf");
|
||||
|
||||
const files = await importReferenceFiles("C:/project", [
|
||||
"C:/source/paper.pdf",
|
||||
]);
|
||||
|
||||
expect(mocks.copyFileToProject).toHaveBeenCalledWith(
|
||||
"C:/project",
|
||||
"C:/source/paper.pdf",
|
||||
"attachments/paper.pdf",
|
||||
);
|
||||
expect(files).toEqual([
|
||||
{
|
||||
relativePath: "attachments/paper.pdf",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds a prompt section that keeps PDF references as PDFs", () => {
|
||||
const section = buildReferenceFilesSection([
|
||||
{ relativePath: "attachments/paper.pdf" },
|
||||
{ relativePath: "attachments/data.csv" },
|
||||
]);
|
||||
|
||||
expect(section).toContain("### Reference Files");
|
||||
expect(section).toContain("`attachments/paper.pdf` (PDF)");
|
||||
expect(section).toContain("`attachments/data.csv`");
|
||||
expect(section).not.toContain("extracted text");
|
||||
expect(section).not.toContain(".pdf.txt");
|
||||
});
|
||||
});
|
||||
46
apps/desktop/src/__tests__/lib/provider-icons.test.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getProviderDisplayName,
|
||||
getProviderIconSrc,
|
||||
} from "@/lib/provider-icons";
|
||||
|
||||
describe("getProviderDisplayName", () => {
|
||||
it("derives provider names from old custom labels", () => {
|
||||
expect(
|
||||
getProviderDisplayName({
|
||||
label: "Custom OpenAI API",
|
||||
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
model: "qwen3.7-plus",
|
||||
}),
|
||||
).toBe("Qwen");
|
||||
|
||||
expect(
|
||||
getProviderDisplayName({
|
||||
label: "Custom OpenAI API",
|
||||
baseUrl: "https://open.bigmodel.cn/api/paas/v4",
|
||||
model: "glm-5.1",
|
||||
}),
|
||||
).toBe("GLM");
|
||||
});
|
||||
|
||||
it("keeps meaningful provider labels for unknown endpoints", () => {
|
||||
expect(
|
||||
getProviderDisplayName({
|
||||
label: "Acme AI",
|
||||
baseUrl: "https://models.example.test/v1",
|
||||
model: "acme-large",
|
||||
}),
|
||||
).toBe("Acme AI");
|
||||
});
|
||||
|
||||
it("recognizes local Ollama endpoints", () => {
|
||||
const provider = {
|
||||
label: "Custom OpenAI API",
|
||||
baseUrl: "http://localhost:11434/v1",
|
||||
model: "llama3.2",
|
||||
};
|
||||
|
||||
expect(getProviderDisplayName(provider)).toBe("Ollama");
|
||||
expect(getProviderIconSrc(provider)).toContain("ollama");
|
||||
});
|
||||
});
|
||||
|
|
@ -23,13 +23,18 @@ describe("tauri fs helpers", () => {
|
|||
expect(getProjectFileType("script.py")).toBe("other");
|
||||
});
|
||||
|
||||
it("ignores generated and binary file types", () => {
|
||||
it("ignores LaTeX build artifacts", () => {
|
||||
expect(getProjectFileType("main.aux")).toBeNull();
|
||||
expect(getProjectFileType("main.synctex.gz")).toBeNull();
|
||||
expect(getProjectFileType("archive.zip")).toBeNull();
|
||||
expect(getProjectFileType("module.pyc")).toBeNull();
|
||||
expect(getProjectFileType("module.PYO")).toBeNull();
|
||||
expect(getProjectFileType("native.pyd")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps imported files with arbitrary extensions visible", () => {
|
||||
expect(getProjectFileType("archive.zip")).toBe("other");
|
||||
expect(getProjectFileType("paper.docx")).toBe("other");
|
||||
expect(getProjectFileType("data.xlsx")).toBe("other");
|
||||
expect(getProjectFileType("movie.mp4")).toBe("other");
|
||||
expect(getProjectFileType("module.pyc")).toBe("other");
|
||||
expect(getProjectFileType("native.pyd")).toBe("other");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -53,7 +58,8 @@ describe("tauri fs helpers", () => {
|
|||
describe("scanProjectFolder", () => {
|
||||
it("does not recurse into generated cache directories", async () => {
|
||||
vi.mocked(readDir).mockImplementation(async (dir: string | URL) => {
|
||||
if (dir === "/project") {
|
||||
const dirPath = String(dir);
|
||||
if (dirPath === "/project") {
|
||||
return [
|
||||
{ name: "__pycache__", isDirectory: true },
|
||||
{ name: "node_modules", isDirectory: true },
|
||||
|
|
@ -62,11 +68,11 @@ describe("tauri fs helpers", () => {
|
|||
] as any;
|
||||
}
|
||||
|
||||
if (dir === "/project/chapters") {
|
||||
if (dirPath === "/project/chapters") {
|
||||
return [{ name: "intro.tex", isDirectory: false }] as any;
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected readDir path: ${dir}`);
|
||||
throw new Error(`Unexpected readDir path: ${dirPath}`);
|
||||
});
|
||||
|
||||
const result = await scanProjectFolder("/project");
|
||||
|
|
@ -82,7 +88,7 @@ describe("tauri fs helpers", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("filters bytecode files while keeping real source files", async () => {
|
||||
it("keeps arbitrary file formats visible as other files", async () => {
|
||||
vi.mocked(readDir).mockResolvedValue([
|
||||
{ name: "module.pyc", isDirectory: false },
|
||||
{ name: "worker.py", isDirectory: false },
|
||||
|
|
@ -93,10 +99,11 @@ describe("tauri fs helpers", () => {
|
|||
const result = await scanProjectFolder("/project");
|
||||
|
||||
expect(result.files.map((file) => file.relativePath)).toEqual([
|
||||
"module.pyc",
|
||||
"worker.py",
|
||||
"notes.txt",
|
||||
]);
|
||||
expect(stat).toHaveBeenCalledTimes(2);
|
||||
expect(stat).toHaveBeenCalledTimes(3);
|
||||
expect(result.files.every((file) => file.type === "other")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -23,13 +23,17 @@ vi.mock("@/stores/history-store", () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
import { useClaudeChatStore } from "@/stores/claude-chat-store";
|
||||
import {
|
||||
CLAUDE_CODE_PROVIDER_ID,
|
||||
useClaudeChatStore,
|
||||
} from "@/stores/claude-chat-store";
|
||||
|
||||
function resetClaudeChatStore() {
|
||||
useClaudeChatStore.setState({
|
||||
messages: [],
|
||||
sessionId: null,
|
||||
isStreaming: false,
|
||||
streamingStartedAt: null,
|
||||
error: null,
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
|
|
@ -37,9 +41,13 @@ function resetClaudeChatStore() {
|
|||
{
|
||||
id: "tab-default",
|
||||
title: "New Chat",
|
||||
projectPath: "/project",
|
||||
sessionId: null,
|
||||
providerKey: CLAUDE_CODE_PROVIDER_ID,
|
||||
sessionProviderKey: null,
|
||||
messages: [],
|
||||
isStreaming: false,
|
||||
streamingStartedAt: null,
|
||||
error: null,
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
|
|
@ -47,9 +55,13 @@ function resetClaudeChatStore() {
|
|||
},
|
||||
],
|
||||
activeTabId: "tab-default",
|
||||
activeProjectPath: "/project",
|
||||
pendingInitialPrompt: null,
|
||||
pendingAttachments: [],
|
||||
pendingPinnedContextRemovalLabels: [],
|
||||
selectedModel: "opus",
|
||||
selectedProviderCredentialId: CLAUDE_CODE_PROVIDER_ID,
|
||||
selectedProviderModels: {},
|
||||
effortLevel: "medium",
|
||||
_cancelledByUser: false,
|
||||
});
|
||||
|
|
@ -168,4 +180,265 @@ describe("useClaudeChatStore.sendPrompt context assembly", () => {
|
|||
"[claude] Before Claude edit",
|
||||
);
|
||||
});
|
||||
|
||||
it("sends Claude Code when the Claude provider option is selected", async () => {
|
||||
useClaudeChatStore.setState({
|
||||
selectedProviderCredentialId: CLAUDE_CODE_PROVIDER_ID,
|
||||
});
|
||||
|
||||
await useClaudeChatStore.getState().sendPrompt("Use Claude");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
"execute_claude_code",
|
||||
expect.objectContaining({
|
||||
providerCredentialId: null,
|
||||
providerModelOverride: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("starts Claude Code with prior context when switching from a direct provider", async () => {
|
||||
useClaudeChatStore.setState((state) => ({
|
||||
sessionId: "qwen-session",
|
||||
selectedProviderCredentialId: CLAUDE_CODE_PROVIDER_ID,
|
||||
tabs: state.tabs.map((tab) =>
|
||||
tab.id === "tab-default"
|
||||
? {
|
||||
...tab,
|
||||
sessionId: "qwen-session",
|
||||
providerKey: CLAUDE_CODE_PROVIDER_ID,
|
||||
sessionProviderKey: "openai-compatible:qwen-cred",
|
||||
messages: [
|
||||
{
|
||||
type: "user",
|
||||
message: {
|
||||
content: [{ type: "text", text: "Old DS question" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [{ type: "text", text: "Old DS answer" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: tab,
|
||||
),
|
||||
}));
|
||||
|
||||
await useClaudeChatStore.getState().sendPrompt("Use Claude now");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
"execute_claude_code",
|
||||
expect.objectContaining({
|
||||
providerCredentialId: null,
|
||||
providerModelOverride: null,
|
||||
prompt: expect.stringContaining("[Provider switch context]"),
|
||||
}),
|
||||
);
|
||||
const prompt = (vi.mocked(invoke).mock.calls[0]?.[1] as any).prompt;
|
||||
expect(prompt).toContain("Old DS question");
|
||||
expect(prompt).toContain("Old DS answer");
|
||||
expect(prompt).toContain("Use Claude now");
|
||||
expect(
|
||||
vi
|
||||
.mocked(invoke)
|
||||
.mock.calls.some(([command]) => command === "resume_claude_code"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the same backend session when switching between OpenAI-compatible providers", async () => {
|
||||
useClaudeChatStore.setState((state) => ({
|
||||
sessionId: "shared-session",
|
||||
selectedProviderCredentialId: "deepseek-cred",
|
||||
selectedProviderModels: { "deepseek-cred": "deepseek-chat" },
|
||||
tabs: state.tabs.map((tab) =>
|
||||
tab.id === "tab-default"
|
||||
? {
|
||||
...tab,
|
||||
sessionId: "shared-session",
|
||||
providerKey: "openai-compatible:deepseek-cred",
|
||||
sessionProviderKey: "openai-compatible:qwen-cred",
|
||||
}
|
||||
: tab,
|
||||
),
|
||||
}));
|
||||
|
||||
await useClaudeChatStore.getState().sendPrompt("Use DeepSeek now");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
"resume_claude_code",
|
||||
expect.objectContaining({
|
||||
sessionId: "shared-session",
|
||||
providerCredentialId: "deepseek-cred",
|
||||
providerModelOverride: "deepseek-chat",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes an OpenAI-compatible model override with the provider credential", async () => {
|
||||
useClaudeChatStore.getState().setSelectedProviderCredentialId("qwen-cred");
|
||||
useClaudeChatStore.setState({
|
||||
selectedProviderModels: { "qwen-cred": "qwen3.7-plus" },
|
||||
});
|
||||
|
||||
await useClaudeChatStore.getState().sendPrompt("Use Qwen");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
"execute_claude_code",
|
||||
expect.objectContaining({
|
||||
providerCredentialId: "qwen-cred",
|
||||
providerModelOverride: "qwen3.7-plus",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useClaudeChatStore.resumeSession", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetClaudeChatStore();
|
||||
setMockDocumentState();
|
||||
});
|
||||
|
||||
it("restores token totals from loaded session history", async () => {
|
||||
vi.mocked(invoke).mockResolvedValueOnce([
|
||||
{
|
||||
type: "user",
|
||||
message: { content: [{ type: "text", text: "hello" }] },
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [{ type: "text", text: "hi" }],
|
||||
usage: { input_tokens: 11, output_tokens: 7 },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
usage: { input_tokens: 13, output_tokens: 5 },
|
||||
},
|
||||
]);
|
||||
|
||||
await useClaudeChatStore.getState().resumeSession("session-123");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("load_session_history", {
|
||||
projectPath: "/project",
|
||||
sessionId: "session-123",
|
||||
});
|
||||
|
||||
const state = useClaudeChatStore.getState();
|
||||
expect(state.sessionId).toBe("session-123");
|
||||
expect(state.messages).toHaveLength(3);
|
||||
expect(state.totalInputTokens).toBe(24);
|
||||
expect(state.totalOutputTokens).toBe(12);
|
||||
});
|
||||
|
||||
it("does not reuse a tab from another project with the same session id", async () => {
|
||||
vi.mocked(invoke).mockResolvedValueOnce([
|
||||
{
|
||||
type: "user",
|
||||
message: { content: [{ type: "text", text: "from current project" }] },
|
||||
},
|
||||
]);
|
||||
|
||||
useClaudeChatStore.setState((state) => {
|
||||
const baseTab = state.tabs[0];
|
||||
return {
|
||||
tabs: [
|
||||
{
|
||||
...baseTab,
|
||||
id: "tab-current",
|
||||
projectPath: "/project",
|
||||
sessionId: null,
|
||||
messages: [],
|
||||
},
|
||||
{
|
||||
...baseTab,
|
||||
id: "tab-other",
|
||||
title: "Other project",
|
||||
projectPath: "/other-project",
|
||||
sessionId: "shared-session-id",
|
||||
messages: [
|
||||
{
|
||||
type: "user",
|
||||
message: {
|
||||
content: [{ type: "text", text: "from another project" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
activeTabId: "tab-current",
|
||||
activeProjectPath: "/project",
|
||||
messages: [],
|
||||
sessionId: null,
|
||||
};
|
||||
});
|
||||
|
||||
await useClaudeChatStore.getState().resumeSession("shared-session-id");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("load_session_history", {
|
||||
projectPath: "/project",
|
||||
sessionId: "shared-session-id",
|
||||
});
|
||||
|
||||
const state = useClaudeChatStore.getState();
|
||||
const otherProjectTab = state.tabs.find((tab) => tab.id === "tab-other");
|
||||
expect(state.activeTabId).toBe("tab-current");
|
||||
expect(state.activeProjectPath).toBe("/project");
|
||||
expect(state.messages[0].message?.content?.[0].text).toBe(
|
||||
"from current project",
|
||||
);
|
||||
expect(otherProjectTab?.messages[0].message?.content?.[0].text).toBe(
|
||||
"from another project",
|
||||
);
|
||||
});
|
||||
|
||||
it("hides internal file and pasted-image context when restoring history", async () => {
|
||||
const tempImagePath = [
|
||||
"C:\\Temp",
|
||||
"ClaudePrism",
|
||||
"chat-pastes",
|
||||
"1781110224092-1-paste-1781110223586-1.png",
|
||||
].join("\\");
|
||||
const restoredPrompt = [
|
||||
"[Currently open file: main.tex]",
|
||||
"[Selection: Pasted image]",
|
||||
"[Selected text:",
|
||||
`[Temporary pasted image: ${tempImagePath}]`,
|
||||
"Use this image file as visual context for the user's message.",
|
||||
"]",
|
||||
"",
|
||||
"Please inspect this image",
|
||||
].join("\n");
|
||||
|
||||
vi.mocked(invoke).mockResolvedValueOnce([
|
||||
{
|
||||
type: "user",
|
||||
message: {
|
||||
content: restoredPrompt,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [{ type: "text", text: "OK" }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
await useClaudeChatStore.getState().resumeSession("session-with-image");
|
||||
|
||||
const state = useClaudeChatStore.getState();
|
||||
const userContent = state.messages[0].message?.content as any;
|
||||
const activeTab = state.tabs.find((tab) => tab.id === state.activeTabId);
|
||||
|
||||
expect(userContent).toBe("Pasted image\nPlease inspect this image");
|
||||
expect(userContent).not.toContain("[Currently open file:");
|
||||
expect(userContent).not.toContain("[Temporary pasted image:");
|
||||
expect(activeTab?.title).toBe("Please inspect this image");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,17 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { offsetToLineCol } from "@/stores/claude-chat-store";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
CLAUDE_CODE_PROVIDER_ID,
|
||||
SELECTED_PROVIDER_CREDENTIAL_STORAGE_KEY,
|
||||
loadSelectedProviderCredentialId,
|
||||
offsetToLineCol,
|
||||
useClaudeChatStore,
|
||||
} from "@/stores/claude-chat-store";
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
useClaudeChatStore.setState({ selectedProviderCredentialId: null });
|
||||
});
|
||||
|
||||
describe("offsetToLineCol", () => {
|
||||
it("returns line 1, col 1 for offset 0 on empty string", () => {
|
||||
|
|
@ -38,3 +50,258 @@ describe("offsetToLineCol", () => {
|
|||
expect(offsetToLineCol("\n\n", 2)).toEqual({ line: 3, col: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider selection persistence", () => {
|
||||
it("persists Claude Code as an explicit provider selection", () => {
|
||||
useClaudeChatStore
|
||||
.getState()
|
||||
.setSelectedProviderCredentialId(CLAUDE_CODE_PROVIDER_ID);
|
||||
|
||||
expect(
|
||||
sessionStorage.getItem(SELECTED_PROVIDER_CREDENTIAL_STORAGE_KEY),
|
||||
).toBe(CLAUDE_CODE_PROVIDER_ID);
|
||||
expect(
|
||||
localStorage.getItem(SELECTED_PROVIDER_CREDENTIAL_STORAGE_KEY),
|
||||
).toBeNull();
|
||||
expect(loadSelectedProviderCredentialId()).toBe(CLAUDE_CODE_PROVIDER_ID);
|
||||
});
|
||||
|
||||
it("persists and clears OpenAI-compatible provider selections", () => {
|
||||
useClaudeChatStore.getState().setSelectedProviderCredentialId("qwen");
|
||||
|
||||
expect(
|
||||
sessionStorage.getItem(SELECTED_PROVIDER_CREDENTIAL_STORAGE_KEY),
|
||||
).toBe("qwen");
|
||||
expect(
|
||||
localStorage.getItem(SELECTED_PROVIDER_CREDENTIAL_STORAGE_KEY),
|
||||
).toBeNull();
|
||||
|
||||
useClaudeChatStore.getState().setSelectedProviderCredentialId(null);
|
||||
|
||||
expect(
|
||||
sessionStorage.getItem(SELECTED_PROVIDER_CREDENTIAL_STORAGE_KEY),
|
||||
).toBeNull();
|
||||
expect(loadSelectedProviderCredentialId()).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps provider selections isolated between chat tabs", () => {
|
||||
const store = useClaudeChatStore.getState();
|
||||
const firstTabId = store.activeTabId;
|
||||
|
||||
store.setSelectedProviderCredentialId("qwen");
|
||||
const secondTabId = store.createTab();
|
||||
useClaudeChatStore.getState().setSelectedProviderCredentialId("gemini");
|
||||
|
||||
expect(useClaudeChatStore.getState().selectedProviderCredentialId).toBe(
|
||||
"gemini",
|
||||
);
|
||||
|
||||
useClaudeChatStore.getState().setActiveTab(firstTabId);
|
||||
expect(useClaudeChatStore.getState().selectedProviderCredentialId).toBe(
|
||||
"qwen",
|
||||
);
|
||||
|
||||
useClaudeChatStore.getState().setActiveTab(secondTabId);
|
||||
expect(useClaudeChatStore.getState().selectedProviderCredentialId).toBe(
|
||||
"gemini",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("project-scoped chat state", () => {
|
||||
it("resets tabs for a new project without clearing a pending initial prompt", () => {
|
||||
useClaudeChatStore.setState((state) => {
|
||||
const baseTab = state.tabs[0];
|
||||
const message = {
|
||||
type: "user" as const,
|
||||
message: { content: [{ type: "text" as const, text: "old project" }] },
|
||||
};
|
||||
|
||||
return {
|
||||
pendingInitialPrompt: "build this project",
|
||||
pendingAttachments: [
|
||||
{
|
||||
label: "old attachment",
|
||||
filePath: "/project-a/old.png",
|
||||
selectedText: "old",
|
||||
},
|
||||
],
|
||||
pendingPinnedContextRemovalLabels: ["@old.tex"],
|
||||
activeProjectPath: "/project-a",
|
||||
activeTabId: "tab-project-a",
|
||||
sessionId: "session-project-a",
|
||||
messages: [message],
|
||||
tabs: [
|
||||
{
|
||||
...baseTab,
|
||||
id: "tab-project-a",
|
||||
title: "Old project chat",
|
||||
projectPath: "/project-a",
|
||||
sessionId: "session-project-a",
|
||||
messages: [message],
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
useClaudeChatStore.getState().resetForProject("/project-b");
|
||||
|
||||
const state = useClaudeChatStore.getState();
|
||||
const activeTab = state.tabs.find((tab) => tab.id === state.activeTabId);
|
||||
expect(state.activeProjectPath).toBe("/project-b");
|
||||
expect(activeTab?.projectPath).toBe("/project-b");
|
||||
expect(state.sessionId).toBeNull();
|
||||
expect(state.messages).toEqual([]);
|
||||
expect(state.pendingAttachments).toEqual([]);
|
||||
expect(state.pendingPinnedContextRemovalLabels).toEqual([]);
|
||||
expect(state.pendingInitialPrompt).toBe("build this project");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pinned context removal requests", () => {
|
||||
it("queues and consumes pinned context labels to remove", () => {
|
||||
const chat = useClaudeChatStore.getState();
|
||||
|
||||
chat.requestPinnedContextRemoval(["@main.tex:1:1-1:5"]);
|
||||
chat.requestPinnedContextRemoval(["@main.tex:2:1-2:5"]);
|
||||
|
||||
expect(
|
||||
useClaudeChatStore.getState().pendingPinnedContextRemovalLabels,
|
||||
).toEqual(["@main.tex:1:1-1:5", "@main.tex:2:1-2:5"]);
|
||||
|
||||
expect(
|
||||
useClaudeChatStore.getState().consumePendingPinnedContextRemovals(),
|
||||
).toEqual(["@main.tex:1:1-1:5", "@main.tex:2:1-2:5"]);
|
||||
expect(
|
||||
useClaudeChatStore.getState().pendingPinnedContextRemovalLabels,
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("queued guidance", () => {
|
||||
it("queues and consumes guidance for the active tab", () => {
|
||||
const chat = useClaudeChatStore.getState();
|
||||
const tabId = chat.activeTabId;
|
||||
|
||||
chat.clearQueuedGuidance(tabId);
|
||||
chat.queueGuidance(tabId, "please focus on the API key deletion flow", {
|
||||
label: "@main.tex:1:1-1:8",
|
||||
filePath: "main.tex",
|
||||
selectedText: "selected",
|
||||
});
|
||||
|
||||
expect(
|
||||
useClaudeChatStore.getState().tabs.find((tab) => tab.id === tabId)
|
||||
?.queuedGuidance,
|
||||
).toHaveLength(1);
|
||||
|
||||
const queued = useClaudeChatStore.getState().consumeQueuedGuidance(tabId);
|
||||
expect(queued?.prompt).toBe("please focus on the API key deletion flow");
|
||||
expect(queued?.contextOverride?.filePath).toBe("main.tex");
|
||||
expect(
|
||||
useClaudeChatStore.getState().tabs.find((tab) => tab.id === tabId)
|
||||
?.queuedGuidance,
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("can remove and consume a specific queued guidance item", () => {
|
||||
const chat = useClaudeChatStore.getState();
|
||||
const tabId = chat.activeTabId;
|
||||
|
||||
chat.clearQueuedGuidance(tabId);
|
||||
chat.queueGuidance(tabId, "first");
|
||||
chat.queueGuidance(tabId, "second");
|
||||
chat.queueGuidance(tabId, "third");
|
||||
|
||||
const queue = useClaudeChatStore
|
||||
.getState()
|
||||
.tabs.find((tab) => tab.id === tabId)?.queuedGuidance;
|
||||
expect(queue?.map((item) => item.prompt)).toEqual([
|
||||
"first",
|
||||
"second",
|
||||
"third",
|
||||
]);
|
||||
|
||||
chat.removeQueuedGuidance(tabId, queue![1].id);
|
||||
expect(
|
||||
useClaudeChatStore
|
||||
.getState()
|
||||
.tabs.find((tab) => tab.id === tabId)
|
||||
?.queuedGuidance?.map((item) => item.prompt),
|
||||
).toEqual(["first", "third"]);
|
||||
|
||||
const thirdId = useClaudeChatStore
|
||||
.getState()
|
||||
.tabs.find((tab) => tab.id === tabId)?.queuedGuidance?.[1].id;
|
||||
const selected = chat.consumeQueuedGuidance(tabId, thirdId);
|
||||
expect(selected?.prompt).toBe("third");
|
||||
expect(
|
||||
useClaudeChatStore
|
||||
.getState()
|
||||
.tabs.find((tab) => tab.id === tabId)
|
||||
?.queuedGuidance?.map((item) => item.prompt),
|
||||
).toEqual(["first"]);
|
||||
});
|
||||
|
||||
it("marks multiple queued guidance items as displayed in chat", () => {
|
||||
const chat = useClaudeChatStore.getState();
|
||||
const tabId = chat.activeTabId;
|
||||
|
||||
chat.clearQueuedGuidance(tabId);
|
||||
chat.queueGuidance(tabId, "first");
|
||||
chat.queueGuidance(tabId, "second");
|
||||
chat.queueGuidance(tabId, "third");
|
||||
|
||||
const queue = useClaudeChatStore
|
||||
.getState()
|
||||
.tabs.find((tab) => tab.id === tabId)?.queuedGuidance;
|
||||
const secondId = queue?.[1].id;
|
||||
const thirdId = queue?.[2].id;
|
||||
|
||||
expect(
|
||||
useClaudeChatStore
|
||||
.getState()
|
||||
.displayQueuedGuidanceInChat(tabId, secondId),
|
||||
).toBe(secondId);
|
||||
expect(
|
||||
useClaudeChatStore.getState().displayQueuedGuidanceInChat(tabId, thirdId),
|
||||
).toBe(thirdId);
|
||||
|
||||
expect(
|
||||
useClaudeChatStore
|
||||
.getState()
|
||||
.tabs.find((tab) => tab.id === tabId)
|
||||
?.queuedGuidance?.map((item) => ({
|
||||
prompt: item.prompt,
|
||||
displayedInChat: item.displayedInChat ?? false,
|
||||
})),
|
||||
).toEqual([
|
||||
{ prompt: "first", displayedInChat: false },
|
||||
{ prompt: "second", displayedInChat: true },
|
||||
{ prompt: "third", displayedInChat: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("consumes displayed guidance before ordinary queued guidance", () => {
|
||||
const chat = useClaudeChatStore.getState();
|
||||
const tabId = chat.activeTabId;
|
||||
|
||||
chat.clearQueuedGuidance(tabId);
|
||||
chat.queueGuidance(tabId, "first");
|
||||
chat.queueGuidance(tabId, "second");
|
||||
|
||||
const secondId = useClaudeChatStore
|
||||
.getState()
|
||||
.tabs.find((tab) => tab.id === tabId)?.queuedGuidance?.[1].id;
|
||||
useClaudeChatStore.getState().displayQueuedGuidanceInChat(tabId, secondId);
|
||||
|
||||
const selected = chat.consumeQueuedGuidance(tabId);
|
||||
expect(selected?.prompt).toBe("second");
|
||||
expect(
|
||||
useClaudeChatStore
|
||||
.getState()
|
||||
.tabs.find((tab) => tab.id === tabId)
|
||||
?.queuedGuidance?.map((item) => item.prompt),
|
||||
).toEqual(["first"]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useClaudeSetupStore } from "@/stores/claude-setup-store";
|
||||
|
||||
// advanceSteps is module-private — replicate for testing
|
||||
type StepStatus = "pending" | "active" | "complete" | "error";
|
||||
|
|
@ -95,3 +97,384 @@ describe("advanceSteps", () => {
|
|||
expect(result[2].status).toBe("pending");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useClaudeSetupStore.saveApiKey", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useClaudeSetupStore.setState({
|
||||
status: "ready",
|
||||
isInstalling: false,
|
||||
isLoggingIn: false,
|
||||
isSavingApiKey: false,
|
||||
isClearingApiKey: false,
|
||||
error: null,
|
||||
version: "1.0.0",
|
||||
providerKind: "claude-code",
|
||||
accountEmail: null,
|
||||
providerModel: null,
|
||||
providerBaseUrl: null,
|
||||
openAiCredentials: [],
|
||||
activeOpenAiCredentialId: null,
|
||||
installSteps: [],
|
||||
installLogs: [],
|
||||
installLogsVisible: false,
|
||||
loginSteps: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("requires Claude Code before saving provider credentials", async () => {
|
||||
useClaudeSetupStore.setState({ status: "not-installed" });
|
||||
|
||||
const success = await useClaudeSetupStore
|
||||
.getState()
|
||||
.saveApiKey(
|
||||
"sk-test",
|
||||
"https://api.deepseek.com/anthropic",
|
||||
"openai-compatible",
|
||||
"deepseek-v4-pro",
|
||||
);
|
||||
|
||||
expect(success).toBe(false);
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
expect(useClaudeSetupStore.getState().error).toBe(
|
||||
"Install Claude Code before configuring an AI provider.",
|
||||
);
|
||||
});
|
||||
|
||||
it("verifies OpenAI-compatible credentials before saving them", async () => {
|
||||
vi.mocked(invoke).mockImplementation(async (command) => {
|
||||
if (command === "check_claude_status") {
|
||||
return {
|
||||
installed: true,
|
||||
authenticated: true,
|
||||
binary_path: null,
|
||||
version: "OpenAI-compatible provider",
|
||||
provider_kind: "openai-compatible",
|
||||
account_email: null,
|
||||
provider_model: "deepseek-v4-pro",
|
||||
provider_base_url: "https://api.deepseek.com/anthropic",
|
||||
missing_git: false,
|
||||
};
|
||||
}
|
||||
if (command === "list_openai_compatible_credentials") {
|
||||
return [
|
||||
{
|
||||
id: "cred-1",
|
||||
label: "DeepSeek",
|
||||
model: "deepseek-v4-pro",
|
||||
base_url: "https://api.deepseek.com/anthropic",
|
||||
},
|
||||
];
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const success = await useClaudeSetupStore
|
||||
.getState()
|
||||
.saveApiKey(
|
||||
"sk-test",
|
||||
"https://api.deepseek.com/anthropic",
|
||||
"openai-compatible",
|
||||
"deepseek-v4-pro",
|
||||
);
|
||||
|
||||
expect(success).toBe(true);
|
||||
expect(invoke).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"verify_openai_compatible_api_key",
|
||||
{
|
||||
apiKey: "sk-test",
|
||||
baseUrl: "https://api.deepseek.com/anthropic",
|
||||
model: "deepseek-v4-pro",
|
||||
},
|
||||
);
|
||||
expect(invoke).toHaveBeenNthCalledWith(2, "save_anthropic_api_key", {
|
||||
apiKey: "sk-test",
|
||||
baseUrl: "https://api.deepseek.com/anthropic",
|
||||
provider: "openai-compatible",
|
||||
model: "deepseek-v4-pro",
|
||||
credentialLabel: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes legacy DeepSeek root URLs to the native Anthropic endpoint", async () => {
|
||||
vi.mocked(invoke).mockResolvedValue(null);
|
||||
|
||||
const success = await useClaudeSetupStore
|
||||
.getState()
|
||||
.saveApiKey(
|
||||
"sk-test",
|
||||
"https://api.deepseek.com",
|
||||
"openai-compatible",
|
||||
"deepseek-v4-pro",
|
||||
);
|
||||
|
||||
expect(success).toBe(true);
|
||||
expect(invoke).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"verify_openai_compatible_api_key",
|
||||
{
|
||||
apiKey: "sk-test",
|
||||
baseUrl: "https://api.deepseek.com/anthropic",
|
||||
model: "deepseek-v4-pro",
|
||||
},
|
||||
);
|
||||
expect(invoke).toHaveBeenNthCalledWith(2, "save_anthropic_api_key", {
|
||||
apiKey: "sk-test",
|
||||
baseUrl: "https://api.deepseek.com/anthropic",
|
||||
provider: "openai-compatible",
|
||||
model: "deepseek-v4-pro",
|
||||
credentialLabel: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes legacy Qwen compatible URLs to the native Anthropic endpoint", async () => {
|
||||
vi.mocked(invoke).mockResolvedValue(null);
|
||||
|
||||
const success = await useClaudeSetupStore
|
||||
.getState()
|
||||
.saveApiKey(
|
||||
"sk-test",
|
||||
"https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"openai-compatible",
|
||||
"qwen3-max-2026-01-23",
|
||||
);
|
||||
|
||||
expect(success).toBe(true);
|
||||
expect(invoke).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"verify_openai_compatible_api_key",
|
||||
{
|
||||
apiKey: "sk-test",
|
||||
baseUrl: "https://dashscope.aliyuncs.com/apps/anthropic",
|
||||
model: "qwen3-max-2026-01-23",
|
||||
},
|
||||
);
|
||||
expect(invoke).toHaveBeenNthCalledWith(2, "save_anthropic_api_key", {
|
||||
apiKey: "sk-test",
|
||||
baseUrl: "https://dashscope.aliyuncs.com/apps/anthropic",
|
||||
provider: "openai-compatible",
|
||||
model: "qwen3-max-2026-01-23",
|
||||
credentialLabel: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves Qwen native Anthropic URLs when saving credentials", async () => {
|
||||
vi.mocked(invoke).mockResolvedValue(null);
|
||||
|
||||
const success = await useClaudeSetupStore
|
||||
.getState()
|
||||
.saveApiKey(
|
||||
"sk-test",
|
||||
"https://dashscope.aliyuncs.com/apps/anthropic/v1",
|
||||
"openai-compatible",
|
||||
"qwen3-max-2026-01-23",
|
||||
);
|
||||
|
||||
expect(success).toBe(true);
|
||||
expect(invoke).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"verify_openai_compatible_api_key",
|
||||
{
|
||||
apiKey: "sk-test",
|
||||
baseUrl: "https://dashscope.aliyuncs.com/apps/anthropic",
|
||||
model: "qwen3-max-2026-01-23",
|
||||
},
|
||||
);
|
||||
expect(invoke).toHaveBeenNthCalledWith(2, "save_anthropic_api_key", {
|
||||
apiKey: "sk-test",
|
||||
baseUrl: "https://dashscope.aliyuncs.com/apps/anthropic",
|
||||
provider: "openai-compatible",
|
||||
model: "qwen3-max-2026-01-23",
|
||||
credentialLabel: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes Moonshot compatible URLs to the native Anthropic endpoint", async () => {
|
||||
vi.mocked(invoke).mockResolvedValue(null);
|
||||
|
||||
const success = await useClaudeSetupStore
|
||||
.getState()
|
||||
.saveApiKey(
|
||||
"sk-test",
|
||||
"https://api.moonshot.cn/v1",
|
||||
"openai-compatible",
|
||||
"kimi-k2.5",
|
||||
);
|
||||
|
||||
expect(success).toBe(true);
|
||||
expect(invoke).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"verify_openai_compatible_api_key",
|
||||
{
|
||||
apiKey: "sk-test",
|
||||
baseUrl: "https://api.moonshot.ai/anthropic",
|
||||
model: "kimi-k2.5",
|
||||
},
|
||||
);
|
||||
expect(invoke).toHaveBeenNthCalledWith(2, "save_anthropic_api_key", {
|
||||
apiKey: "sk-test",
|
||||
baseUrl: "https://api.moonshot.ai/anthropic",
|
||||
provider: "openai-compatible",
|
||||
model: "kimi-k2.5",
|
||||
credentialLabel: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves Moonshot Anthropic-looking URLs when saving credentials", async () => {
|
||||
vi.mocked(invoke).mockResolvedValue(null);
|
||||
|
||||
const success = await useClaudeSetupStore
|
||||
.getState()
|
||||
.saveApiKey(
|
||||
"sk-test",
|
||||
"https://api.moonshot.ai/anthropic/v1",
|
||||
"openai-compatible",
|
||||
"kimi-k2.5",
|
||||
);
|
||||
|
||||
expect(success).toBe(true);
|
||||
expect(invoke).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"verify_openai_compatible_api_key",
|
||||
{
|
||||
apiKey: "sk-test",
|
||||
baseUrl: "https://api.moonshot.ai/anthropic",
|
||||
model: "kimi-k2.5",
|
||||
},
|
||||
);
|
||||
expect(invoke).toHaveBeenNthCalledWith(2, "save_anthropic_api_key", {
|
||||
apiKey: "sk-test",
|
||||
baseUrl: "https://api.moonshot.ai/anthropic",
|
||||
provider: "openai-compatible",
|
||||
model: "kimi-k2.5",
|
||||
credentialLabel: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows local OpenAI-compatible providers without an API key", async () => {
|
||||
vi.mocked(invoke).mockImplementation(async (command) => {
|
||||
if (command === "check_claude_status") {
|
||||
return {
|
||||
installed: true,
|
||||
authenticated: true,
|
||||
binary_path: null,
|
||||
version: "OpenAI-compatible provider",
|
||||
provider_kind: "openai-compatible",
|
||||
account_email: null,
|
||||
provider_model: "llama3.2",
|
||||
provider_base_url: "http://localhost:11434/v1",
|
||||
missing_git: false,
|
||||
};
|
||||
}
|
||||
if (command === "list_openai_compatible_credentials") {
|
||||
return [
|
||||
{
|
||||
id: "ollama-cred",
|
||||
label: "Ollama",
|
||||
model: "llama3.2",
|
||||
base_url: "http://localhost:11434/v1",
|
||||
},
|
||||
];
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const success = await useClaudeSetupStore
|
||||
.getState()
|
||||
.saveApiKey(
|
||||
"",
|
||||
"http://localhost:11434/v1",
|
||||
"openai-compatible",
|
||||
"llama3.2",
|
||||
"Ollama",
|
||||
);
|
||||
|
||||
expect(success).toBe(true);
|
||||
expect(invoke).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"verify_openai_compatible_api_key",
|
||||
{
|
||||
apiKey: "",
|
||||
baseUrl: "http://localhost:11434/v1",
|
||||
model: "llama3.2",
|
||||
},
|
||||
);
|
||||
expect(invoke).toHaveBeenNthCalledWith(2, "save_anthropic_api_key", {
|
||||
apiKey: "",
|
||||
baseUrl: "http://localhost:11434/v1",
|
||||
provider: "openai-compatible",
|
||||
model: "llama3.2",
|
||||
credentialLabel: "Ollama",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not save OpenAI-compatible credentials when verification fails", async () => {
|
||||
vi.mocked(invoke).mockRejectedValueOnce(
|
||||
new Error("Invalid provider API key"),
|
||||
);
|
||||
|
||||
const success = await useClaudeSetupStore
|
||||
.getState()
|
||||
.saveApiKey(
|
||||
"sk-test",
|
||||
"https://api.deepseek.com/anthropic",
|
||||
"openai-compatible",
|
||||
"deepseek-v4-pro",
|
||||
);
|
||||
|
||||
expect(success).toBe(false);
|
||||
expect(invoke).toHaveBeenCalledTimes(1);
|
||||
expect(invoke).toHaveBeenCalledWith("verify_openai_compatible_api_key", {
|
||||
apiKey: "sk-test",
|
||||
baseUrl: "https://api.deepseek.com/anthropic",
|
||||
model: "deepseek-v4-pro",
|
||||
});
|
||||
expect(useClaudeSetupStore.getState().error).toBe(
|
||||
"Invalid provider API key",
|
||||
);
|
||||
});
|
||||
|
||||
it("clears saved credentials and refreshes status", async () => {
|
||||
useClaudeSetupStore.setState({
|
||||
status: "ready",
|
||||
version: "OpenAI-compatible provider",
|
||||
providerKind: "openai-compatible",
|
||||
providerModel: "qwen3-coder-plus",
|
||||
providerBaseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
});
|
||||
vi.mocked(invoke).mockImplementation(async (command) => {
|
||||
if (command === "check_claude_status") {
|
||||
return {
|
||||
installed: true,
|
||||
authenticated: false,
|
||||
binary_path: null,
|
||||
version: "1.0.0",
|
||||
provider_kind: "claude-code",
|
||||
account_email: null,
|
||||
provider_model: null,
|
||||
provider_base_url: null,
|
||||
missing_git: false,
|
||||
};
|
||||
}
|
||||
if (command === "list_openai_compatible_credentials") {
|
||||
return [];
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const success = await useClaudeSetupStore.getState().clearApiKey();
|
||||
|
||||
expect(success).toBe(true);
|
||||
expect(invoke).toHaveBeenNthCalledWith(1, "clear_anthropic_api_key");
|
||||
expect(invoke).toHaveBeenNthCalledWith(2, "check_claude_status");
|
||||
expect(invoke).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
"list_openai_compatible_credentials",
|
||||
);
|
||||
expect(useClaudeSetupStore.getState().status).toBe("not-authenticated");
|
||||
expect(useClaudeSetupStore.getState().providerModel).toBeNull();
|
||||
expect(useClaudeSetupStore.getState().providerBaseUrl).toBeNull();
|
||||
expect(useClaudeSetupStore.getState().isClearingApiKey).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,15 +3,18 @@ import { invoke } from "@tauri-apps/api/core";
|
|||
import {
|
||||
readDir,
|
||||
readTextFile,
|
||||
rename,
|
||||
stat,
|
||||
writeTextFile,
|
||||
} from "@tauri-apps/plugin-fs";
|
||||
import {
|
||||
useDocumentStore,
|
||||
getCurrentPdfBytes,
|
||||
getCurrentPdfRootId,
|
||||
clearPdfBytesCache,
|
||||
type ProjectFile,
|
||||
} from "@/stores/document-store";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
|
||||
// Mock history store
|
||||
vi.mock("@/stores/history-store", () => ({
|
||||
|
|
@ -20,6 +23,7 @@ vi.mock("@/stores/history-store", () => ({
|
|||
init: vi.fn(() => Promise.resolve()),
|
||||
loadSnapshots: vi.fn(() => Promise.resolve()),
|
||||
createSnapshot: vi.fn(() => Promise.resolve()),
|
||||
reset: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
|
@ -66,6 +70,10 @@ describe("useDocumentStore", () => {
|
|||
isSaving: false,
|
||||
initialized: true,
|
||||
});
|
||||
useProjectStore.setState({
|
||||
recentProjects: [],
|
||||
lastProjectFolder: null,
|
||||
});
|
||||
});
|
||||
|
||||
describe("getActiveFile logic", () => {
|
||||
|
|
@ -124,7 +132,8 @@ describe("useDocumentStore", () => {
|
|||
it("skips Python cache directories and bytecode files during open", async () => {
|
||||
vi.mocked(invoke).mockResolvedValue(undefined as never);
|
||||
vi.mocked(readDir).mockImplementation(async (dir: string | URL) => {
|
||||
if (dir === "/project") {
|
||||
const dirPath = String(dir);
|
||||
if (dirPath === "/project") {
|
||||
return [
|
||||
{ name: "__pycache__", isDirectory: true },
|
||||
{ name: "main.tex", isDirectory: false },
|
||||
|
|
@ -133,17 +142,18 @@ describe("useDocumentStore", () => {
|
|||
] as any;
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected readDir path: ${dir}`);
|
||||
throw new Error(`Unexpected readDir path: ${dirPath}`);
|
||||
});
|
||||
vi.mocked(stat).mockResolvedValue({ size: 32 } as any);
|
||||
vi.mocked(readTextFile).mockImplementation(async (path: string | URL) => {
|
||||
if (path === "/project/main.tex") {
|
||||
const filePath = String(path);
|
||||
if (filePath === "/project/main.tex") {
|
||||
return "\\documentclass{article}";
|
||||
}
|
||||
if (path === "/project/tool.py") {
|
||||
if (filePath === "/project/tool.py") {
|
||||
return "print('hello')";
|
||||
}
|
||||
throw new Error(`Unexpected readTextFile path: ${path}`);
|
||||
throw new Error(`Unexpected readTextFile path: ${filePath}`);
|
||||
});
|
||||
|
||||
await useDocumentStore.getState().openProject("/project");
|
||||
|
|
@ -160,6 +170,70 @@ describe("useDocumentStore", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("renameProject", () => {
|
||||
it("renames the project folder and reopens the new path", async () => {
|
||||
vi.mocked(invoke).mockResolvedValue(undefined as never);
|
||||
vi.mocked(readDir).mockResolvedValue([
|
||||
{ name: "main.tex", isDirectory: false },
|
||||
] as any);
|
||||
vi.mocked(readTextFile).mockResolvedValue("\\documentclass{article}");
|
||||
useProjectStore.setState({
|
||||
recentProjects: [{ path: "/work/old", name: "old", lastOpened: 1 }],
|
||||
lastProjectFolder: "/work",
|
||||
});
|
||||
useDocumentStore.setState({
|
||||
projectRoot: "/work/old",
|
||||
files: [makeFile({ absolutePath: "/work/old/main.tex" })],
|
||||
});
|
||||
|
||||
await useDocumentStore.getState().renameProject("renamed");
|
||||
|
||||
expect(rename).toHaveBeenCalledWith("/work/old", "/work/renamed");
|
||||
expect(invoke).toHaveBeenCalledWith("migrate_project_sessions", {
|
||||
oldProjectPath: "/work/old",
|
||||
newProjectPath: "/work/renamed",
|
||||
});
|
||||
expect(invoke).toHaveBeenCalledWith("allow_project_directory", {
|
||||
rootPath: "/work/renamed",
|
||||
});
|
||||
expect(useDocumentStore.getState().projectRoot).toBe("/work/renamed");
|
||||
expect(useProjectStore.getState().recentProjects[0]).toMatchObject({
|
||||
path: "/work/renamed",
|
||||
name: "renamed",
|
||||
});
|
||||
expect(
|
||||
useProjectStore
|
||||
.getState()
|
||||
.recentProjects.some((project) => project.path === "/work/old"),
|
||||
).toBe(false);
|
||||
expect(useProjectStore.getState().lastProjectFolder).toBe("/work");
|
||||
});
|
||||
|
||||
it("saves dirty files before renaming the project folder", async () => {
|
||||
vi.mocked(invoke).mockResolvedValue(undefined as never);
|
||||
vi.mocked(writeTextFile).mockResolvedValue(undefined);
|
||||
vi.mocked(readDir).mockResolvedValue([
|
||||
{ name: "main.tex", isDirectory: false },
|
||||
] as any);
|
||||
vi.mocked(readTextFile).mockResolvedValue("\\documentclass{article}");
|
||||
useDocumentStore.setState({
|
||||
projectRoot: "/work/old",
|
||||
files: [
|
||||
makeFile({
|
||||
absolutePath: "/work/old/main.tex",
|
||||
content: "dirty",
|
||||
isDirty: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await useDocumentStore.getState().renameProject("renamed");
|
||||
|
||||
expect(writeTextFile).toHaveBeenCalledWith("/work/old/main.tex", "dirty");
|
||||
expect(rename).toHaveBeenCalledWith("/work/old", "/work/renamed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("insertAtCursor", () => {
|
||||
it("inserts text at cursor position", () => {
|
||||
useDocumentStore.getState().insertAtCursor(", Beautiful");
|
||||
|
|
@ -370,6 +444,39 @@ describe("useDocumentStore", () => {
|
|||
expect(state.cursorPosition).toBe(100);
|
||||
expect(state.selectionRange).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the current PDF when switching to a non-tex file", () => {
|
||||
const pdfBytes = new Uint8Array([1, 2, 3]);
|
||||
useDocumentStore.setState({
|
||||
files: [
|
||||
makeFile({
|
||||
content:
|
||||
"\\documentclass{article}\\begin{document}Hi\\end{document}",
|
||||
}),
|
||||
makeFile({
|
||||
id: "analysis.py",
|
||||
name: "analysis.py",
|
||||
relativePath: "analysis.py",
|
||||
absolutePath: "/project/analysis.py",
|
||||
type: "other",
|
||||
content: "print('hello')",
|
||||
}),
|
||||
],
|
||||
activeFileId: "main.tex",
|
||||
selectionRange: { start: 0, end: 3 },
|
||||
});
|
||||
useDocumentStore.getState().setPdfData(pdfBytes, "main.tex");
|
||||
const revisionBefore = useDocumentStore.getState().pdfRevision;
|
||||
|
||||
useDocumentStore.getState().setActiveFile("analysis.py");
|
||||
|
||||
const state = useDocumentStore.getState();
|
||||
expect(state.activeFileId).toBe("analysis.py");
|
||||
expect(state.selectionRange).toBeNull();
|
||||
expect(state.pdfRevision).toBe(revisionBefore);
|
||||
expect(getCurrentPdfRootId()).toBe("main.tex");
|
||||
expect(getCurrentPdfBytes()).toEqual(pdfBytes);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveFile", () => {
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ function resetStores() {
|
|||
messages: [],
|
||||
sessionId: null,
|
||||
isStreaming: false,
|
||||
streamingStartedAt: null,
|
||||
error: null,
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
|
|
@ -43,9 +44,13 @@ function resetStores() {
|
|||
{
|
||||
id: "tab-default",
|
||||
title: "New Chat",
|
||||
projectPath: "/project",
|
||||
sessionId: null,
|
||||
providerKey: null,
|
||||
sessionProviderKey: null,
|
||||
messages: [],
|
||||
isStreaming: false,
|
||||
streamingStartedAt: null,
|
||||
error: null,
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
|
|
@ -53,6 +58,7 @@ function resetStores() {
|
|||
},
|
||||
],
|
||||
activeTabId: "tab-default",
|
||||
activeProjectPath: "/project",
|
||||
_cancelledByUser: false,
|
||||
});
|
||||
useProposedChangesStore.setState({ changes: [] });
|
||||
|
|
@ -238,6 +244,32 @@ describe("Multi-tab merge triggers", () => {
|
|||
expect(tabBState.isStreaming).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves streaming start time when switching tabs", () => {
|
||||
const chat = useClaudeChatStore.getState();
|
||||
const tabB = chat.createTab();
|
||||
const startedAt = Date.now() - 12_000;
|
||||
|
||||
useClaudeChatStore.setState((s) => ({
|
||||
tabs: s.tabs.map((t) =>
|
||||
t.id === "tab-default"
|
||||
? { ...t, isStreaming: true, streamingStartedAt: startedAt }
|
||||
: t,
|
||||
),
|
||||
activeTabId: "tab-default",
|
||||
isStreaming: true,
|
||||
streamingStartedAt: startedAt,
|
||||
}));
|
||||
|
||||
chat.setActiveTab(tabB);
|
||||
chat.setActiveTab("tab-default");
|
||||
|
||||
const state = useClaudeChatStore.getState();
|
||||
expect(state.streamingStartedAt).toBe(startedAt);
|
||||
expect(
|
||||
state.tabs.find((t) => t.id === "tab-default")!.streamingStartedAt,
|
||||
).toBe(startedAt);
|
||||
});
|
||||
|
||||
it("_appendMessage routes to the specified tab, not the active tab", () => {
|
||||
const chat = useClaudeChatStore.getState();
|
||||
const tabB = chat.createTab();
|
||||
|
|
@ -271,6 +303,89 @@ describe("Multi-tab merge triggers", () => {
|
|||
expect(state.messages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("_appendMessage merges direct provider streaming deltas", () => {
|
||||
const chat = useClaudeChatStore.getState();
|
||||
|
||||
chat._appendMessage("tab-default", {
|
||||
type: "assistant",
|
||||
subtype: "streaming_delta",
|
||||
message: { content: [{ type: "text", text: "Hello" }] },
|
||||
});
|
||||
chat._appendMessage("tab-default", {
|
||||
type: "assistant",
|
||||
subtype: "streaming_delta",
|
||||
message: { content: [{ type: "text", text: " world" }] },
|
||||
});
|
||||
|
||||
const messages = useClaudeChatStore.getState().messages;
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].message?.content?.[0].text).toBe("Hello world");
|
||||
});
|
||||
|
||||
it("_appendMessage merges direct provider streaming thinking deltas", () => {
|
||||
const chat = useClaudeChatStore.getState();
|
||||
|
||||
chat._appendMessage("tab-default", {
|
||||
type: "assistant",
|
||||
subtype: "streaming_delta",
|
||||
message: {
|
||||
content: [
|
||||
{ type: "thinking", thinking: "Reason A. " },
|
||||
{ type: "text", text: "Hello" },
|
||||
],
|
||||
},
|
||||
});
|
||||
chat._appendMessage("tab-default", {
|
||||
type: "assistant",
|
||||
subtype: "streaming_delta",
|
||||
message: {
|
||||
content: [
|
||||
{ type: "thinking", thinking: "Reason B." },
|
||||
{ type: "text", text: " world" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const messages = useClaudeChatStore.getState().messages;
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].message?.content?.[0].type).toBe("thinking");
|
||||
expect(messages[0].message?.content?.[0].thinking).toBe(
|
||||
"Reason A. Reason B.",
|
||||
);
|
||||
expect(messages[0].message?.content?.[1].text).toBe("Hello world");
|
||||
});
|
||||
|
||||
it("_appendMessage replaces streaming deltas with final direct provider message", () => {
|
||||
const chat = useClaudeChatStore.getState();
|
||||
|
||||
chat._appendMessage("tab-default", {
|
||||
type: "assistant",
|
||||
subtype: "streaming_delta",
|
||||
message: { content: [{ type: "text", text: "Draft" }] },
|
||||
});
|
||||
chat._appendMessage("tab-default", {
|
||||
type: "assistant",
|
||||
subtype: "streaming_final",
|
||||
message: {
|
||||
content: [
|
||||
{ type: "text", text: "Final" },
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool-1",
|
||||
name: "Read",
|
||||
input: { file_path: "main.tex" },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const messages = useClaudeChatStore.getState().messages;
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].subtype).toBe("streaming_final");
|
||||
expect(messages[0].message?.content?.[0].text).toBe("Final");
|
||||
expect(messages[0].message?.content?.[1].type).toBe("tool_use");
|
||||
});
|
||||
|
||||
it("_setSessionId routes to the specified tab", () => {
|
||||
const chat = useClaudeChatStore.getState();
|
||||
const tabB = chat.createTab();
|
||||
|
|
|
|||
|
|
@ -30,6 +30,18 @@ describe("useProjectStore", () => {
|
|||
expect(recentProjects[1].path).toBe("/b");
|
||||
});
|
||||
|
||||
it("normalizes trailing separators when deduplicating", () => {
|
||||
const store = useProjectStore.getState();
|
||||
store.addRecentProject("C:\\Projects\\ClaudePrism\\paper\\");
|
||||
store.addRecentProject("C:\\Projects\\ClaudePrism\\paper");
|
||||
const { recentProjects } = useProjectStore.getState();
|
||||
expect(recentProjects).toHaveLength(1);
|
||||
expect(recentProjects[0]).toMatchObject({
|
||||
path: "C:\\Projects\\ClaudePrism\\paper",
|
||||
name: "paper",
|
||||
});
|
||||
});
|
||||
|
||||
it("limits to MAX_RECENT (10) entries", () => {
|
||||
const store = useProjectStore.getState();
|
||||
for (let i = 0; i < 12; i++) {
|
||||
|
|
@ -67,4 +79,38 @@ describe("useProjectStore", () => {
|
|||
expect(recentProjects[0].path).toBe("/b");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renameRecentProject", () => {
|
||||
it("replaces the old recent project path with the new folder path", () => {
|
||||
const store = useProjectStore.getState();
|
||||
store.addRecentProject("/work/old");
|
||||
store.addRecentProject("/work/other");
|
||||
store.renameRecentProject("/work/old", "/work/new");
|
||||
|
||||
const { recentProjects } = useProjectStore.getState();
|
||||
expect(recentProjects[0]).toMatchObject({
|
||||
path: "/work/new",
|
||||
name: "new",
|
||||
});
|
||||
expect(
|
||||
recentProjects.some((project) => project.path === "/work/old"),
|
||||
).toBe(false);
|
||||
expect(
|
||||
recentProjects.some((project) => project.path === "/work/other"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("matches renamed paths even when the old recent path has a trailing slash", () => {
|
||||
const store = useProjectStore.getState();
|
||||
store.addRecentProject("/work/old/");
|
||||
store.renameRecentProject("/work/old", "/work/new/");
|
||||
|
||||
const { recentProjects } = useProjectStore.getState();
|
||||
expect(recentProjects).toHaveLength(1);
|
||||
expect(recentProjects[0]).toMatchObject({
|
||||
path: "/work/new",
|
||||
name: "new",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
3
apps/desktop/src/assets/providers/anthropic.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="248" height="248" viewBox="0 0 248 248" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M52.4285 162.873L98.7844 136.879L99.5485 134.602L98.7844 133.334H96.4921L88.7237 132.862L62.2346 132.153L39.3113 131.207L17.0249 130.026L11.4214 128.844L6.2 121.873L6.7094 118.447L11.4214 115.257L18.171 115.847L33.0711 116.911L55.485 118.447L71.6586 119.392L95.728 121.873H99.5485L100.058 120.337L98.7844 119.392L97.7656 118.447L74.5877 102.732L49.4995 86.1905L36.3823 76.62L29.3779 71.7757L25.8121 67.2858L24.2839 57.3608L30.6515 50.2716L39.3113 50.8623L41.4763 51.4531L50.2636 58.1879L68.9842 72.7209L93.4357 90.6804L97.0015 93.6343L98.4374 92.6652L98.6571 91.9801L97.0015 89.2625L83.757 65.2772L69.621 40.8192L63.2534 30.6579L61.5978 24.632C60.9565 22.1032 60.579 20.0111 60.579 17.4246L67.8381 7.49965L71.9133 6.19995L81.7193 7.49965L85.7946 11.0443L91.9074 24.9865L101.714 46.8451L116.996 76.62L121.453 85.4816L123.873 93.6343L124.764 96.1155H126.292V94.6976L127.566 77.9197L129.858 57.3608L132.15 30.8942L132.915 23.4505L136.608 14.4708L143.994 9.62643L149.725 12.344L154.437 19.0788L153.8 23.4505L150.998 41.6463L145.522 70.1215L141.957 89.2625H143.994L146.414 86.7813L156.093 74.0206L172.266 53.698L179.398 45.6635L187.803 36.802L193.152 32.5484H203.34L210.726 43.6549L207.415 55.1159L196.972 68.3492L188.312 79.5739L175.896 96.2095L168.191 109.585L168.882 110.689L170.738 110.53L198.755 104.504L213.91 101.787L231.994 98.7149L240.144 102.496L241.036 106.395L237.852 114.311L218.495 119.037L195.826 123.645L162.07 131.592L161.696 131.893L162.137 132.547L177.36 133.925L183.855 134.279H199.774L229.447 136.524L237.215 141.605L241.8 147.867L241.036 152.711L229.065 158.737L213.019 154.956L175.45 145.977L162.587 142.787H160.805V143.85L171.502 154.366L191.242 172.089L215.82 195.011L217.094 200.682L213.91 205.172L210.599 204.699L188.949 188.394L180.544 181.069L161.696 165.118H160.422V166.772L164.752 173.152L187.803 207.771L188.949 218.405L187.294 221.832L181.308 223.959L174.813 222.777L161.187 203.754L147.305 182.486L136.098 163.345L134.745 164.2L128.075 235.42L125.019 239.082L117.887 241.8L111.902 237.31L108.718 229.984L111.902 215.452L115.722 196.547L118.779 181.541L121.58 162.873L123.291 156.636L123.14 156.219L121.773 156.449L107.699 175.752L86.304 204.699L69.3663 222.777L65.291 224.431L58.2867 220.768L58.9235 214.27L62.8713 208.48L86.304 178.705L100.44 160.155L109.551 149.507L109.462 147.967L108.959 147.924L46.6977 188.512L35.6182 189.93L30.7788 185.44L31.4156 178.115L33.7079 175.752L52.4285 162.873Z" fill="#D97757"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
14
apps/desktop/src/assets/providers/deepseek.svg
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 377.1 277.86">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #4d6bfe;
|
||||
stroke-width: 0px;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<path id="path" class="cls-1" d="M373.15,23.32c-4-1.95-5.72,1.77-8.06,3.66-.79.62-1.47,1.43-2.14,2.14-5.85,6.26-12.67,10.36-21.57,9.86-13.04-.71-24.16,3.38-33.99,13.37-2.09-12.31-9.04-19.66-19.6-24.38-5.54-2.45-11.13-4.9-14.99-10.23-2.71-3.78-3.44-8-4.81-12.16-.85-2.51-1.72-5.09-4.6-5.52-3.13-.5-4.36,2.14-5.58,4.34-4.93,8.99-6.82,18.92-6.65,28.97.43,22.58,9.97,40.56,28.89,53.37,2.16,1.46,2.71,2.95,2.03,5.09-1.29,4.4-2.82,8.68-4.19,13.09-.85,2.82-2.14,3.44-5.15,2.2-10.39-4.34-19.37-10.76-27.29-18.55-13.46-13.02-25.63-27.41-40.81-38.67-3.57-2.64-7.12-5.09-10.81-7.41-15.49-15.07,2.03-27.45,6.08-28.9,4.25-1.52,1.47-6.79-12.23-6.73-13.69.06-26.24,4.65-42.21,10.76-2.34.93-4.79,1.61-7.32,2.14-14.5-2.73-29.55-3.35-45.29-1.58-29.62,3.32-53.28,17.34-70.68,41.28C1.29,88.2-3.63,120.88,2.39,155c6.33,35.91,24.64,65.68,52.8,88.94,29.18,24.1,62.8,35.91,101.15,33.65,23.29-1.33,49.23-4.46,78.48-29.24,7.38,3.66,15.12,5.12,27.97,6.23,9.89.93,19.41-.5,26.79-2.02,11.55-2.45,10.75-13.15,6.58-15.13-33.87-15.78-26.44-9.36-33.2-14.54,17.21-20.41,43.15-41.59,53.3-110.19.79-5.46.11-8.87,0-13.3-.06-2.67.54-3.72,3.61-4.03,8.48-.96,16.72-3.29,24.28-7.47,21.94-12,30.78-31.69,32.87-55.33.31-3.6-.06-7.35-3.86-9.24ZM181.96,235.97c-32.83-25.83-48.74-34.33-55.31-33.96-6.14.34-5.04,7.38-3.69,11.97,1.41,4.53,3.26,7.66,5.85,11.63,1.78,2.64,3.01,6.57-1.78,9.49-10.57,6.58-28.95-2.2-29.82-2.64-21.38-12.59-39.26-29.24-51.87-52.01-12.16-21.92-19.23-45.43-20.39-70.52-.31-6.08,1.47-8.22,7.49-9.3,7.92-1.46,16.11-1.77,24.03-.62,33.49,4.9,62.01,19.91,85.9,43.63,13.65,13.55,23.97,29.71,34.61,45.49,11.3,16.78,23.48,32.75,38.97,45.84,5.46,4.59,9.83,8.09,14,10.67-12.59,1.4-33.62,1.71-47.99-9.68ZM197.69,134.65c0-2.7,2.15-4.84,4.87-4.84.6,0,1.16.12,1.66.31.67.25,1.29.62,1.77,1.18.87.84,1.36,2.08,1.36,3.35,0,2.7-2.15,4.84-4.85,4.84s-4.81-2.14-4.81-4.84ZM246.55,159.77c-3.13,1.27-6.26,2.39-9.27,2.51-4.67.22-9.77-1.68-12.55-4-4.3-3.6-7.36-5.61-8.67-11.94-.54-2.7-.23-6.85.25-9.24,1.12-5.15-.12-8.44-3.74-11.44-2.96-2.45-6.7-3.1-10.82-3.1-1.54,0-2.95-.68-4-1.24-1.72-.87-3.13-3.01-1.78-5.64.43-.84,2.53-2.92,3.02-3.29,5.58-3.19,12.03-2.14,18,.25,5.54,2.26,9.71,6.42,15.72,12.28,6.16,7.1,7.26,9.09,10.76,14.39,2.76,4.19,5.29,8.47,7.01,13.37,1.04,3.04-.31,5.55-3.94,7.1Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
1
apps/desktop/src/assets/providers/gemini-color.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="#3186FF"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-0-_R_0_)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-1-_R_0_)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-2-_R_0_)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-0-_R_0_" x1="7" x2="11" y1="15.5" y2="12"><stop stop-color="#08B962"></stop><stop offset="1" stop-color="#08B962" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-1-_R_0_" x1="8" x2="11.5" y1="5.5" y2="11"><stop stop-color="#F94543"></stop><stop offset="1" stop-color="#F94543" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-2-_R_0_" x1="3.5" x2="17.5" y1="13.5" y2="12"><stop stop-color="#FABC12"></stop><stop offset=".46" stop-color="#FABC12" stop-opacity="0"></stop></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
1
apps/desktop/src/assets/providers/moonshot.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>MoonshotAI</title><path d="M1.052 16.916l9.539 2.552a21.007 21.007 0 00.06 2.033l5.956 1.593a11.997 11.997 0 01-5.586.865l-.18-.016-.044-.004-.084-.009-.094-.01a11.605 11.605 0 01-.157-.02l-.107-.014-.11-.016a11.962 11.962 0 01-.32-.051l-.042-.008-.075-.013-.107-.02-.07-.015-.093-.019-.075-.016-.095-.02-.097-.023-.094-.022-.068-.017-.088-.022-.09-.024-.095-.025-.082-.023-.109-.03-.062-.02-.084-.025-.093-.028-.105-.034-.058-.019-.08-.026-.09-.031-.066-.024a6.293 6.293 0 01-.044-.015l-.068-.025-.101-.037-.057-.022-.08-.03-.087-.035-.088-.035-.079-.032-.095-.04-.063-.028-.063-.027a5.655 5.655 0 01-.041-.018l-.066-.03-.103-.047-.052-.024-.096-.046-.062-.03-.084-.04-.086-.044-.093-.047-.052-.027-.103-.055-.057-.03-.058-.032a6.49 6.49 0 01-.046-.026l-.094-.053-.06-.034-.051-.03-.072-.041-.082-.05-.093-.056-.052-.032-.084-.053-.061-.039-.079-.05-.07-.047-.053-.035a7.785 7.785 0 01-.054-.036l-.044-.03-.044-.03a6.066 6.066 0 01-.04-.028l-.057-.04-.076-.054-.069-.05-.074-.054-.056-.042-.076-.057-.076-.059-.086-.067-.045-.035-.064-.052-.074-.06-.089-.073-.046-.039-.046-.039a7.516 7.516 0 01-.043-.037l-.045-.04-.061-.053-.07-.062-.068-.06-.062-.058-.067-.062-.053-.05-.088-.084a13.28 13.28 0 01-.099-.097l-.029-.028-.041-.042-.069-.07-.05-.051-.05-.053a6.457 6.457 0 01-.168-.179l-.08-.088-.062-.07-.071-.08-.042-.049-.053-.062-.058-.068-.046-.056a7.175 7.175 0 01-.027-.033l-.045-.055-.066-.082-.041-.052-.05-.064-.02-.025a11.99 11.99 0 01-1.44-2.402zm-1.02-5.794l11.353 3.037a20.468 20.468 0 00-.469 2.011l10.817 2.894a12.076 12.076 0 01-1.845 2.005L.657 15.923l-.016-.046-.035-.104a11.965 11.965 0 01-.05-.153l-.007-.023a11.896 11.896 0 01-.207-.741l-.03-.126-.018-.08-.021-.097-.018-.081-.018-.09-.017-.084-.018-.094c-.026-.141-.05-.283-.071-.426l-.017-.118-.011-.083-.013-.102a12.01 12.01 0 01-.019-.161l-.005-.047a12.12 12.12 0 01-.034-2.145zm1.593-5.15l11.948 3.196c-.368.605-.705 1.231-1.01 1.875l11.295 3.022c-.142.82-.368 1.612-.668 2.365l-11.55-3.09L.124 10.26l.015-.1.008-.049.01-.067.015-.087.018-.098c.026-.148.056-.295.088-.442l.028-.124.02-.085.024-.097c.022-.09.045-.18.07-.268l.028-.102.023-.083.03-.1.025-.082.03-.096.026-.082.031-.095a11.896 11.896 0 011.01-2.232zm4.442-4.4L17.352 4.59a20.77 20.77 0 00-1.688 1.721l7.823 2.093c.267.852.442 1.744.513 2.665L2.106 5.213l.045-.065.027-.04.04-.055.046-.065.055-.076.054-.072.064-.086.05-.065.057-.073.055-.07.06-.074.055-.069.065-.077.054-.066.066-.077.053-.06.072-.082.053-.06.067-.074.054-.058.073-.078.058-.06.063-.067.168-.17.1-.098.059-.056.076-.071a12.084 12.084 0 012.272-1.677zM12.017 0h.097l.082.001.069.001.054.002.068.002.046.001.076.003.047.002.06.003.054.002.087.005.105.007.144.011.088.007.044.004.077.008.082.008.047.005.102.012.05.006.108.014.081.01.042.006.065.01.207.032.07.012.065.011.14.026.092.018.11.022.046.01.075.016.041.01L14.7.3l.042.01.065.015.049.012.071.017.096.024.112.03.113.03.113.032.05.015.07.02.078.024.073.023.05.016.05.016.076.025.099.033.102.036.048.017.064.023.093.034.11.041.116.045.1.04.047.02.06.024.041.018.063.026.04.018.057.025.11.048.1.046.074.035.075.036.06.028.092.046.091.045.102.052.053.028.049.026.046.024.06.033.041.022.052.029.088.05.106.06.087.051.057.034.053.032.096.059.088.055.098.062.036.024.064.041.084.056.04.027.062.042.062.043.023.017c.054.037.108.075.161.114l.083.06.065.048.056.043.086.065.082.064.04.03.05.041.086.069.079.065.085.071c.712.6 1.353 1.283 1.909 2.031L7.222.994l.062-.027.065-.028.081-.034.086-.035c.113-.045.227-.09.341-.131l.096-.035.093-.033.084-.03.096-.031c.087-.03.176-.058.264-.085l.091-.027.086-.025.102-.03.085-.023.1-.026L9.04.37l.09-.023.091-.022.095-.022.09-.02.098-.021.091-.02.095-.018.092-.018.1-.018.091-.016.098-.017.092-.014.097-.015.092-.013.102-.013.091-.012.105-.012.09-.01.105-.01c.093-.01.186-.018.28-.024l.106-.008.09-.005.11-.006.093-.004.1-.004.097-.002.099-.002.197-.002z"></path></svg>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
1
apps/desktop/src/assets/providers/ollama.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" viewBox="0 0 512 512"><circle cx="256" cy="256" r="256" style="fill:#fff"/><defs><circle id="ollama_svg__a" cx="256" cy="256" r="256"/></defs><clipPath id="ollama_svg__b"><use xlink:href="#ollama_svg__a" style="overflow:visible"/></clipPath><g style="clip-path:url(#ollama_svg__b)"><path d="M157.3 35.9c-4.3.7-9.5 3-13.1 5.7-11 8.4-19.6 26.2-23.2 48.4-1.4 8.4-2.3 20-2.3 28.9 0 10.5 1.2 23.9 3 33.1.4 2.1.6 3.9.4 4-.1.1-1.8 1.5-3.6 2.9-6.2 5-13.4 12.6-18.3 19.6-9.4 13.4-15.5 28.6-18.1 45-1 6.5-1.3 19.6-.5 26.1 1.8 15 6.3 27.7 14 39.3l2.5 3.7-.7 1.2c-5.2 8.7-9.6 21.3-11.6 33.3-1.6 9.6-1.8 12.1-1.8 24.9 0 12.9.2 15.5 1.7 24.4 1.8 10.7 5.5 22 9.7 29.5 1.4 2.5 4.7 7.6 5.1 7.8.1.1-.3 1.3-.9 2.7-4.8 10.5-8.9 24.4-10.6 36.1-1.2 8-1.4 10.6-1.4 19.1 0 10.8.6 16 2.9 24.6l.3 1.3h28.4l-.9-1.8c-5.7-10.6-6.3-30.3-1.3-50 2.3-9.1 4.8-15.8 9.6-24.9l2.9-5.6v-3.4c0-3.2-.1-3.5-1.1-5.6-.8-1.6-1.9-3-3.7-4.8-3.2-3.1-5.5-6.4-7.4-10.5-8.2-17.7-9.8-44-4-66.5 2.4-9.4 6.3-17.7 10.5-22.2 2.8-3.1 4.3-6.6 4.3-10.2 0-3.7-1.3-6.8-4.3-10.1-8.6-9.2-13.8-20.3-15.7-33.3-2.7-18.5 2.2-38.6 13.3-54.6 10.8-15.7 26.1-25.7 43.1-28.4 3.8-.6 10.9-.5 14.9.2 4.3.8 7.1.5 9.9-.8 3.5-1.6 5.2-3.6 7.2-8.3 1.8-4.1 3.2-6.4 6.9-11.1 4.5-5.6 8.9-9.4 15.8-14 8-5.2 17-9 26-10.8 3.3-.7 4.8-.8 10.9-.8s7.7.1 10.9.8c13.2 2.7 26.4 9.5 36.9 19.2 2.3 2.1 7.7 8.8 9.4 11.6.7 1.1 1.8 3.4 2.6 5.1 2 4.6 3.7 6.7 7.2 8.3 2.7 1.3 5.5 1.6 9.7.9 6.6-1.1 11.7-1 18.1.3 22 4.4 41.2 22.6 49.7 46.9 7.4 21.3 5.3 43.7-5.7 60.7-1.9 2.9-3.7 5.2-6.4 8.1-5.8 6.2-5.8 13.9 0 20.3 9.5 10.4 15.4 35.9 13.6 58.5-1.2 14.9-5 28.2-10.3 35.7-.9 1.3-2.9 3.6-4.3 5-1.9 1.9-3 3.2-3.7 4.8-1 2.1-1.1 2.5-1.1 5.6v3.4l2.9 5.6c4.8 9.2 7.3 15.9 9.6 24.9 4.9 19.4 4.4 38.7-1.1 49.7-.5.9-.9 1.8-.9 1.9s6.3.2 14.1.2h14.1l.4-1.4c.2-.8.5-1.9.7-2.6.4-1.5 1.1-5.8 1.7-9.9.6-4.2.6-19.6 0-24.2-2.1-16.9-5.7-30.2-11.5-42.9-.6-1.4-1-2.7-.9-2.7.2-.1 1.1-1.4 2.1-2.9 7.2-10.9 11.7-24.7 13.9-42.9.6-5 .6-26.5 0-31.4-1.6-12.4-3.5-20.8-6.7-29.4-1.3-3.5-4.8-11-6.3-13.5l-.7-1.2 2.5-3.7c7.7-11.6 12.2-24.3 14-39.3.8-6.5.5-19.6-.5-26.1-2.6-16.5-8.7-31.6-18.1-45-4.9-7-12-14.7-18.3-19.6-1.8-1.5-3.5-2.8-3.6-2.9-.2-.1 0-2 .4-4 4-20.9 3.9-47-.3-67.4-3.6-17.8-10.3-31.9-18.8-40.1-6.8-6.5-13.8-9.3-22.2-8.8-19.2 1.1-34.6 23.2-40.7 58-1 5.6-1.9 12.2-1.9 14 0 .7-.1 1.3-.3 1.3s-1.5-.7-2.9-1.5C288.5 98.8 272 94.1 256 94.1s-32.5 4.7-47.3 13.4c-1.4.8-2.7 1.5-2.9 1.5s-.3-.6-.3-1.3c0-1.9-.9-8.6-1.9-14-5.5-31.2-18.2-51.9-35.1-57.1-2.2-.6-8.8-1.1-11.2-.7m5.6 27c4.8 3.8 10.1 14.6 13.1 26.7.6 2.2 1.2 4.7 1.3 5.6s.5 2.9.8 4.5c1.3 7 1.9 14.6 2 23.9v9.1l-2.3 3.4-2.3 3.4h-5.3c-6.2 0-12.4.8-18.4 2.4-2.1.5-4.2 1.1-4.6 1.2-.6.1-.7-.1-1.1-2.8-2-14.8-1.9-31.1.3-44.7 2.4-15.2 8-28.9 13.4-32.9 1.4-1 1.6-1 3.1.2m189.2-.2c3.3 2.4 6.9 8.9 9.6 17.1 5.4 16.5 6.9 39 4.1 60.5-.4 2.7-.5 2.9-1.1 2.8-.4-.1-2.5-.6-4.6-1.2-5.9-1.6-12.1-2.4-18.4-2.4h-5.3l-2.3-3.4-2.3-3.4v-9.1c.1-12.9 1.3-22.9 4.1-34.1 3-12 8.4-22.8 13.1-26.6 1.6-1.2 1.8-1.2 3.1-.2"/><path d="M250.9 229.6c-7.2.7-9.2 1-12.6 1.7-5.6 1.2-13.1 3.7-18.3 6.3-18.1 8.9-30.6 23.6-34.4 40.7-.8 3.4-.9 4.5-.9 10.2 0 5.6.1 6.9.8 10.1 5.1 22.3 25.6 38.8 52.3 41.8 5.8.6 30.7.6 36.5 0 21.4-2.4 39.7-14 48-30.3 2.2-4.3 3.3-7.2 4.2-11.6.7-3.2.8-4.4.8-10.1s-.1-6.8-.9-10.2c-5.5-24.8-29.6-44.4-59.2-48.1-3.7-.3-13.8-.7-16.3-.5m12.4 18.1c9.9 1.1 19.8 4.6 27.7 9.9 4.3 2.9 10.3 8.8 12.9 12.7 3.2 4.8 5 9.8 5.8 15.8.4 2.8.2 4.8-.8 9.3-1.6 6.6-6.4 13.6-12.9 18.4-3.1 2.2-9.4 5.4-13.3 6.7-7.4 2.4-12.2 2.8-29.4 2.7-11.2-.1-13.2-.2-16.4-.8-11-2.1-19.7-6.4-26-13.1-5.1-5.4-7.4-10.3-8.7-18.2-.6-3.7.5-9.8 2.7-14.9 2.6-6.3 9.4-14.1 16.1-18.5 7.8-5.2 18-8.9 27.4-9.9 3.6-.5 11.2-.5 14.9-.1"/><path d="M243.3 271.9c-2.5 1.4-4.3 4.8-3.7 7.4.6 2.8 3 5.5 6.8 7.8 2 1.2 2.2 1.4 2.3 2.6.1.7-.2 2.8-.6 4.7-.4 1.8-.7 3.7-.7 4.3 0 1.4 1.4 3.7 2.8 4.9 1.2 1 1.5 1 4.9 1.1 3.2.1 3.8 0 5.1-.6 3.3-1.6 4.1-4.5 2.9-10.1-1-4.7-.8-5.4 1.7-6.8 2.6-1.5 5.4-4.2 6.2-6 1.6-3.5.1-7.4-3.4-9.3-.9-.4-1.9-.6-3.5-.6-2.4 0-4 .6-6.8 2.4l-1.6 1-1-.6c-4.2-2.5-5-2.8-7.5-2.8-2 0-3 .1-3.9.6m-80.5-38.5c-5.9 1.9-10.3 6.2-12.5 12.3-1.1 2.9-1.6 7.5-1.2 10 1.1 5.9 6 11.3 11.5 12.8 7 1.8 12.2.6 16.8-3.9 2.7-2.6 4.1-4.9 5.6-8.6 1.1-2.6 1.1-3.1 1.1-6.8v-4l-1.4-2.9c-2.2-4.5-6.2-7.9-10.9-9.1-2.5-.6-6.7-.6-9 .2m177.2-.1c-4.5 1.2-8.6 4.6-10.7 9.1l-1.4 2.9v4c0 3.7.1 4.2 1.1 6.8 1.5 3.7 2.9 6 5.6 8.6 4.6 4.6 9.8 5.8 16.8 3.9 4-1.1 8-4.4 10-8.4 1.7-3.4 2.1-5.8 1.5-9.6-1.2-8.7-6.3-15.1-13.9-17.3-2.3-.7-6.6-.7-9 0"/></g></svg>
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
1
apps/desktop/src/assets/providers/openai.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
15
apps/desktop/src/assets/providers/qwen.svg
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="27.55 17.52 147.28 145.51">
|
||||
<path d="M174.82 108.75L155.38 75L165.64 57.75C166.46 56.31 166.46 54.53 165.64 53.09L155.38 35.84C154.86 34.91 153.87 34.33 152.78 34.33H114.88L106.14 19.03C105.62 18.1 104.63 17.52 103.54 17.52H83.3C82.21 17.52 81.22 18.1 80.7 19.03L61.26 52.77H41.02C39.93 52.77 38.94 53.35 38.42 54.28L28.16 71.53C27.34 72.97 27.34 74.75 28.16 76.19L45.52 107.5L36.78 122.8C35.96 124.24 35.96 126.02 36.78 127.46L47.04 144.71C47.56 145.64 48.55 146.22 49.64 146.22H87.54L96.28 161.52C96.8 162.45 97.79 163.03 98.88 163.03H119.12C120.21 163.03 121.2 162.45 121.72 161.52L141.16 127.78H158.52C159.61 127.78 160.6 127.2 161.12 126.27L171.38 109.02C172.2 107.58 172.2 105.8 171.38 104.36L174.82 108.75Z" fill="url(#paint0_radial)"/>
|
||||
<path d="M119.12 163.03H98.88L87.54 144.71H49.64L61.26 126.39H80.7L38.42 55.29H61.26L83.3 19.03L93.56 37.35L83.3 55.29H161.58L151.32 72.54L170.76 106.28H151.32L141.16 88.34L101.18 163.03H119.12Z" fill="white"/>
|
||||
<path d="M127.86 79.83H76.14L101.18 122.11L127.86 79.83Z" fill="url(#paint1_radial)"/>
|
||||
<defs>
|
||||
<radialGradient id="paint0_radial" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(100 100) rotate(90) scale(100)">
|
||||
<stop stop-color="#665CEE"/>
|
||||
<stop offset="1" stop-color="#332E91"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="paint1_radial" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(100 100) rotate(90) scale(100)">
|
||||
<stop stop-color="#665CEE"/>
|
||||
<stop offset="1" stop-color="#332E91"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
1
apps/desktop/src/assets/providers/zhipu-color.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Zhipu</title><path d="M11.991 23.503a.24.24 0 00-.244.248.24.24 0 00.244.249.24.24 0 00.245-.249.24.24 0 00-.22-.247l-.025-.001zM9.671 5.365a1.697 1.697 0 011.099 2.132l-.071.172-.016.04-.018.054c-.07.16-.104.32-.104.498-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368 0 .124.018.23.053.337.209.373.54.658.96.8.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711.854.284 1.378 1.226 1.099 2.167a1.661 1.661 0 01-2.077 1.102 1.711 1.711 0 01-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 00-1.954.746 1.66 1.66 0 01-1.065.764 1.677 1.677 0 01-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.51 1.51 0 01.296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.307 1.307 0 00-.227-.693 2.515 2.515 0 01-.366-1.403 2.39 2.39 0 01.366-1.208c.14-.195.21-.444.227-.693.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.43 1.43 0 01-.299-.07l-.05-.019a1.7 1.7 0 01-1.047-2.114 1.68 1.68 0 012.094-1.101zm-5.575 10.11c.26-.264.639-.367.994-.27.355.096.633.379.728.74.095.362-.007.748-.267 1.013-.402.41-1.053.41-1.455 0a1.062 1.062 0 010-1.482zm14.845-.294c.359-.09.738.024.992.297.254.274.344.665.237 1.025-.107.36-.396.634-.756.718-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 01.757-1.26zm-.064-4.39c.314.32.49.753.49 1.206 0 .452-.176.886-.49 1.206-.315.32-.74.5-1.185.5-.444 0-.87-.18-1.184-.5a1.727 1.727 0 010-2.412 1.654 1.654 0 012.369 0zm-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 01-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 011.33-1.038c.593-.08 1.183.169 1.547.652zm11.545-4.221c.368 0 .708.2.892.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.892.524c-.568 0-1.03-.47-1.03-1.048 0-.579.462-1.048 1.03-1.048zm-14.358 0c.368 0 .707.2.891.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.891.524c-.569 0-1.03-.47-1.03-1.048 0-.579.461-1.048 1.03-1.048zm10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705-1.674-.763-1.674-1.705c0-.942.75-1.706 1.674-1.706zm-2.626-.684c.362-.082.653-.356.761-.718a1.062 1.062 0 00-.238-1.028 1.017 1.017 0 00-.996-.294c-.547.14-.881.7-.752 1.257.13.558.675.907 1.225.783zm0 16.876c.359-.087.644-.36.75-.72a1.062 1.062 0 00-.237-1.019 1.018 1.018 0 00-.985-.301 1.037 1.037 0 00-.762.717c-.108.361-.017.754.239 1.028.245.263.606.377.953.305l.043-.01zM17.19 3.5a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64a.631.631 0 00-.628.64c0 .355.28.64.628.64zm-10.38 0a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64a.631.631 0 00-.628.64c0 .355.279.64.628.64zm-5.182 7.852a.631.631 0 00-.628.64c0 .354.28.639.628.639a.63.63 0 00.627-.606l.001-.034a.62.62 0 00-.628-.64zm5.182 9.13a.631.631 0 00-.628.64c0 .355.279.64.628.64a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm10.38.018a.631.631 0 00-.628.64c0 .355.28.64.628.64a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64zm5.182-9.148a.631.631 0 00-.628.64c0 .354.279.639.628.639a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 00.244-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249c0 .142.122.249.244.249zM11.991.497a.24.24 0 00.245-.248A.24.24 0 0011.99 0a.24.24 0 00-.244.249c0 .133.108.236.223.247l.021.001zM2.011 6.36a.24.24 0 00.245-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249.24.24 0 00.244.249zm0 11.263a.24.24 0 00-.243.248.24.24 0 00.244.249.24.24 0 00.244-.249.252.252 0 00-.244-.248zm19.995-.018a.24.24 0 00-.245.248.24.24 0 00.245.25.24.24 0 00.244-.25.252.252 0 00-.244-.248z" fill="#3859FF" fill-rule="nonzero"></path></svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
1
apps/desktop/src/assets/vscode.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><mask id="a" width="128" height="128" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#fff" fill-rule="evenodd" d="M90.767 127.126a7.968 7.968 0 0 0 6.35-.244l26.353-12.681a8 8 0 0 0 4.53-7.209V21.009a8 8 0 0 0-4.53-7.21L97.117 1.12a7.97 7.97 0 0 0-9.093 1.548l-50.45 46.026L15.6 32.013a5.328 5.328 0 0 0-6.807.302l-7.048 6.411a5.335 5.335 0 0 0-.006 7.888L20.796 64 1.74 81.387a5.336 5.336 0 0 0 .006 7.887l7.048 6.411a5.327 5.327 0 0 0 6.807.303l21.974-16.68 50.45 46.025a7.96 7.96 0 0 0 2.743 1.793Zm5.252-92.183L57.74 64l38.28 29.058V34.943Z" clip-rule="evenodd"/></mask><g mask="url(#a)"><path fill="#0065A9" d="M123.471 13.82 97.097 1.12A7.973 7.973 0 0 0 88 2.668L1.662 81.387a5.333 5.333 0 0 0 .006 7.887l7.052 6.411a5.333 5.333 0 0 0 6.811.303l103.971-78.875c3.488-2.646 8.498-.158 8.498 4.22v-.306a8.001 8.001 0 0 0-4.529-7.208Z"/><g filter="url(#b)"><path fill="#007ACC" d="m123.471 114.181-26.374 12.698A7.973 7.973 0 0 1 88 125.333L1.662 46.613a5.333 5.333 0 0 1 .006-7.887l7.052-6.411a5.333 5.333 0 0 1 6.811-.303l103.971 78.874c3.488 2.647 8.498.159 8.498-4.219v.306a8.001 8.001 0 0 1-4.529 7.208Z"/></g><g filter="url(#c)"><path fill="#1F9CF0" d="M97.098 126.882A7.977 7.977 0 0 1 88 125.333c2.952 2.952 8 .861 8-3.314V5.98c0-4.175-5.048-6.266-8-3.313a7.977 7.977 0 0 1 9.098-1.549L123.467 13.8A8 8 0 0 1 128 21.01v85.982a8 8 0 0 1-4.533 7.21l-26.369 12.681Z"/></g><path fill="url(#d)" fill-rule="evenodd" d="M90.69 127.126a7.968 7.968 0 0 0 6.349-.244l26.353-12.681a8 8 0 0 0 4.53-7.21V21.009a8 8 0 0 0-4.53-7.21L97.039 1.12a7.97 7.97 0 0 0-9.093 1.548l-50.45 46.026-21.974-16.68a5.328 5.328 0 0 0-6.807.302l-7.048 6.411a5.336 5.336 0 0 0-.006 7.888L20.718 64 1.662 81.386a5.335 5.335 0 0 0 .006 7.888l7.048 6.411a5.328 5.328 0 0 0 6.807.303l21.975-16.681 50.45 46.026a7.959 7.959 0 0 0 2.742 1.793Zm5.252-92.184L57.662 64l38.28 29.057V34.943Z" clip-rule="evenodd" opacity="0.25" style="mix-blend-mode:overlay"/></g><defs><filter id="b" width="144.744" height="113.408" x="-8.41115" y="22.5944" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset/><feGaussianBlur stdDeviation="4.16667"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/><feBlend in2="BackgroundImageFix" mode="overlay" result="effect1_dropShadow_1_36"/><feBlend in="SourceGraphic" in2="effect1_dropShadow_1_36" result="shape"/></filter><filter id="c" width="56.6667" height="144.007" x="79.6667" y="-8.0035" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset/><feGaussianBlur stdDeviation="4.16667"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/><feBlend in2="BackgroundImageFix" mode="overlay" result="effect1_dropShadow_1_36"/><feBlend in="SourceGraphic" in2="effect1_dropShadow_1_36" result="shape"/></filter><linearGradient id="d" x1="63.9222" x2="63.9222" y1="0.329902" y2="127.67" gradientUnits="userSpaceOnUse"><stop stop-color="#fff"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
|
|
@ -1,61 +1,125 @@
|
|||
import { type FC, memo, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AlertCircleIcon } from "lucide-react";
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
CornerDownRightIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
useClaudeChatStore,
|
||||
type ClaudeStreamMessage,
|
||||
type ContentBlock,
|
||||
type QueuedGuidance,
|
||||
} from "@/stores/claude-chat-store";
|
||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MarkdownRenderer } from "./markdown-renderer";
|
||||
import { ThinkingWidget, ToolWidget } from "./tool-widgets";
|
||||
|
||||
// ─── Streaming Indicator (isolated to prevent re-render storms) ───
|
||||
|
||||
const StreamingIndicator: FC = memo(() => {
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const startRef = useRef(Date.now());
|
||||
const StreamingIndicator: FC<{ startedAt: number | null }> = memo(
|
||||
({ startedAt }) => {
|
||||
const calculateElapsed = () =>
|
||||
startedAt ? Math.max(0, Math.floor((Date.now() - startedAt) / 1000)) : 0;
|
||||
|
||||
useEffect(() => {
|
||||
startRef.current = Date.now();
|
||||
setElapsed(0);
|
||||
const timer = setInterval(() => {
|
||||
setElapsed(Math.floor((Date.now() - startRef.current) / 1000));
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
const [elapsed, setElapsed] = useState(calculateElapsed);
|
||||
|
||||
useEffect(() => {
|
||||
setElapsed(calculateElapsed());
|
||||
const timer = setInterval(() => {
|
||||
setElapsed(calculateElapsed());
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [startedAt]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 px-1 py-1.5 text-muted-foreground">
|
||||
<div className="flex gap-0.5">
|
||||
<span
|
||||
className="size-1.5 animate-bounce rounded-full bg-muted-foreground/50"
|
||||
style={{ animationDelay: "0ms" }}
|
||||
/>
|
||||
<span
|
||||
className="size-1.5 animate-bounce rounded-full bg-muted-foreground/50"
|
||||
style={{ animationDelay: "150ms" }}
|
||||
/>
|
||||
<span
|
||||
className="size-1.5 animate-bounce rounded-full bg-muted-foreground/50"
|
||||
style={{ animationDelay: "300ms" }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm">
|
||||
Thinking...
|
||||
{elapsed >= 3 && (
|
||||
<span className="ml-1 text-muted-foreground/60 text-xs">
|
||||
{elapsed}s
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const EMPTY_PENDING_GUIDANCE: QueuedGuidance[] = [];
|
||||
const THREAD_MAX_WIDTH = "max-w-[44rem]";
|
||||
|
||||
const MessageActions: FC<{
|
||||
text: string;
|
||||
align?: "left" | "right";
|
||||
}> = ({ text, align = "left" }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const canCopy = text.trim().length > 0;
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!canCopy) return;
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1200);
|
||||
};
|
||||
|
||||
if (!canCopy) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 px-1 py-1.5 text-muted-foreground">
|
||||
<div className="flex gap-0.5">
|
||||
<span
|
||||
className="size-1.5 animate-bounce rounded-full bg-muted-foreground/50"
|
||||
style={{ animationDelay: "0ms" }}
|
||||
/>
|
||||
<span
|
||||
className="size-1.5 animate-bounce rounded-full bg-muted-foreground/50"
|
||||
style={{ animationDelay: "150ms" }}
|
||||
/>
|
||||
<span
|
||||
className="size-1.5 animate-bounce rounded-full bg-muted-foreground/50"
|
||||
style={{ animationDelay: "300ms" }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm">
|
||||
Thinking...
|
||||
{elapsed >= 3 && (
|
||||
<span className="ml-1 text-muted-foreground/60 text-xs">
|
||||
{elapsed}s
|
||||
</span>
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-1 text-muted-foreground",
|
||||
align === "right" ? "justify-end" : "justify-start",
|
||||
)}
|
||||
>
|
||||
<TooltipIconButton
|
||||
tooltip={copied ? "Copied" : "Copy"}
|
||||
side="top"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon className="fade-in zoom-in-50 size-4 animate-in duration-200" />
|
||||
) : (
|
||||
<CopyIcon className="fade-in zoom-in-75 size-4 animate-in duration-150" />
|
||||
)}
|
||||
</span>
|
||||
</TooltipIconButton>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// ─── Chat Messages (main component) ───
|
||||
|
||||
export const ChatMessages: FC = () => {
|
||||
const messages = useClaudeChatStore((s) => s.messages) ?? [];
|
||||
const isStreaming = useClaudeChatStore((s) => s.isStreaming);
|
||||
const streamingStartedAt = useClaudeChatStore((s) => s.streamingStartedAt);
|
||||
const queuedGuidance =
|
||||
useClaudeChatStore(
|
||||
(s) => s.tabs.find((tab) => tab.id === s.activeTabId)?.queuedGuidance,
|
||||
) ?? EMPTY_PENDING_GUIDANCE;
|
||||
const pendingGuidance = useMemo(
|
||||
() => queuedGuidance.filter((guidance) => guidance.displayedInChat),
|
||||
[queuedGuidance],
|
||||
);
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const shouldAutoScrollRef = useRef(true);
|
||||
const userHasScrolledRef = useRef(false);
|
||||
|
|
@ -120,7 +184,7 @@ export const ChatMessages: FC = () => {
|
|||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
}, [displayMessages]);
|
||||
}, [displayMessages, pendingGuidance]);
|
||||
|
||||
// Reset auto-scroll when streaming stops
|
||||
useEffect(() => {
|
||||
|
|
@ -148,19 +212,36 @@ export const ChatMessages: FC = () => {
|
|||
<div
|
||||
ref={viewportRef}
|
||||
onScroll={handleScroll}
|
||||
className="absolute inset-0 overflow-y-auto scroll-smooth px-4 py-2"
|
||||
className="absolute inset-0 overflow-y-auto scroll-smooth px-4 pt-4"
|
||||
>
|
||||
{displayMessages.length === 0 && !isStreaming && (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground text-sm">
|
||||
Ask Claude about your LaTeX document...
|
||||
{displayMessages.length === 0 &&
|
||||
pendingGuidance.length === 0 &&
|
||||
!isStreaming && (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground text-sm">
|
||||
Ask Claude about your LaTeX document...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{displayMessages.map((msg, idx) => (
|
||||
<div key={idx} className={cn("mx-auto w-full", THREAD_MAX_WIDTH)}>
|
||||
<MessageBubble message={msg} toolResultMap={toolResultMap} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isStreaming && (
|
||||
<div className={cn("mx-auto w-full px-2", THREAD_MAX_WIDTH)}>
|
||||
<StreamingIndicator startedAt={streamingStartedAt} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{displayMessages.map((msg, idx) => (
|
||||
<MessageBubble key={idx} message={msg} toolResultMap={toolResultMap} />
|
||||
{pendingGuidance.map((guidance) => (
|
||||
<div
|
||||
key={guidance.id}
|
||||
className={cn("mx-auto w-full", THREAD_MAX_WIDTH)}
|
||||
>
|
||||
<PendingGuidanceMessage guidance={guidance} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isStreaming && <StreamingIndicator />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -198,10 +279,15 @@ const UserMessage: FC<{ message: ClaudeStreamMessage }> = ({ message }) => {
|
|||
|
||||
if (!textContent) return null;
|
||||
|
||||
// Parse leading @file:line:col or ~@file:line context reference
|
||||
const contextMatch = textContent.match(/^(~?@[^\n]+)\n([\s\S]*)$/);
|
||||
const contextLabel = contextMatch?.[1] ?? null;
|
||||
const bodyText = contextMatch ? contextMatch[2] : textContent;
|
||||
const firstLineMatch = textContent.match(/^([^\n]+)\n([\s\S]*)$/);
|
||||
const firstLine = firstLineMatch?.[1]?.trim() ?? "";
|
||||
const hasContextLabel =
|
||||
firstLine.startsWith("@") ||
|
||||
firstLine.startsWith("~@") ||
|
||||
/^Pasted image(?: \d+)?(?:, Pasted image(?: \d+)?)*$/.test(firstLine);
|
||||
const contextLabel = hasContextLabel ? firstLine : null;
|
||||
const bodyText =
|
||||
hasContextLabel && firstLineMatch ? firstLineMatch[2] : textContent;
|
||||
|
||||
// Parse error block patterns for styled rendering:
|
||||
// Lint single: "[Lint error in FILE:LINE]\n[Error: MSG]\n\nPrompt"
|
||||
|
|
@ -223,27 +309,34 @@ const UserMessage: FC<{ message: ClaudeStreamMessage }> = ({ message }) => {
|
|||
errors: { message: string; location?: string }[],
|
||||
prompt: string,
|
||||
) => (
|
||||
<div className="flex w-full flex-col items-end py-1.5">
|
||||
<div className="max-w-[85%] rounded-xl bg-muted px-3 py-2 text-foreground text-sm">
|
||||
<div className="mb-2 rounded-lg border border-red-500/20 bg-red-500/10 px-2.5 py-2">
|
||||
<div className="mb-1.5 font-medium text-red-400 text-xs">{title}</div>
|
||||
<div className="space-y-1">
|
||||
{errors.map((e, i) => (
|
||||
<div key={i} className="flex items-start gap-1.5">
|
||||
<AlertCircleIcon className="mt-0.5 size-3 shrink-0 text-red-400/70" />
|
||||
<span className="flex-1 text-foreground/80 text-xs">
|
||||
{e.message}
|
||||
</span>
|
||||
{e.location && (
|
||||
<span className="shrink-0 font-mono text-muted-foreground text-xs">
|
||||
{e.location}
|
||||
<div className="fade-in slide-in-from-bottom-1 grid w-full animate-in auto-rows-auto grid-cols-[minmax(72px,1fr)_auto] content-start gap-y-2 px-2 py-3 duration-150 [&:where(>*)]:col-start-2">
|
||||
<div className="relative col-start-2 min-w-0">
|
||||
<div className="wrap-break-word rounded-xl bg-muted px-4 py-2 text-foreground text-sm empty:hidden">
|
||||
<div className="mb-2 rounded-lg border border-red-500/20 bg-red-500/10 px-2.5 py-2">
|
||||
<div className="mb-1.5 font-medium text-red-400 text-xs">
|
||||
{title}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{errors.map((e, i) => (
|
||||
<div key={i} className="flex items-start gap-1.5">
|
||||
<AlertCircleIcon className="mt-0.5 size-3 shrink-0 text-red-400/70" />
|
||||
<span className="flex-1 text-foreground/80 text-xs">
|
||||
{e.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{e.location && (
|
||||
<span className="shrink-0 font-mono text-muted-foreground text-xs">
|
||||
{e.location}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-muted-foreground">{prompt}</span>
|
||||
</div>
|
||||
<span className="text-muted-foreground">{prompt}</span>
|
||||
</div>
|
||||
<div className="col-span-full col-start-1 row-start-2 -mr-1 flex justify-end">
|
||||
<MessageActions text={bodyText} align="right" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -287,18 +380,23 @@ const UserMessage: FC<{ message: ClaudeStreamMessage }> = ({ message }) => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col items-end py-1.5">
|
||||
<div className="max-w-[85%] rounded-xl bg-muted px-3 py-1.5 text-foreground text-sm">
|
||||
{contextLabel && (
|
||||
<span className="mb-1 inline-flex items-center rounded-md bg-background/60 px-1.5 py-0.5 font-mono text-muted-foreground text-xs">
|
||||
{contextLabel}
|
||||
</span>
|
||||
)}
|
||||
{contextLabel && bodyText && <br />}
|
||||
<MarkdownRenderer
|
||||
content={bodyText}
|
||||
className="prose prose-sm dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"
|
||||
/>
|
||||
<div className="fade-in slide-in-from-bottom-1 grid w-full animate-in auto-rows-auto grid-cols-[minmax(72px,1fr)_auto] content-start gap-y-2 px-2 py-3 duration-150 [&:where(>*)]:col-start-2">
|
||||
<div className="relative col-start-2 min-w-0">
|
||||
<div className="wrap-break-word rounded-xl bg-muted px-4 py-2 text-foreground text-sm empty:hidden">
|
||||
{contextLabel && (
|
||||
<span className="mb-1 inline-flex items-center rounded-md bg-background/60 px-1.5 py-0.5 font-mono text-muted-foreground text-xs">
|
||||
{contextLabel}
|
||||
</span>
|
||||
)}
|
||||
{contextLabel && bodyText && <br />}
|
||||
<MarkdownRenderer
|
||||
content={bodyText}
|
||||
className="prose prose-sm dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-full col-start-1 row-start-2 -mr-1 flex justify-end">
|
||||
<MessageActions text={textContent} align="right" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -306,6 +404,40 @@ const UserMessage: FC<{ message: ClaudeStreamMessage }> = ({ message }) => {
|
|||
|
||||
// ─── Assistant Message ───
|
||||
|
||||
const PendingGuidanceMessage: FC<{ guidance: QueuedGuidance }> = ({
|
||||
guidance,
|
||||
}) => {
|
||||
const contextLabel = guidance.contextOverride?.label ?? null;
|
||||
const copyText = contextLabel
|
||||
? `${contextLabel}\n${guidance.prompt}`
|
||||
: guidance.prompt;
|
||||
|
||||
return (
|
||||
<div className="fade-in slide-in-from-bottom-1 grid w-full animate-in auto-rows-auto grid-cols-[minmax(72px,1fr)_auto] content-start gap-y-2 px-2 py-3 duration-150 [&:where(>*)]:col-start-2">
|
||||
<div className="relative col-start-2 min-w-0">
|
||||
<div className="wrap-break-word rounded-xl bg-muted px-4 py-2 text-foreground text-sm empty:hidden">
|
||||
{contextLabel && (
|
||||
<span className="mb-1 inline-flex items-center rounded-md bg-background/60 px-1.5 py-0.5 font-mono text-muted-foreground text-xs">
|
||||
{contextLabel}
|
||||
</span>
|
||||
)}
|
||||
{contextLabel && guidance.prompt && <br />}
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<CornerDownRightIcon className="mt-0.5 size-3.5 shrink-0 text-muted-foreground/70" />
|
||||
<MarkdownRenderer
|
||||
content={guidance.prompt}
|
||||
className="prose prose-sm dark:prose-invert min-w-0 max-w-none flex-1 break-words [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-full col-start-1 row-start-2 -mr-1 flex justify-end">
|
||||
<MessageActions text={copyText} align="right" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AssistantMessage: FC<{
|
||||
message: ClaudeStreamMessage;
|
||||
toolResultMap: Map<string, ContentBlock>;
|
||||
|
|
@ -322,19 +454,15 @@ const AssistantMessage: FC<{
|
|||
|
||||
if (!hasRenderableContent) return null;
|
||||
|
||||
const copyText = content
|
||||
.filter((block) => block.type === "text" && block.text)
|
||||
.map((block) => block.text)
|
||||
.join("\n\n");
|
||||
|
||||
return (
|
||||
<div className="w-full py-1.5">
|
||||
<div className="px-1 text-foreground text-sm leading-relaxed">
|
||||
<div className="fade-in slide-in-from-bottom-1 relative mx-auto w-full animate-in py-3 duration-150">
|
||||
<div className="wrap-break-word px-2 text-foreground text-sm leading-relaxed">
|
||||
{content.map((block, idx) => {
|
||||
if (block.type === "thinking" && block.thinking) {
|
||||
return (
|
||||
<ThinkingWidget
|
||||
key={idx}
|
||||
thinking={block.thinking}
|
||||
signature={block.signature}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (block.type === "text" && block.text) {
|
||||
return (
|
||||
<MarkdownRenderer
|
||||
|
|
@ -348,9 +476,21 @@ const AssistantMessage: FC<{
|
|||
const result = toolResultMap.get(block.id);
|
||||
return <ToolWidget key={idx} toolUse={block} toolResult={result} />;
|
||||
}
|
||||
if (block.type === "thinking" && block.thinking) {
|
||||
return (
|
||||
<ThinkingWidget
|
||||
key={idx}
|
||||
thinking={block.thinking}
|
||||
signature={block.signature}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
<div className="-mb-7.5 ml-2 flex min-h-7.5 items-center pt-1.5">
|
||||
<MessageActions text={copyText} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -364,8 +504,8 @@ const ResultMessage: FC<{ message: ClaudeStreamMessage }> = ({ message }) => {
|
|||
if (!resultText) return null;
|
||||
|
||||
return (
|
||||
<div className="w-full py-1.5">
|
||||
<div className="px-1 text-foreground text-sm leading-relaxed">
|
||||
<div className="fade-in slide-in-from-bottom-1 relative mx-auto w-full animate-in py-3 duration-150">
|
||||
<div className="wrap-break-word px-2 text-foreground text-sm leading-relaxed">
|
||||
{isError ? (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-destructive text-sm">
|
||||
{resultText}
|
||||
|
|
@ -377,6 +517,9 @@ const ResultMessage: FC<{ message: ClaudeStreamMessage }> = ({ message }) => {
|
|||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="-mb-7.5 ml-2 flex min-h-7.5 items-center pt-1.5">
|
||||
<MessageActions text={resultText} />
|
||||
</div>
|
||||
{message.cost_usd != null && (
|
||||
<div className="mt-1 px-1 text-right text-muted-foreground text-xs">
|
||||
Cost: ${message.cost_usd.toFixed(4)}
|
||||
|
|
|
|||
|
|
@ -95,11 +95,11 @@ export function ChatTabBar() {
|
|||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center">
|
||||
<div className="flex shrink-0 items-center gap-0.5 pr-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreate}
|
||||
className="flex size-7 items-center justify-center text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label="New tab"
|
||||
>
|
||||
<PlusIcon className="size-3.5" />
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { ChatMessages } from "./chat-messages";
|
|||
import { ChatComposer } from "./chat-composer";
|
||||
import { ChatTabBar } from "./chat-tab-bar";
|
||||
|
||||
const MIN_HEIGHT = 150;
|
||||
const MIN_HEIGHT = 260;
|
||||
const DEFAULT_HEIGHT = 360;
|
||||
|
||||
export function ClaudeChatDrawer() {
|
||||
|
|
@ -43,11 +43,14 @@ export function ClaudeChatDrawer() {
|
|||
if (shouldOpen && !isOpen) {
|
||||
setIsOpen(true);
|
||||
const parent = containerRef.current?.parentElement;
|
||||
const maxHeight = parent ? parent.clientHeight * 0.5 : 400;
|
||||
setHeight(maxHeight);
|
||||
heightRef.current = maxHeight;
|
||||
const maxHeight = parent
|
||||
? Math.max(MIN_HEIGHT, parent.clientHeight * 0.5)
|
||||
: 400;
|
||||
const nextHeight = Math.max(maxHeight, MIN_HEIGHT);
|
||||
setHeight(nextHeight);
|
||||
heightRef.current = nextHeight;
|
||||
if (panelRef.current) {
|
||||
panelRef.current.style.height = `${maxHeight}px`;
|
||||
panelRef.current.style.height = `${nextHeight}px`;
|
||||
}
|
||||
}
|
||||
}, [anyStreaming, isOpen, pendingAttachments]);
|
||||
|
|
@ -66,7 +69,9 @@ export function ClaudeChatDrawer() {
|
|||
const handleMouseMove = (e: MouseEvent) => {
|
||||
hasDraggedRef.current = true;
|
||||
const parent = containerRef.current?.parentElement;
|
||||
const maxHeight = parent ? parent.clientHeight * 0.5 : 400;
|
||||
const maxHeight = parent
|
||||
? Math.max(MIN_HEIGHT, parent.clientHeight * 0.5)
|
||||
: 400;
|
||||
const delta = startY - e.clientY;
|
||||
const newHeight = Math.min(
|
||||
Math.max(startHeight + delta, MIN_HEIGHT),
|
||||
|
|
@ -108,7 +113,12 @@ export function ClaudeChatDrawer() {
|
|||
const dims = getExpandedDimensions();
|
||||
return { height: dims.height, maxWidth: dims.width, borderRadius: 0 };
|
||||
}
|
||||
return { height, maxWidth: 672, borderRadius: 24 };
|
||||
return {
|
||||
height: Math.max(height, MIN_HEIGHT),
|
||||
minHeight: MIN_HEIGHT,
|
||||
maxWidth: 672,
|
||||
borderRadius: 24,
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -196,7 +206,7 @@ export function ClaudeChatDrawer() {
|
|||
|
||||
{/* Error banner */}
|
||||
{error && (
|
||||
<div className="mx-3 mb-1 rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-1.5 text-destructive text-xs">
|
||||
<div className="mx-3 mt-2 mb-1 rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-1.5 text-destructive text-xs">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { invoke } from "@tauri-apps/api/core";
|
|||
import "katex/dist/katex.min.css";
|
||||
|
||||
import { useDocumentStore } from "@/stores/document-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ─── Shell Detection ───
|
||||
|
||||
|
|
@ -83,11 +84,56 @@ export const MarkdownRenderer: FC<MarkdownRendererProps> = ({
|
|||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
className={className ?? "prose prose-sm dark:prose-invert max-w-none"}
|
||||
className={cn(
|
||||
"min-w-0 max-w-full break-words [overflow-wrap:anywhere] [&_*]:max-w-full",
|
||||
className ?? "prose prose-sm dark:prose-invert max-w-none",
|
||||
)}
|
||||
components={{
|
||||
pre({ children }) {
|
||||
return <>{children}</>;
|
||||
},
|
||||
table({ children, node, ...props }) {
|
||||
return (
|
||||
<div className="my-3 w-full overflow-x-auto rounded-lg border border-border">
|
||||
<table
|
||||
className="m-0 w-full border-collapse text-left text-sm"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
thead({ children, node, ...props }) {
|
||||
return (
|
||||
<thead className="bg-muted/70" {...props}>
|
||||
{children}
|
||||
</thead>
|
||||
);
|
||||
},
|
||||
th({ children, node, ...props }) {
|
||||
return (
|
||||
<th
|
||||
className="border-border border-r border-b px-3 py-2 font-medium text-foreground last:border-r-0"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
},
|
||||
td({ children, node, ...props }) {
|
||||
return (
|
||||
<td
|
||||
className="border-border border-t border-r px-3 py-2 align-top text-foreground last:border-r-0"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
},
|
||||
hr({ node, ...props }) {
|
||||
return <hr className="my-5 border-border border-t" {...props} />;
|
||||
},
|
||||
code({ className: codeClassName, children, node, ...props }) {
|
||||
const match = /language-(\w+)/.exec(codeClassName || "");
|
||||
const language = match?.[1];
|
||||
|
|
@ -98,7 +144,13 @@ export const MarkdownRenderer: FC<MarkdownRendererProps> = ({
|
|||
|
||||
if (!match && !isBlock) {
|
||||
return (
|
||||
<code className={codeClassName} {...props}>
|
||||
<code
|
||||
className={cn(
|
||||
"break-words [overflow-wrap:anywhere]",
|
||||
codeClassName,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { type FC } from "react";
|
||||
import { type FC, useEffect, useRef, useState } from "react";
|
||||
import { Check, X } from "lucide-react";
|
||||
import { type ProposedChange } from "@/stores/proposed-changes-store";
|
||||
|
||||
|
|
@ -17,42 +17,79 @@ export const ProposedChangesPanel: FC<ProposedChangesPanelProps> = ({
|
|||
onKeep,
|
||||
onUndo,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const oldLines = change.oldContent.split("\n").length;
|
||||
const newLines = change.newContent.split("\n").length;
|
||||
const added = Math.max(0, newLines - oldLines);
|
||||
const removed = Math.max(0, oldLines - newLines);
|
||||
const compactTitle = containerWidth > 0 && containerWidth < 680;
|
||||
const hideToolName = containerWidth > 0 && containerWidth < 920;
|
||||
|
||||
useEffect(() => {
|
||||
const element = containerRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const updateWidth = () => setContainerWidth(element.clientWidth);
|
||||
updateWidth();
|
||||
|
||||
const observer = new ResizeObserver(updateWidth);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between border-border border-t bg-muted/50 px-3 py-1.5">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium text-foreground">Proposed Changes</span>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="grid h-9 min-w-0 shrink-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 overflow-hidden border-border border-t bg-muted/50 px-3"
|
||||
>
|
||||
<div className="flex shrink-0 items-center gap-2 text-sm">
|
||||
<span className="whitespace-nowrap font-medium text-foreground">
|
||||
{compactTitle ? "Changes" : "Proposed Changes"}
|
||||
</span>
|
||||
{totalChanges > 1 && (
|
||||
<span className="rounded bg-violet-500/15 px-1.5 py-0.5 font-medium text-violet-600 text-xs dark:text-violet-400">
|
||||
<span className="shrink-0 whitespace-nowrap rounded bg-violet-500/15 px-1.5 py-0.5 font-medium text-violet-600 text-xs dark:text-violet-400">
|
||||
{changeIndex + 1}/{totalChanges} files
|
||||
</span>
|
||||
)}
|
||||
<span className="text-muted-foreground">{change.filePath}</span>
|
||||
<span className="text-muted-foreground">{change.toolName}</span>
|
||||
{added > 0 && <span className="text-green-400">+{added}</span>}
|
||||
{removed > 0 && <span className="text-red-400">-{removed}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex min-w-0 items-center gap-2 overflow-hidden text-sm">
|
||||
<span className="min-w-0 truncate text-muted-foreground">
|
||||
{change.filePath}
|
||||
</span>
|
||||
{!hideToolName && (
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
{change.toolName}
|
||||
</span>
|
||||
)}
|
||||
{added > 0 && <span className="shrink-0 text-green-400">+{added}</span>}
|
||||
{removed > 0 && (
|
||||
<span className="shrink-0 text-red-400">-{removed}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onKeep}
|
||||
className="flex items-center gap-1 rounded-md bg-green-600/20 px-2.5 py-1 text-green-400 text-xs transition-colors hover:bg-green-600/30"
|
||||
className="flex items-center justify-center gap-1 rounded-md bg-green-600/20 px-2.5 py-1 text-green-400 text-xs transition-colors hover:bg-green-600/30"
|
||||
title="Keep All"
|
||||
aria-label="Keep All"
|
||||
>
|
||||
<Check className="size-3.5" />
|
||||
Keep All
|
||||
<span className="whitespace-nowrap">Keep All</span>
|
||||
<kbd className="ml-1 rounded bg-green-600/20 px-1 py-0.5 font-mono text-[10px]">
|
||||
⌘Y
|
||||
</kbd>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUndo}
|
||||
className="flex items-center gap-1 rounded-md bg-red-600/20 px-2.5 py-1 text-red-400 text-xs transition-colors hover:bg-red-600/30"
|
||||
className="flex items-center justify-center gap-1 rounded-md bg-red-600/20 px-2.5 py-1 text-red-400 text-xs transition-colors hover:bg-red-600/30"
|
||||
title="Undo All"
|
||||
aria-label="Undo All"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
Undo All
|
||||
<span className="whitespace-nowrap">Undo All</span>
|
||||
<kbd className="ml-1 rounded bg-red-600/20 px-1 py-0.5 font-mono text-[10px]">
|
||||
⌘N
|
||||
</kbd>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
import { useState, useCallback } from "react";
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { HistoryIcon, PlusIcon, CheckIcon, Loader2Icon } from "lucide-react";
|
||||
import {
|
||||
HistoryIcon,
|
||||
PlusIcon,
|
||||
CheckIcon,
|
||||
Loader2Icon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
|
|
@ -9,6 +15,15 @@ import {
|
|||
DropdownMenuSeparator,
|
||||
DropdownMenuLabel,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useClaudeChatStore } from "@/stores/claude-chat-store";
|
||||
import { useDocumentStore } from "@/stores/document-store";
|
||||
import { createLogger } from "@/lib/debug/logger";
|
||||
|
|
@ -37,11 +52,31 @@ function formatRelativeTime(unixSeconds: number): string {
|
|||
export function SessionSelector() {
|
||||
const [sessions, setSessions] = useState<ClaudeSessionInfo[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ClaudeSessionInfo | null>(
|
||||
null,
|
||||
);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const sessionId = useClaudeChatStore((s) => s.sessionId);
|
||||
const isStreaming = useClaudeChatStore((s) => s.isStreaming);
|
||||
const tabs = useClaudeChatStore((s) => s.tabs);
|
||||
const newSession = useClaudeChatStore((s) => s.newSession);
|
||||
const resumeSession = useClaudeChatStore((s) => s.resumeSession);
|
||||
const setSessionTitle = useClaudeChatStore((s) => s._setSessionTitle);
|
||||
const projectRoot = useDocumentStore((s) => s.projectRoot);
|
||||
const streamingSessionIds = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
tabs
|
||||
.filter(
|
||||
(tab) =>
|
||||
tab.projectPath === projectRoot &&
|
||||
tab.isStreaming &&
|
||||
tab.sessionId,
|
||||
)
|
||||
.map((tab) => tab.sessionId as string),
|
||||
),
|
||||
[projectRoot, tabs],
|
||||
);
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
if (!projectRoot) return;
|
||||
|
|
@ -50,16 +85,20 @@ export function SessionSelector() {
|
|||
try {
|
||||
const result = await invoke<ClaudeSessionInfo[]>("list_claude_sessions", {
|
||||
projectPath: projectRoot,
|
||||
generateTitles: false,
|
||||
});
|
||||
log.debug("loaded sessions", { count: result.length });
|
||||
setSessions(result);
|
||||
for (const session of result) {
|
||||
setSessionTitle(session.session_id, session.title);
|
||||
}
|
||||
} catch (err) {
|
||||
log.error("Failed to load sessions", { error: String(err) });
|
||||
setSessions([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [projectRoot]);
|
||||
}, [projectRoot, setSessionTitle]);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
|
|
@ -71,77 +110,204 @@ export function SessionSelector() {
|
|||
);
|
||||
|
||||
const handleSelectSession = useCallback(
|
||||
(sid: string) => {
|
||||
if (isStreaming) return;
|
||||
if (sid === sessionId) return;
|
||||
log.debug(`selecting session: ${sid}`);
|
||||
resumeSession(sid);
|
||||
(session: ClaudeSessionInfo) => {
|
||||
if (deletingId === session.session_id) return;
|
||||
if (session.session_id === sessionId) return;
|
||||
log.debug(`selecting session: ${session.session_id}`);
|
||||
resumeSession(session.session_id, session.title);
|
||||
},
|
||||
[isStreaming, sessionId, resumeSession],
|
||||
[deletingId, sessionId, resumeSession],
|
||||
);
|
||||
|
||||
const handleDeleteSession = useCallback(
|
||||
async (sid: string) => {
|
||||
if (deletingId || !projectRoot || streamingSessionIds.has(sid)) return;
|
||||
|
||||
setDeleteError(null);
|
||||
setDeletingId(sid);
|
||||
try {
|
||||
await invoke("delete_claude_session", {
|
||||
projectPath: projectRoot,
|
||||
sessionId: sid,
|
||||
});
|
||||
setSessions((prev) => prev.filter((item) => item.session_id !== sid));
|
||||
if (sid === sessionId) {
|
||||
newSession();
|
||||
}
|
||||
setDeleteTarget((current) =>
|
||||
current?.session_id === sid ? null : current,
|
||||
);
|
||||
} catch (err) {
|
||||
log.error("Failed to delete session", {
|
||||
sessionId: sid,
|
||||
error: String(err),
|
||||
});
|
||||
setDeleteError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setDeletingId((current) => (current === sid ? null : current));
|
||||
}
|
||||
},
|
||||
[deletingId, newSession, projectRoot, sessionId, streamingSessionIds],
|
||||
);
|
||||
|
||||
const handleNewChat = useCallback(() => {
|
||||
if (isStreaming) return;
|
||||
newSession();
|
||||
}, [isStreaming, newSession]);
|
||||
}, [newSession]);
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpenChange={handleOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label="Session history"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
<>
|
||||
<DropdownMenu onOpenChange={handleOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label="Session history"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<HistoryIcon className="size-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
side="bottom"
|
||||
className="max-h-80 w-72 overflow-y-auto"
|
||||
>
|
||||
<HistoryIcon className="size-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuLabel>Sessions</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
side="bottom"
|
||||
className="max-h-80 w-72 overflow-y-auto"
|
||||
<DropdownMenuItem onSelect={handleNewChat}>
|
||||
<PlusIcon className="size-4" />
|
||||
<span>New Chat</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2Icon className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="px-2 py-4 text-center text-muted-foreground text-sm">
|
||||
No previous sessions
|
||||
</div>
|
||||
) : (
|
||||
sessions.map((session) => {
|
||||
const sessionIsStreaming = streamingSessionIds.has(
|
||||
session.session_id,
|
||||
);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={session.session_id}
|
||||
onSelect={() => handleSelectSession(session)}
|
||||
disabled={deletingId === session.session_id}
|
||||
className="group flex items-start gap-2"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-sm">{session.title}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{formatRelativeTime(session.last_modified)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{sessionIsStreaming ? (
|
||||
<Loader2Icon className="size-4 animate-spin text-primary" />
|
||||
) : (
|
||||
session.session_id === sessionId && (
|
||||
<CheckIcon className="size-4 text-primary" />
|
||||
)
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-6 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40"
|
||||
aria-label={`Delete ${session.title}`}
|
||||
title={
|
||||
sessionIsStreaming
|
||||
? "Cannot delete a running session"
|
||||
: "Delete session"
|
||||
}
|
||||
disabled={
|
||||
sessionIsStreaming || deletingId === session.session_id
|
||||
}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setDeleteError(null);
|
||||
setDeleteTarget(session);
|
||||
}}
|
||||
>
|
||||
{deletingId === session.session_id ? (
|
||||
<Loader2Icon className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2Icon className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Dialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !deletingId) {
|
||||
setDeleteTarget(null);
|
||||
setDeleteError(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenuLabel>Sessions</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuItem onSelect={handleNewChat} disabled={isStreaming}>
|
||||
<PlusIcon className="size-4" />
|
||||
<span>New Chat</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2Icon className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="px-2 py-4 text-center text-muted-foreground text-sm">
|
||||
No previous sessions
|
||||
</div>
|
||||
) : (
|
||||
sessions.map((session) => (
|
||||
<DropdownMenuItem
|
||||
key={session.session_id}
|
||||
onSelect={() => handleSelectSession(session.session_id)}
|
||||
disabled={isStreaming}
|
||||
className="flex items-start gap-2"
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Chat</DialogTitle>
|
||||
<DialogDescription>
|
||||
Delete "{deleteTarget?.title || "this session"}" from this
|
||||
project?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{deleteError && (
|
||||
<p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-destructive text-xs">
|
||||
{deleteError}
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (deletingId) return;
|
||||
setDeleteTarget(null);
|
||||
setDeleteError(null);
|
||||
}}
|
||||
disabled={!!deletingId}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-sm">{session.title}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{formatRelativeTime(session.last_modified)}
|
||||
</span>
|
||||
</div>
|
||||
{session.session_id === sessionId && (
|
||||
<CheckIcon className="size-4 shrink-0 text-primary" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
if (deleteTarget) {
|
||||
void handleDeleteSession(deleteTarget.session_id);
|
||||
}
|
||||
}}
|
||||
disabled={
|
||||
!deleteTarget ||
|
||||
!!deletingId ||
|
||||
streamingSessionIds.has(deleteTarget.session_id)
|
||||
}
|
||||
>
|
||||
{deletingId ? (
|
||||
<Loader2Icon className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2Icon className="size-3.5" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { type FC, useState } from "react";
|
||||
import {
|
||||
BotIcon,
|
||||
BrainIcon,
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
|
|
@ -37,12 +37,18 @@ export const ToolWidget: FC<ToolWidgetProps> = ({ toolUse, toolResult }) => {
|
|||
return <ReadWidget input={toolUse.input} result={toolResult} />;
|
||||
if (name === "bash")
|
||||
return <BashWidget input={toolUse.input} result={toolResult} />;
|
||||
if (name === "powershell" || name === "pwsh")
|
||||
return (
|
||||
<BashWidget input={toolUse.input} result={toolResult} prefix="PS>" />
|
||||
);
|
||||
if (name === "glob")
|
||||
return <GlobWidget input={toolUse.input} result={toolResult} />;
|
||||
if (name === "grep")
|
||||
return <GrepWidget input={toolUse.input} result={toolResult} />;
|
||||
if (name === "askuserquestion")
|
||||
return <AskUserQuestionWidget input={toolUse.input} result={toolResult} />;
|
||||
if (name === "exitplanmode")
|
||||
return <ExitPlanModeWidget input={toolUse.input} result={toolResult} />;
|
||||
if (name === "todowrite")
|
||||
return <TodoWriteWidget input={toolUse.input} result={toolResult} />;
|
||||
|
||||
|
|
@ -160,26 +166,27 @@ const ReadWidget: FC<{ input: any; result?: ContentBlock }> = ({
|
|||
|
||||
// ─── Bash Widget ───
|
||||
|
||||
const BashWidget: FC<{ input: any; result?: ContentBlock }> = ({
|
||||
input,
|
||||
result,
|
||||
}) => {
|
||||
const BashWidget: FC<{
|
||||
input: any;
|
||||
result?: ContentBlock;
|
||||
prefix?: string;
|
||||
}> = ({ input, result, prefix = "$" }) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const command = input?.command || input?.description || "";
|
||||
const resultContent =
|
||||
typeof result?.content === "string" ? result.content : "";
|
||||
|
||||
return (
|
||||
<div className="my-1.5 rounded-lg border border-border bg-[#1e1e2e] text-sm">
|
||||
<div className="my-1.5 rounded-lg border border-border bg-muted/70 text-sm dark:bg-neutral-900">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-3 py-2"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<StatusIcon result={result} />
|
||||
<TerminalIcon className="size-3.5 shrink-0 text-green-400" />
|
||||
<code className="min-w-0 truncate text-green-300 text-xs">
|
||||
$ {truncate(command, 80)}
|
||||
<TerminalIcon className="size-3.5 shrink-0 text-emerald-600 dark:text-emerald-400" />
|
||||
<code className="min-w-0 truncate text-emerald-700 text-xs dark:text-emerald-300">
|
||||
{prefix} {truncate(command, 80)}
|
||||
</code>
|
||||
{result &&
|
||||
(expanded ? (
|
||||
|
|
@ -190,7 +197,7 @@ const BashWidget: FC<{ input: any; result?: ContentBlock }> = ({
|
|||
</button>
|
||||
{expanded && resultContent && (
|
||||
<div className="max-h-40 overflow-auto border-border/50 border-t px-3 py-2">
|
||||
<pre className="whitespace-pre-wrap font-mono text-gray-300 text-xs">
|
||||
<pre className="whitespace-pre-wrap font-mono text-muted-foreground text-xs">
|
||||
{truncate(resultContent, 2000)}
|
||||
</pre>
|
||||
</div>
|
||||
|
|
@ -359,6 +366,78 @@ const AskUserQuestionWidget: FC<{ input: any; result?: ContentBlock }> = ({
|
|||
);
|
||||
};
|
||||
|
||||
// ExitPlanMode Widget
|
||||
|
||||
const ExitPlanModeWidget: FC<{ input: any; result?: ContentBlock }> = ({
|
||||
input,
|
||||
result,
|
||||
}) => {
|
||||
const [answered, setAnswered] = useState(false);
|
||||
const isStreaming = useClaudeChatStore((s) => s.isStreaming);
|
||||
const needsApproval =
|
||||
!answered && !isStreaming && (!result || result.is_error);
|
||||
const plan = input?.plan || input?.content || "";
|
||||
|
||||
const sendPlanResponse = (text: string) => {
|
||||
const { sendPrompt, isStreaming } = useClaudeChatStore.getState();
|
||||
if (isStreaming) return;
|
||||
setAnswered(true);
|
||||
sendPrompt(text);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`my-1.5 rounded-lg border text-sm ${
|
||||
needsApproval
|
||||
? "border-amber-500/40 bg-amber-500/10"
|
||||
: "border-amber-500/20 bg-amber-500/5"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<SparklesIcon className="size-3.5 text-amber-500" />
|
||||
<span className="font-medium text-amber-700 dark:text-amber-300">
|
||||
{needsApproval
|
||||
? "Plan needs approval"
|
||||
: answered
|
||||
? "Plan response sent"
|
||||
: "Plan handled"}
|
||||
</span>
|
||||
</div>
|
||||
{plan && (
|
||||
<div className="border-amber-500/20 border-t px-3 py-2">
|
||||
<pre className="max-h-56 overflow-auto whitespace-pre-wrap text-foreground text-xs leading-relaxed">
|
||||
{plan}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{needsApproval && (
|
||||
<div className="flex flex-wrap gap-2 border-amber-500/20 border-t px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md bg-amber-500 px-2.5 py-1 font-medium text-background text-xs hover:bg-amber-500/90"
|
||||
onClick={() =>
|
||||
sendPlanResponse("Approved. Continue implementing the plan.")
|
||||
}
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-amber-500/30 px-2.5 py-1 text-amber-700 text-xs hover:bg-amber-500/10 dark:text-amber-300"
|
||||
onClick={() =>
|
||||
sendPlanResponse(
|
||||
"Revise the plan before implementing. Keep it concise and address any missing risks.",
|
||||
)
|
||||
}
|
||||
>
|
||||
Revise
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── TodoWrite Widget ───
|
||||
|
||||
const TodoWriteWidget: FC<{ input: any; result?: ContentBlock }> = ({
|
||||
|
|
@ -484,9 +563,8 @@ export const ThinkingWidget: FC<{ thinking: string; signature?: string }> = ({
|
|||
className="flex w-full items-center justify-between px-3 py-2 transition-colors hover:bg-muted-foreground/10"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<BotIcon className="size-4 text-muted-foreground" />
|
||||
<SparklesIcon className="absolute -top-1 -right-1 size-2.5 animate-pulse text-muted-foreground/70" />
|
||||
<div className="flex size-5 items-center justify-center rounded-full bg-muted-foreground/10">
|
||||
<BrainIcon className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<span className="font-medium text-muted-foreground text-sm italic">
|
||||
Thinking...
|
||||
|
|
|
|||