Compare commits
100 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
674e7c7561 | ||
|
|
28f61908a7 | ||
|
|
67dde775ac | ||
|
|
311caec278 | ||
|
|
973005e086 | ||
|
|
1939030e9a | ||
|
|
f8d5b2a20a | ||
|
|
a5e33b6cc1 | ||
|
|
846ef90e16 | ||
|
|
d5337a5007 | ||
|
|
a81e0e06a9 | ||
|
|
42f20163f1 | ||
|
|
6e34b8660f | ||
|
|
4f50c1a4b2 | ||
|
|
b0126b26bf | ||
|
|
715753c8d3 | ||
|
|
c2e8e83081 | ||
|
|
9b1a8825d1 | ||
|
|
e09e6ddfd2 | ||
|
|
87a89c4c2c | ||
|
|
0dbd06e5ed | ||
|
|
97180c0fed | ||
|
|
1b2b7c3222 | ||
|
|
17a9b3caaa | ||
|
|
8404e27e07 | ||
|
|
25d3247ce5 | ||
|
|
c47a92f462 | ||
|
|
dc5e9a5d24 | ||
|
|
e63e913849 | ||
|
|
6a1fdf650d | ||
|
|
1f4055a730 | ||
|
|
eb1e300c81 | ||
|
|
c0c0ce4255 | ||
|
|
640f38e706 | ||
|
|
7ea1bd89f6 | ||
|
|
313f47d969 | ||
|
|
d9cf9d461e | ||
|
|
7ab99b5b5e | ||
|
|
d4a79375ca | ||
|
|
c8e9926c20 | ||
|
|
29f6fd936d | ||
|
|
1c4eaf3e6d | ||
|
|
cf672e44d5 | ||
|
|
037e033f35 | ||
|
|
995acd18c9 | ||
|
|
0f2635ab7a | ||
|
|
fcbcbc4c2d | ||
|
|
e57162f7b1 | ||
|
|
9fa07c1b8b | ||
|
|
9b6a662e16 | ||
|
|
9318ea632e | ||
|
|
4effb83cf6 | ||
|
|
52cfd79e43 | ||
|
|
d152fa1d6a | ||
|
|
6434f176e1 | ||
|
|
ef5cd94f4d | ||
|
|
4971a7d91a | ||
|
|
863b62b826 | ||
|
|
e473ce0a66 | ||
|
|
083eed94f1 | ||
|
|
ce32bb071b | ||
|
|
dd6ca0cb54 | ||
|
|
e37cf55367 | ||
|
|
7e5e395e30 | ||
|
|
f69de4ef12 | ||
|
|
cc4ad9d668 | ||
|
|
69bcb494f4 | ||
|
|
593a7e2301 | ||
|
|
7c874a7139 | ||
|
|
baea3e32a9 | ||
|
|
0f9755d7e1 | ||
|
|
37957aacb8 | ||
|
|
a790fa8f30 | ||
|
|
fd6725347b | ||
|
|
5d61977df6 | ||
|
|
51b6d6abec | ||
|
|
0301f904bf | ||
|
|
a6ace1a8d2 | ||
|
|
3bce19d6a0 | ||
|
|
bdc57978ba | ||
|
|
5d0be68695 | ||
|
|
2e623c217f | ||
|
|
ad9e718c31 | ||
|
|
12a79b0981 | ||
|
|
b1dee623b6 | ||
|
|
71a4035a31 | ||
|
|
ac26fc9524 | ||
|
|
03fcd08d34 | ||
|
|
f9ff9bbf3f | ||
|
|
a772b5833c | ||
|
|
f900319d86 | ||
|
|
a477605af1 | ||
|
|
956695665f | ||
|
|
37ca06db35 | ||
|
|
29d9935812 | ||
|
|
492ac17124 | ||
|
|
0eb69850e5 | ||
|
|
a1e01f19da | ||
|
|
151cc1889b | ||
|
|
b5ced3a7fa |
243
.github/workflows/build-desktop.yml
vendored
|
|
@ -130,16 +130,50 @@ jobs:
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Install macOS dependencies
|
# Build tools required by vcpkg ports (autotools for gperf, fontconfig, etc.)
|
||||||
|
# These are compile-time only — they do NOT create runtime dylib dependencies.
|
||||||
|
- name: Install build tools
|
||||||
|
run: brew install autoconf autoconf-archive automake libtool pkg-config
|
||||||
|
|
||||||
|
# Static linking via vcpkg — avoids runtime dependency on Homebrew dylibs.
|
||||||
|
# Ref: https://tectonic-typesetting.github.io/book/latest/howto/build-tectonic/
|
||||||
|
# Ref: https://learn.microsoft.com/en-us/vcpkg/users/triplets (arm64-osx defaults to static)
|
||||||
|
- name: Setup vcpkg
|
||||||
run: |
|
run: |
|
||||||
brew install icu4c harfbuzz pkg-config graphite2 freetype fontconfig
|
git clone --depth 1 https://github.com/microsoft/vcpkg $HOME/vcpkg
|
||||||
INCLUDES="-I$(brew --prefix harfbuzz)/include -I$(brew --prefix freetype)/include -I$(brew --prefix graphite2)/include -I$(brew --prefix icu4c)/include"
|
$HOME/vcpkg/bootstrap-vcpkg.sh
|
||||||
{
|
echo "VCPKG_ROOT=$HOME/vcpkg" >> $GITHUB_ENV
|
||||||
echo "PKG_CONFIG_PATH=$(brew --prefix icu4c)/lib/pkgconfig:$(brew --prefix harfbuzz)/lib/pkgconfig:$(brew --prefix graphite2)/lib/pkgconfig:$(brew --prefix freetype)/lib/pkgconfig:$(brew --prefix libpng)/lib/pkgconfig:$(brew --prefix fontconfig)/lib/pkgconfig"
|
|
||||||
echo "CXXFLAGS=-std=c++17 $INCLUDES"
|
- name: Restore vcpkg cache
|
||||||
echo "CFLAGS=$INCLUDES"
|
id: vcpkg-cache
|
||||||
echo "LDFLAGS=-L$(brew --prefix harfbuzz)/lib -L$(brew --prefix freetype)/lib -L$(brew --prefix graphite2)/lib -L$(brew --prefix icu4c)/lib"
|
uses: actions/cache/restore@v4
|
||||||
} >> $GITHUB_ENV
|
with:
|
||||||
|
path: ~/vcpkg/installed
|
||||||
|
key: vcpkg-macos-arm64-v1
|
||||||
|
|
||||||
|
- name: Install macOS dependencies (vcpkg)
|
||||||
|
if: steps.vcpkg-cache.outputs.cache-hit != 'true'
|
||||||
|
env:
|
||||||
|
VCPKG_BINARY_SOURCES: "clear"
|
||||||
|
run: |
|
||||||
|
$HOME/vcpkg/vcpkg install \
|
||||||
|
"harfbuzz[graphite2]:arm64-osx" \
|
||||||
|
fontconfig:arm64-osx \
|
||||||
|
freetype:arm64-osx \
|
||||||
|
icu:arm64-osx
|
||||||
|
|
||||||
|
- name: Save vcpkg cache
|
||||||
|
if: steps.vcpkg-cache.outputs.cache-hit != 'true'
|
||||||
|
uses: actions/cache/save@v4
|
||||||
|
with:
|
||||||
|
path: ~/vcpkg/installed
|
||||||
|
key: vcpkg-macos-arm64-v1
|
||||||
|
|
||||||
|
- name: Set macOS build environment
|
||||||
|
run: |
|
||||||
|
echo "TECTONIC_DEP_BACKEND=vcpkg" >> $GITHUB_ENV
|
||||||
|
echo "CXXFLAGS=-std=c++17" >> $GITHUB_ENV
|
||||||
|
echo "CFLAGS=" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Import Apple certificate
|
- name: Import Apple certificate
|
||||||
env:
|
env:
|
||||||
|
|
@ -186,7 +220,6 @@ jobs:
|
||||||
ZOTERO_CONSUMER_KEY: ${{ secrets.ZOTERO_CONSUMER_KEY }}
|
ZOTERO_CONSUMER_KEY: ${{ secrets.ZOTERO_CONSUMER_KEY }}
|
||||||
ZOTERO_CONSUMER_SECRET: ${{ secrets.ZOTERO_CONSUMER_SECRET }}
|
ZOTERO_CONSUMER_SECRET: ${{ secrets.ZOTERO_CONSUMER_SECRET }}
|
||||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||||
TECTONIC_DEP_BACKEND: pkg-config
|
|
||||||
run: pnpm --filter @claude-prism/desktop tauri build --target aarch64-apple-darwin
|
run: pnpm --filter @claude-prism/desktop tauri build --target aarch64-apple-darwin
|
||||||
|
|
||||||
- name: Notarize DMG
|
- name: Notarize DMG
|
||||||
|
|
@ -242,6 +275,163 @@ jobs:
|
||||||
sig: ${{ steps.collect.outputs.sig }}
|
sig: ${{ steps.collect.outputs.sig }}
|
||||||
url: ${{ steps.collect.outputs.url }}
|
url: ${{ steps.collect.outputs.url }}
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# macOS (Intel x86_64)
|
||||||
|
# Ref: https://github.com/actions/runner-images/issues/13045
|
||||||
|
# macos-13 retired 2025-12-04, macos-15-intel is the replacement
|
||||||
|
# Intel runners available until Fall 2027
|
||||||
|
# Ref: https://v2.tauri.app/distribute/pipelines/github/
|
||||||
|
# Ref: https://v2.tauri.app/plugin/updater/ (platform key: darwin-x86_64)
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
build-macos-intel:
|
||||||
|
runs-on: macos-15-intel
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
# Build tools required by vcpkg ports (autotools for gperf, fontconfig, etc.)
|
||||||
|
- name: Install build tools
|
||||||
|
run: brew install autoconf autoconf-archive automake libtool pkg-config
|
||||||
|
|
||||||
|
# Static linking via vcpkg — same approach as Apple Silicon and Windows.
|
||||||
|
- name: Setup vcpkg
|
||||||
|
run: |
|
||||||
|
git clone --depth 1 https://github.com/microsoft/vcpkg $HOME/vcpkg
|
||||||
|
$HOME/vcpkg/bootstrap-vcpkg.sh
|
||||||
|
echo "VCPKG_ROOT=$HOME/vcpkg" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Restore vcpkg cache
|
||||||
|
id: vcpkg-cache
|
||||||
|
uses: actions/cache/restore@v4
|
||||||
|
with:
|
||||||
|
path: ~/vcpkg/installed
|
||||||
|
key: vcpkg-macos-x64-v1
|
||||||
|
|
||||||
|
- name: Install macOS dependencies (vcpkg)
|
||||||
|
if: steps.vcpkg-cache.outputs.cache-hit != 'true'
|
||||||
|
env:
|
||||||
|
VCPKG_BINARY_SOURCES: "clear"
|
||||||
|
run: |
|
||||||
|
$HOME/vcpkg/vcpkg install \
|
||||||
|
"harfbuzz[graphite2]:x64-osx" \
|
||||||
|
fontconfig:x64-osx \
|
||||||
|
freetype:x64-osx \
|
||||||
|
icu:x64-osx
|
||||||
|
|
||||||
|
- name: Save vcpkg cache
|
||||||
|
if: steps.vcpkg-cache.outputs.cache-hit != 'true'
|
||||||
|
uses: actions/cache/save@v4
|
||||||
|
with:
|
||||||
|
path: ~/vcpkg/installed
|
||||||
|
key: vcpkg-macos-x64-v1
|
||||||
|
|
||||||
|
- name: Set macOS build environment
|
||||||
|
run: |
|
||||||
|
echo "TECTONIC_DEP_BACKEND=vcpkg" >> $GITHUB_ENV
|
||||||
|
echo "CXXFLAGS=-std=c++17" >> $GITHUB_ENV
|
||||||
|
echo "CFLAGS=" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Import Apple certificate
|
||||||
|
env:
|
||||||
|
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||||
|
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||||
|
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
|
||||||
|
|
||||||
|
# Create temporary keychain
|
||||||
|
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||||
|
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
|
||||||
|
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||||
|
|
||||||
|
# Import certificate
|
||||||
|
echo "$APPLE_CERTIFICATE" | base64 --decode > $RUNNER_TEMP/certificate.p12
|
||||||
|
security import $RUNNER_TEMP/certificate.p12 \
|
||||||
|
-k "$KEYCHAIN_PATH" \
|
||||||
|
-P "$APPLE_CERTIFICATE_PASSWORD" \
|
||||||
|
-T /usr/bin/codesign \
|
||||||
|
-T /usr/bin/security
|
||||||
|
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||||
|
security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain-db
|
||||||
|
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: x86_64-apple-darwin
|
||||||
|
|
||||||
|
- uses: swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: apps/desktop/src-tauri -> target
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- run: pnpm install
|
||||||
|
|
||||||
|
- name: Build Tauri app
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
ZOTERO_CONSUMER_KEY: ${{ secrets.ZOTERO_CONSUMER_KEY }}
|
||||||
|
ZOTERO_CONSUMER_SECRET: ${{ secrets.ZOTERO_CONSUMER_SECRET }}
|
||||||
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||||
|
run: pnpm --filter @claude-prism/desktop tauri build --target x86_64-apple-darwin
|
||||||
|
|
||||||
|
- name: Notarize DMG
|
||||||
|
env:
|
||||||
|
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||||
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
|
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
TARGET="x86_64-apple-darwin"
|
||||||
|
BUNDLE="apps/desktop/src-tauri/target/$TARGET/release/bundle"
|
||||||
|
DMG_PATH=$(find "$BUNDLE/dmg" -name '*.dmg' | head -1)
|
||||||
|
APP_PATH="$BUNDLE/macos/ClaudePrism.app"
|
||||||
|
|
||||||
|
if [ -z "$DMG_PATH" ]; then
|
||||||
|
echo "Error: DMG not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Notarizing $DMG_PATH ..."
|
||||||
|
xcrun notarytool submit "$DMG_PATH" \
|
||||||
|
--apple-id "$APPLE_ID" \
|
||||||
|
--team-id "$APPLE_TEAM_ID" \
|
||||||
|
--password "$APPLE_PASSWORD" \
|
||||||
|
--wait --timeout 30m
|
||||||
|
|
||||||
|
echo "==> Stapling..."
|
||||||
|
xcrun stapler staple "$DMG_PATH"
|
||||||
|
xcrun stapler staple "$APP_PATH"
|
||||||
|
|
||||||
|
- name: Collect updater artifacts
|
||||||
|
id: collect
|
||||||
|
run: |
|
||||||
|
BUNDLE="apps/desktop/src-tauri/target/x86_64-apple-darwin/release/bundle"
|
||||||
|
TAR_SIG=$(find "$BUNDLE/macos" -name '*.app.tar.gz.sig' 2>/dev/null | head -1)
|
||||||
|
TAR_GZ=$(find "$BUNDLE/macos" -name '*.app.tar.gz' ! -name '*.sig' 2>/dev/null | head -1)
|
||||||
|
|
||||||
|
if [ -n "$TAR_SIG" ] && [ -n "$TAR_GZ" ]; then
|
||||||
|
echo "sig=$(cat "$TAR_SIG")" >> $GITHUB_OUTPUT
|
||||||
|
echo "url=$(basename "$TAR_GZ")" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload build artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: desktop-macos-intel
|
||||||
|
path: |
|
||||||
|
apps/desktop/src-tauri/target/x86_64-apple-darwin/release/bundle/**/*.dmg
|
||||||
|
apps/desktop/src-tauri/target/x86_64-apple-darwin/release/bundle/**/*.app.tar.gz
|
||||||
|
apps/desktop/src-tauri/target/x86_64-apple-darwin/release/bundle/**/*.sig
|
||||||
|
if-no-files-found: warn
|
||||||
|
|
||||||
|
outputs:
|
||||||
|
sig: ${{ steps.collect.outputs.sig }}
|
||||||
|
url: ${{ steps.collect.outputs.url }}
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
# Linux
|
# Linux
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
|
|
@ -257,7 +447,9 @@ jobs:
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y \
|
sudo apt-get install -y \
|
||||||
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf \
|
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf \
|
||||||
libicu-dev libgraphite2-dev libharfbuzz-dev libfreetype-dev libfontconfig-dev
|
autoconf autoconf-archive automake libtool pkg-config \
|
||||||
|
libgraphite2-dev libicu-dev libfontconfig1-dev libharfbuzz-dev \
|
||||||
|
libssl-dev libfreetype-dev libpng-dev zlib1g-dev
|
||||||
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
|
|
@ -280,7 +472,10 @@ jobs:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
ZOTERO_CONSUMER_KEY: ${{ secrets.ZOTERO_CONSUMER_KEY }}
|
ZOTERO_CONSUMER_KEY: ${{ secrets.ZOTERO_CONSUMER_KEY }}
|
||||||
ZOTERO_CONSUMER_SECRET: ${{ secrets.ZOTERO_CONSUMER_SECRET }}
|
ZOTERO_CONSUMER_SECRET: ${{ secrets.ZOTERO_CONSUMER_SECRET }}
|
||||||
|
# Use pkg-config (same as tectonic's own CI) with semi-static linking.
|
||||||
|
# AppImage bundles .so files automatically for portable distribution.
|
||||||
TECTONIC_DEP_BACKEND: pkg-config
|
TECTONIC_DEP_BACKEND: pkg-config
|
||||||
|
TECTONIC_PKGCONFIG_FORCE_SEMI_STATIC: "true"
|
||||||
CXXFLAGS: "-std=c++17"
|
CXXFLAGS: "-std=c++17"
|
||||||
CFLAGS: ""
|
CFLAGS: ""
|
||||||
run: pnpm --filter @claude-prism/desktop tauri build --target x86_64-unknown-linux-gnu
|
run: pnpm --filter @claude-prism/desktop tauri build --target x86_64-unknown-linux-gnu
|
||||||
|
|
@ -316,7 +511,7 @@ jobs:
|
||||||
# Publish: generate latest.json & upload to release
|
# Publish: generate latest.json & upload to release
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
publish:
|
publish:
|
||||||
needs: [build-windows, build-macos, build-linux]
|
needs: [build-windows, build-macos, build-macos-intel, build-linux]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: startsWith(github.ref, 'refs/tags/v') || inputs.release_tag != ''
|
if: startsWith(github.ref, 'refs/tags/v') || inputs.release_tag != ''
|
||||||
|
|
||||||
|
|
@ -344,6 +539,8 @@ jobs:
|
||||||
WIN_URL: ${{ needs.build-windows.outputs.url }}
|
WIN_URL: ${{ needs.build-windows.outputs.url }}
|
||||||
MAC_SIG: ${{ needs.build-macos.outputs.sig }}
|
MAC_SIG: ${{ needs.build-macos.outputs.sig }}
|
||||||
MAC_URL: ${{ needs.build-macos.outputs.url }}
|
MAC_URL: ${{ needs.build-macos.outputs.url }}
|
||||||
|
MAC_INTEL_SIG: ${{ needs.build-macos-intel.outputs.sig }}
|
||||||
|
MAC_INTEL_URL: ${{ needs.build-macos-intel.outputs.url }}
|
||||||
LIN_SIG: ${{ needs.build-linux.outputs.sig }}
|
LIN_SIG: ${{ needs.build-linux.outputs.sig }}
|
||||||
LIN_URL: ${{ needs.build-linux.outputs.url }}
|
LIN_URL: ${{ needs.build-linux.outputs.url }}
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -356,6 +553,8 @@ jobs:
|
||||||
const platforms = {};
|
const platforms = {};
|
||||||
if (process.env.MAC_SIG)
|
if (process.env.MAC_SIG)
|
||||||
platforms['darwin-aarch64'] = { signature: process.env.MAC_SIG, url: BASE + '/ClaudePrism-macOS.app.tar.gz' };
|
platforms['darwin-aarch64'] = { signature: process.env.MAC_SIG, url: BASE + '/ClaudePrism-macOS.app.tar.gz' };
|
||||||
|
if (process.env.MAC_INTEL_SIG)
|
||||||
|
platforms['darwin-x86_64'] = { signature: process.env.MAC_INTEL_SIG, url: BASE + '/ClaudePrism-macOS-Intel.app.tar.gz' };
|
||||||
if (process.env.LIN_SIG)
|
if (process.env.LIN_SIG)
|
||||||
platforms['linux-x86_64'] = { signature: process.env.LIN_SIG, url: BASE + '/ClaudePrism-Linux.AppImage' };
|
platforms['linux-x86_64'] = { signature: process.env.LIN_SIG, url: BASE + '/ClaudePrism-Linux.AppImage' };
|
||||||
if (process.env.WIN_SIG)
|
if (process.env.WIN_SIG)
|
||||||
|
|
@ -387,13 +586,19 @@ jobs:
|
||||||
mkdir -p upload
|
mkdir -p upload
|
||||||
|
|
||||||
# Rename platform artifacts to version-free names
|
# Rename platform artifacts to version-free names
|
||||||
find artifacts -name "*.dmg" | head -1 | xargs -I{} cp {} upload/ClaudePrism-macOS.dmg
|
# macOS Apple Silicon (from desktop-macos artifact)
|
||||||
find artifacts -name "*.app.tar.gz" | head -1 | xargs -I{} cp {} upload/ClaudePrism-macOS.app.tar.gz
|
find artifacts/desktop-macos -name "*.dmg" 2>/dev/null | head -1 | xargs -I{} cp {} upload/ClaudePrism-macOS.dmg
|
||||||
find artifacts -name "*-setup.exe" | head -1 | xargs -I{} cp {} upload/ClaudePrism-Windows-setup.exe
|
find artifacts/desktop-macos -name "*.app.tar.gz" 2>/dev/null | head -1 | xargs -I{} cp {} upload/ClaudePrism-macOS.app.tar.gz
|
||||||
find artifacts -name "*.msi" | head -1 | xargs -I{} cp {} upload/ClaudePrism-Windows.msi
|
# macOS Intel (from desktop-macos-intel artifact)
|
||||||
find artifacts -name "*.deb" | head -1 | xargs -I{} cp {} upload/ClaudePrism-Linux.deb
|
find artifacts/desktop-macos-intel -name "*.dmg" 2>/dev/null | head -1 | xargs -I{} cp {} upload/ClaudePrism-macOS-Intel.dmg
|
||||||
find artifacts -name "*.rpm" | head -1 | xargs -I{} cp {} upload/ClaudePrism-Linux.rpm
|
find artifacts/desktop-macos-intel -name "*.app.tar.gz" 2>/dev/null | head -1 | xargs -I{} cp {} upload/ClaudePrism-macOS-Intel.app.tar.gz
|
||||||
find artifacts -name "*.AppImage" | head -1 | xargs -I{} cp {} upload/ClaudePrism-Linux.AppImage
|
# Windows
|
||||||
|
find artifacts/desktop-windows -name "*-setup.exe" 2>/dev/null | head -1 | xargs -I{} cp {} upload/ClaudePrism-Windows-setup.exe
|
||||||
|
find artifacts/desktop-windows -name "*.msi" 2>/dev/null | head -1 | xargs -I{} cp {} upload/ClaudePrism-Windows.msi
|
||||||
|
# Linux
|
||||||
|
find artifacts/desktop-linux -name "*.deb" 2>/dev/null | head -1 | xargs -I{} cp {} upload/ClaudePrism-Linux.deb
|
||||||
|
find artifacts/desktop-linux -name "*.rpm" 2>/dev/null | head -1 | xargs -I{} cp {} upload/ClaudePrism-Linux.rpm
|
||||||
|
find artifacts/desktop-linux -name "*.AppImage" 2>/dev/null | head -1 | xargs -I{} cp {} upload/ClaudePrism-Linux.AppImage
|
||||||
|
|
||||||
cp latest.json upload/
|
cp latest.json upload/
|
||||||
|
|
||||||
|
|
|
||||||
2
.gitignore
vendored
|
|
@ -31,7 +31,7 @@ tsconfig.tsbuildinfo
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
# Rust
|
# Rust
|
||||||
Cargo.lock
|
apps/desktop/src-tauri/.cargo/config.toml
|
||||||
|
|
||||||
# Tauri auto-generated schemas
|
# Tauri auto-generated schemas
|
||||||
apps/desktop/src-tauri/gen/schemas/
|
apps/desktop/src-tauri/gen/schemas/
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,10 @@
|
||||||
<img src="https://img.shields.io/badge/Website-claudeprism.dev-blue?style=flat-square&logo=googlechrome&logoColor=white" alt="Website" />
|
<img src="https://img.shields.io/badge/Website-claudeprism.dev-blue?style=flat-square&logo=googlechrome&logoColor=white" alt="Website" />
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-macOS.dmg">
|
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-macOS.dmg">
|
||||||
<img src="https://img.shields.io/badge/Download-macOS_(Apple_Silicon)-black?style=for-the-badge&logo=apple&logoColor=white" alt="macOS 版をダウンロード" />
|
<img src="https://img.shields.io/badge/Download-macOS_(Apple_Silicon)-black?style=for-the-badge&logo=apple&logoColor=white" alt="macOS (Apple Silicon) をダウンロード" />
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-macOS-Intel.dmg">
|
||||||
|
<img src="https://img.shields.io/badge/Download-macOS_(Intel)-555555?style=for-the-badge&logo=apple&logoColor=white" alt="macOS (Intel) をダウンロード" />
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-Windows-setup.exe">
|
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-Windows-setup.exe">
|
||||||
<img src="https://img.shields.io/badge/Download-Windows-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Windows 版をダウンロード" />
|
<img src="https://img.shields.io/badge/Download-Windows-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Windows 版をダウンロード" />
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,10 @@
|
||||||
<img src="https://img.shields.io/badge/Website-claudeprism.dev-blue?style=flat-square&logo=googlechrome&logoColor=white" alt="Website" />
|
<img src="https://img.shields.io/badge/Website-claudeprism.dev-blue?style=flat-square&logo=googlechrome&logoColor=white" alt="Website" />
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-macOS.dmg">
|
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-macOS.dmg">
|
||||||
<img src="https://img.shields.io/badge/Download-macOS_(Apple_Silicon)-black?style=for-the-badge&logo=apple&logoColor=white" alt="macOS 다운로드" />
|
<img src="https://img.shields.io/badge/Download-macOS_(Apple_Silicon)-black?style=for-the-badge&logo=apple&logoColor=white" alt="macOS (Apple Silicon) 다운로드" />
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-macOS-Intel.dmg">
|
||||||
|
<img src="https://img.shields.io/badge/Download-macOS_(Intel)-555555?style=for-the-badge&logo=apple&logoColor=white" alt="macOS (Intel) 다운로드" />
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-Windows-setup.exe">
|
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-Windows-setup.exe">
|
||||||
<img src="https://img.shields.io/badge/Download-Windows-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Windows 다운로드" />
|
<img src="https://img.shields.io/badge/Download-Windows-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Windows 다운로드" />
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,10 @@
|
||||||
<img src="https://img.shields.io/badge/Website-claudeprism.dev-blue?style=flat-square&logo=googlechrome&logoColor=white" alt="Website" />
|
<img src="https://img.shields.io/badge/Website-claudeprism.dev-blue?style=flat-square&logo=googlechrome&logoColor=white" alt="Website" />
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-macOS.dmg">
|
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-macOS.dmg">
|
||||||
<img src="https://img.shields.io/badge/Download-macOS_(Apple_Silicon)-black?style=for-the-badge&logo=apple&logoColor=white" alt="Download for macOS" />
|
<img src="https://img.shields.io/badge/Download-macOS_(Apple_Silicon)-black?style=for-the-badge&logo=apple&logoColor=white" alt="Download for macOS (Apple Silicon)" />
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-macOS-Intel.dmg">
|
||||||
|
<img src="https://img.shields.io/badge/Download-macOS_(Intel)-555555?style=for-the-badge&logo=apple&logoColor=white" alt="Download for macOS (Intel)" />
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-Windows-setup.exe">
|
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-Windows-setup.exe">
|
||||||
<img src="https://img.shields.io/badge/Download-Windows-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Download for Windows" />
|
<img src="https://img.shields.io/badge/Download-Windows-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Download for Windows" />
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,8 @@
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://claudeprism.delibae.dev?utm_source=github&utm_medium=readme&utm_campaign=launch_v054">官网</a> ·
|
<a href="https://claudeprism.delibae.dev?utm_source=github&utm_medium=readme&utm_campaign=launch_v054">官网</a> ·
|
||||||
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-macOS.dmg">macOS</a> ·
|
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-macOS.dmg">macOS (Apple Silicon)</a> ·
|
||||||
|
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-macOS-Intel.dmg">macOS (Intel)</a> ·
|
||||||
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-Windows-setup.exe">Windows</a> ·
|
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-Windows-setup.exe">Windows</a> ·
|
||||||
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-Linux.AppImage">Linux</a> ·
|
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-Linux.AppImage">Linux</a> ·
|
||||||
<a href="https://github.com/delibae/claude-prism/releases">所有版本</a>
|
<a href="https://github.com/delibae/claude-prism/releases">所有版本</a>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@claude-prism/desktop",
|
"name": "@claude-prism/desktop",
|
||||||
"version": "1.0.7",
|
"version": "1.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
@ -26,11 +26,12 @@
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@tauri-apps/api": "^2.5.0",
|
"@replit/codemirror-vim": "^6.3.0",
|
||||||
"@tauri-apps/plugin-dialog": "^2.2.2",
|
"@tauri-apps/api": "^2.11.0",
|
||||||
"@tauri-apps/plugin-fs": "^2.3.0",
|
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||||
"@tauri-apps/plugin-process": "^2.2.2",
|
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||||
"@tauri-apps/plugin-shell": "^2.2.2",
|
"@tauri-apps/plugin-process": "^2.3.1",
|
||||||
|
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||||
"@tauri-apps/plugin-updater": "~2.10.0",
|
"@tauri-apps/plugin-updater": "~2.10.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|
@ -67,7 +68,8 @@
|
||||||
"jsdom": "^26.1.0",
|
"jsdom": "^26.1.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^6.3.5",
|
"vite": "^6.4.2",
|
||||||
"vitest": "^3.1.1"
|
"vite-plugin-top-level-await": "^1.6.0",
|
||||||
|
"vitest": "^4.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
# Tectonic build configuration for local macOS development (Homebrew).
|
|
||||||
# ICU is keg-only in Homebrew, so pkg-config can't find it without this path.
|
|
||||||
# HarfBuzz headers need the parent include directory for #include <harfbuzz/hb.h>.
|
|
||||||
# ICU 78+ requires C++17.
|
|
||||||
# On Linux, system packages are in standard paths — these extra paths are harmless.
|
|
||||||
#
|
|
||||||
# CI overrides: force=false means CI env vars (TECTONIC_DEP_BACKEND=vcpkg,
|
|
||||||
# VCPKG_ROOT, CXXFLAGS, CFLAGS) take precedence over these defaults.
|
|
||||||
[env]
|
|
||||||
PKG_CONFIG_PATH = { value = "/opt/homebrew/opt/icu4c/lib/pkgconfig", force = false }
|
|
||||||
CXXFLAGS = { value = "-I/opt/homebrew/include -std=c++17", force = false }
|
|
||||||
CFLAGS = { value = "-I/opt/homebrew/include", force = false }
|
|
||||||
7688
apps/desktop/src-tauri/Cargo.lock
generated
Normal file
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "claude-prism-desktop"
|
name = "claude-prism-desktop"
|
||||||
version = "1.0.5"
|
version = "1.3.0"
|
||||||
description = "AI-powered LaTeX writing workspace"
|
description = "AI-powered LaTeX writing workspace"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
|
|
@ -23,7 +23,7 @@ serde_json = "1"
|
||||||
serde_yaml = "0.9"
|
serde_yaml = "0.9"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
dirs = "5"
|
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"
|
hmac = "0.12"
|
||||||
sha1 = "0.10"
|
sha1 = "0.10"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
|
@ -52,5 +52,9 @@ objc2 = "0.6"
|
||||||
objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSImage", "NSRunningApplication"] }
|
objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSImage", "NSRunningApplication"] }
|
||||||
objc2-foundation = { version = "0.3", features = ["NSData"] }
|
objc2-foundation = { version = "0.3", features = ["NSData"] }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
|
winreg = "0.55"
|
||||||
|
windows-sys = { version = "0.61", features = ["Win32_System_Environment"] }
|
||||||
|
|
||||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
tauri-plugin-updater = "2"
|
tauri-plugin-updater = "2"
|
||||||
|
|
|
||||||
|
|
@ -6,5 +6,20 @@ fn main() {
|
||||||
println!("cargo:rustc-env={key}={val}");
|
println!("cargo:rustc-env={key}={val}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// On Linux, apply a version script to hide statically linked ICU/HarfBuzz/
|
||||||
|
// FreeType/Fontconfig symbols from the dynamic symbol table. This prevents
|
||||||
|
// symbol collisions with the system copies loaded by WebKit2GTK (segfault).
|
||||||
|
// See: https://github.com/delibae/claude-prism/issues/100
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR");
|
||||||
|
println!(
|
||||||
|
"cargo:rustc-link-arg=-Wl,--version-script={}/symbols.map",
|
||||||
|
manifest_dir
|
||||||
|
);
|
||||||
|
println!("cargo:rerun-if-changed=symbols.map");
|
||||||
|
}
|
||||||
|
|
||||||
tauri_build::build()
|
tauri_build::build()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,28 +15,132 @@
|
||||||
"core:window:allow-toggle-maximize",
|
"core:window:allow-toggle-maximize",
|
||||||
"core:window:allow-close",
|
"core:window:allow-close",
|
||||||
"core:webview:allow-create-webview-window",
|
"core:webview:allow-create-webview-window",
|
||||||
|
"core:webview:allow-set-webview-zoom",
|
||||||
"dialog:default",
|
"dialog:default",
|
||||||
"dialog:allow-open",
|
"dialog:allow-open",
|
||||||
"dialog:allow-save",
|
"dialog:allow-save",
|
||||||
"dialog:allow-message",
|
"dialog:allow-message",
|
||||||
"dialog:allow-ask",
|
"dialog:allow-ask",
|
||||||
"fs:default",
|
"fs:default",
|
||||||
"fs:allow-read",
|
|
||||||
"fs:allow-write",
|
|
||||||
"fs:allow-exists",
|
|
||||||
"fs:allow-mkdir",
|
|
||||||
"fs:allow-remove",
|
|
||||||
"fs:allow-rename",
|
|
||||||
"fs:allow-copy-file",
|
|
||||||
"fs:allow-read-dir",
|
|
||||||
"fs:allow-read-text-file",
|
|
||||||
"fs:allow-write-text-file",
|
|
||||||
"fs:allow-read-file",
|
|
||||||
"fs:allow-write-file",
|
|
||||||
"fs:allow-stat",
|
|
||||||
"fs:allow-watch",
|
|
||||||
{
|
{
|
||||||
"identifier": "fs:scope",
|
"identifier": "fs:allow-read",
|
||||||
|
"allow": [
|
||||||
|
{ "path": "$HOME/**" },
|
||||||
|
{ "path": "$DOCUMENT/**" },
|
||||||
|
{ "path": "$DESKTOP/**" },
|
||||||
|
{ "path": "$TEMP/**" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "fs:allow-write",
|
||||||
|
"allow": [
|
||||||
|
{ "path": "$HOME/**" },
|
||||||
|
{ "path": "$DOCUMENT/**" },
|
||||||
|
{ "path": "$DESKTOP/**" },
|
||||||
|
{ "path": "$TEMP/**" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "fs:allow-exists",
|
||||||
|
"allow": [
|
||||||
|
{ "path": "$HOME/**" },
|
||||||
|
{ "path": "$DOCUMENT/**" },
|
||||||
|
{ "path": "$DESKTOP/**" },
|
||||||
|
{ "path": "$TEMP/**" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "fs:allow-mkdir",
|
||||||
|
"allow": [
|
||||||
|
{ "path": "$HOME/**" },
|
||||||
|
{ "path": "$DOCUMENT/**" },
|
||||||
|
{ "path": "$DESKTOP/**" },
|
||||||
|
{ "path": "$TEMP/**" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "fs:allow-remove",
|
||||||
|
"allow": [
|
||||||
|
{ "path": "$HOME/**" },
|
||||||
|
{ "path": "$DOCUMENT/**" },
|
||||||
|
{ "path": "$DESKTOP/**" },
|
||||||
|
{ "path": "$TEMP/**" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "fs:allow-rename",
|
||||||
|
"allow": [
|
||||||
|
{ "path": "$HOME/**" },
|
||||||
|
{ "path": "$DOCUMENT/**" },
|
||||||
|
{ "path": "$DESKTOP/**" },
|
||||||
|
{ "path": "$TEMP/**" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "fs:allow-copy-file",
|
||||||
|
"allow": [
|
||||||
|
{ "path": "$HOME/**" },
|
||||||
|
{ "path": "$DOCUMENT/**" },
|
||||||
|
{ "path": "$DESKTOP/**" },
|
||||||
|
{ "path": "$TEMP/**" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "fs:allow-read-dir",
|
||||||
|
"allow": [
|
||||||
|
{ "path": "$HOME/**" },
|
||||||
|
{ "path": "$DOCUMENT/**" },
|
||||||
|
{ "path": "$DESKTOP/**" },
|
||||||
|
{ "path": "$TEMP/**" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "fs:allow-read-text-file",
|
||||||
|
"allow": [
|
||||||
|
{ "path": "$HOME/**" },
|
||||||
|
{ "path": "$DOCUMENT/**" },
|
||||||
|
{ "path": "$DESKTOP/**" },
|
||||||
|
{ "path": "$TEMP/**" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "fs:allow-write-text-file",
|
||||||
|
"allow": [
|
||||||
|
{ "path": "$HOME/**" },
|
||||||
|
{ "path": "$DOCUMENT/**" },
|
||||||
|
{ "path": "$DESKTOP/**" },
|
||||||
|
{ "path": "$TEMP/**" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "fs:allow-read-file",
|
||||||
|
"allow": [
|
||||||
|
{ "path": "$HOME/**" },
|
||||||
|
{ "path": "$DOCUMENT/**" },
|
||||||
|
{ "path": "$DESKTOP/**" },
|
||||||
|
{ "path": "$TEMP/**" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "fs:allow-write-file",
|
||||||
|
"allow": [
|
||||||
|
{ "path": "$HOME/**" },
|
||||||
|
{ "path": "$DOCUMENT/**" },
|
||||||
|
{ "path": "$DESKTOP/**" },
|
||||||
|
{ "path": "$TEMP/**" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "fs:allow-stat",
|
||||||
|
"allow": [
|
||||||
|
{ "path": "$HOME/**" },
|
||||||
|
{ "path": "$DOCUMENT/**" },
|
||||||
|
{ "path": "$DESKTOP/**" },
|
||||||
|
{ "path": "$TEMP/**" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "fs:allow-watch",
|
||||||
"allow": [
|
"allow": [
|
||||||
{ "path": "$HOME/**" },
|
{ "path": "$HOME/**" },
|
||||||
{ "path": "$DOCUMENT/**" },
|
{ "path": "$DOCUMENT/**" },
|
||||||
|
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,14 @@ use tokio::sync::{Mutex, Semaphore};
|
||||||
|
|
||||||
const MAX_CONCURRENT: usize = 3;
|
const MAX_CONCURRENT: usize = 3;
|
||||||
|
|
||||||
|
/// Windows CREATE_NO_WINDOW flag to prevent console windows from flashing
|
||||||
|
/// when spawning TeXLive/Tectonic child processes from the GUI app.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
|
||||||
struct BuildInfo {
|
struct BuildInfo {
|
||||||
work_dir: PathBuf,
|
work_dir: PathBuf,
|
||||||
main_file_name: String,
|
main_file_name: String,
|
||||||
|
|
@ -43,18 +51,34 @@ fn extract_error_lines(log: &str) -> String {
|
||||||
return String::new();
|
return String::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract real errors first — they take priority over "No pages of output"
|
let lines: Vec<&str> = log.lines().collect();
|
||||||
let error_lines: Vec<&str> = log
|
|
||||||
.lines()
|
|
||||||
.filter(|l| l.starts_with('!') || l.contains("Error:") || l.contains("error:"))
|
|
||||||
.take(10)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if !error_lines.is_empty() {
|
let mut blocks: Vec<String> = Vec::new();
|
||||||
return error_lines.join("\n");
|
let mut i = 0;
|
||||||
|
while i < lines.len() && blocks.len() < 5 {
|
||||||
|
let line = lines[i];
|
||||||
|
let is_error_start =
|
||||||
|
line.starts_with('!') || line.contains("Error:") || line.contains("error:");
|
||||||
|
|
||||||
|
if is_error_start {
|
||||||
|
let end = (i + 14).min(lines.len());
|
||||||
|
blocks.push(lines[i..end].join("\n"));
|
||||||
|
i = end;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if log.lines().any(|l| l.contains("No pages of output")) {
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !blocks.is_empty() {
|
||||||
|
let mut result = blocks.join("\n\n");
|
||||||
|
result.push_str("\n\n---- Engine output ----\n");
|
||||||
|
let tail_start = lines.len().saturating_sub(20);
|
||||||
|
result.push_str(&lines[tail_start..].join("\n"));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if lines.iter().any(|l| l.contains("No pages of output")) {
|
||||||
return "No pages of output. Add visible content to the document body.".to_string();
|
return "No pages of output. Add visible content to the document body.".to_string();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -102,6 +126,102 @@ fn detect_tex_engine(content: &str) -> Option<TexEngine> {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq)]
|
||||||
|
enum BibTool {
|
||||||
|
Biber,
|
||||||
|
BibTeX,
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect which bibliography tool is needed by scanning .tex content.
|
||||||
|
fn detect_bib_tool(content: &str) -> BibTool {
|
||||||
|
for line in content.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.starts_with('%') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if trimmed.contains("\\usepackage") && trimmed.contains("biblatex") {
|
||||||
|
return BibTool::Biber;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for line in content.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.starts_with('%') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if trimmed.contains("\\bibliography{") || trimmed.contains("\\addbibresource{") {
|
||||||
|
return BibTool::BibTeX;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BibTool::None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a TeXLive engine binary to its full path.
|
||||||
|
/// GUI apps on macOS lack the user's shell PATH, so we check standard
|
||||||
|
/// TeXLive installation locations and fall back to a login-shell query.
|
||||||
|
fn find_texlive_binary(name: &str) -> Result<PathBuf, String> {
|
||||||
|
// 1. Try PATH (works when launched from terminal)
|
||||||
|
if let Ok(path) = which::which(name) {
|
||||||
|
return Ok(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check standard TeXLive locations
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
{
|
||||||
|
let standard_paths = [
|
||||||
|
format!("/Library/TeX/texbin/{}", name),
|
||||||
|
format!("/usr/local/texlive/2025/bin/universal-darwin/{}", name),
|
||||||
|
format!("/usr/local/texlive/2024/bin/universal-darwin/{}", name),
|
||||||
|
format!("/usr/local/texlive/2025/bin/x86_64-linux/{}", name),
|
||||||
|
format!("/usr/local/texlive/2024/bin/x86_64-linux/{}", name),
|
||||||
|
format!("/opt/homebrew/bin/{}", name),
|
||||||
|
format!("/usr/bin/{}", name),
|
||||||
|
];
|
||||||
|
for path_str in &standard_paths {
|
||||||
|
let p = PathBuf::from(path_str);
|
||||||
|
if p.exists() {
|
||||||
|
return Ok(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
let standard_paths = [
|
||||||
|
format!("C:\\texlive\\2025\\bin\\windows\\{}.exe", name),
|
||||||
|
format!("C:\\texlive\\2024\\bin\\windows\\{}.exe", name),
|
||||||
|
];
|
||||||
|
for path_str in &standard_paths {
|
||||||
|
let p = PathBuf::from(path_str);
|
||||||
|
if p.exists() {
|
||||||
|
return Ok(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. macOS: ask login shell for PATH
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
if let Ok(output) = std::process::Command::new("/bin/zsh")
|
||||||
|
.args(["-l", "-c", &format!("which {}", name)])
|
||||||
|
.output()
|
||||||
|
{
|
||||||
|
if output.status.success() {
|
||||||
|
let resolved = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
let p = PathBuf::from(&resolved);
|
||||||
|
if p.exists() {
|
||||||
|
return Ok(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(format!(
|
||||||
|
"{} not found. Install TeXLive or add it to your PATH.",
|
||||||
|
name
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
|
fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
|
||||||
if !dst.exists() {
|
if !dst.exists() {
|
||||||
std::fs::create_dir_all(dst)?;
|
std::fs::create_dir_all(dst)?;
|
||||||
|
|
@ -134,10 +254,12 @@ fn sync_source_files(src: &Path, dst: &Path) -> std::io::Result<()> {
|
||||||
for entry in std::fs::read_dir(src)? {
|
for entry in std::fs::read_dir(src)? {
|
||||||
let entry = entry?;
|
let entry = entry?;
|
||||||
let src_path = entry.path();
|
let src_path = entry.path();
|
||||||
let dst_path = dst.join(entry.file_name());
|
let file_name = entry.file_name();
|
||||||
|
let dst_path = dst.join(&file_name);
|
||||||
if src_path.is_dir() {
|
if src_path.is_dir() {
|
||||||
let name = entry.file_name();
|
let name = file_name.to_string_lossy();
|
||||||
if name.to_string_lossy().starts_with('.') {
|
if name.starts_with('.') || matches!(name.as_ref(), "node_modules" | "target" | "dist")
|
||||||
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
sync_source_files(&src_path, &dst_path)?;
|
sync_source_files(&src_path, &dst_path)?;
|
||||||
|
|
@ -173,6 +295,21 @@ fn sync_source_files(src: &Path, dst: &Path) -> std::io::Result<()> {
|
||||||
// Cloud storage (Dropbox/iCloud) may keep files as online-only
|
// Cloud storage (Dropbox/iCloud) may keep files as online-only
|
||||||
// placeholders with 0 bytes. Reading the file forces a download.
|
// placeholders with 0 bytes. Reading the file forces a download.
|
||||||
let metadata = std::fs::metadata(&src_path)?;
|
let metadata = std::fs::metadata(&src_path)?;
|
||||||
|
|
||||||
|
if metadata.len() > 0 {
|
||||||
|
if let Ok(dst_meta) = std::fs::metadata(&dst_path) {
|
||||||
|
if metadata.len() == dst_meta.len() {
|
||||||
|
if let (Ok(src_m), Ok(dst_m)) =
|
||||||
|
(metadata.modified(), dst_meta.modified())
|
||||||
|
{
|
||||||
|
if src_m == dst_m {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if metadata.len() == 0 {
|
if metadata.len() == 0 {
|
||||||
// Attempt to materialize the file by reading it
|
// Attempt to materialize the file by reading it
|
||||||
let data = std::fs::read(&src_path)?;
|
let data = std::fs::read(&src_path)?;
|
||||||
|
|
@ -278,10 +415,13 @@ fn compile_with_tectonic_subprocess(work_dir: &Path, main_file: &str) -> Result<
|
||||||
let exe = std::env::current_exe()
|
let exe = std::env::current_exe()
|
||||||
.map_err(|e| format!("Failed to get current executable path: {}", e))?;
|
.map_err(|e| format!("Failed to get current executable path: {}", e))?;
|
||||||
|
|
||||||
let output = std::process::Command::new(&exe)
|
let mut cmd = std::process::Command::new(&exe);
|
||||||
.args(["--tectonic-compile", &work_dir.to_string_lossy(), main_file])
|
cmd.args(["--tectonic-compile", &work_dir.to_string_lossy(), main_file])
|
||||||
.stdout(std::process::Stdio::piped())
|
.stdout(std::process::Stdio::piped())
|
||||||
.stderr(std::process::Stdio::piped())
|
.stderr(std::process::Stdio::piped());
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||||
|
let output = cmd
|
||||||
.output()
|
.output()
|
||||||
.map_err(|e| format!("Failed to spawn tectonic subprocess: {}", e))?;
|
.map_err(|e| format!("Failed to spawn tectonic subprocess: {}", e))?;
|
||||||
|
|
||||||
|
|
@ -293,6 +433,194 @@ fn compile_with_tectonic_subprocess(work_dir: &Path, main_file: &str) -> Result<
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- TeXLive Compilation ---
|
||||||
|
|
||||||
|
/// Build a PATH that includes the TeXLive bin directory so that xelatex
|
||||||
|
/// can find xdvipdfmx, kpsewhich, and other tools it invokes internally.
|
||||||
|
/// GUI apps on macOS have a minimal PATH that doesn't include TeXLive.
|
||||||
|
fn texlive_env_path(engine: &Path) -> String {
|
||||||
|
let texbin = engine
|
||||||
|
.parent()
|
||||||
|
.map(|p| p.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let current_path = std::env::var("PATH").unwrap_or_default();
|
||||||
|
if current_path.contains(&texbin) {
|
||||||
|
current_path
|
||||||
|
} else {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
format!("{};{}", texbin, current_path)
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
{
|
||||||
|
format!("{}:{}", texbin, current_path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a single TeX engine pass. Never returns `Err` for a non-zero exit
|
||||||
|
/// code — TeXLive returns non-zero for warnings, font substitutions, etc.
|
||||||
|
/// The only `Err` is when the process cannot be *spawned* at all.
|
||||||
|
/// The caller decides success by checking whether the PDF was produced.
|
||||||
|
fn run_texlive_pass(
|
||||||
|
engine: &Path,
|
||||||
|
args: &[&str],
|
||||||
|
main_file: &Path,
|
||||||
|
work_dir: &Path,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut cmd = std::process::Command::new(engine);
|
||||||
|
cmd.args(args)
|
||||||
|
.arg(main_file)
|
||||||
|
.current_dir(work_dir)
|
||||||
|
.env("PATH", texlive_env_path(engine))
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::piped());
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||||
|
let output = cmd
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to launch {}: {}", engine.display(), e))?;
|
||||||
|
|
||||||
|
// TeXLive returns non-zero on warnings too — don't fail here.
|
||||||
|
// The caller decides success by checking whether the PDF was produced.
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
if !stderr.trim().is_empty() {
|
||||||
|
eprintln!("[texlive] engine stderr: {}", stderr.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compile_with_texlive(
|
||||||
|
work_dir: &Path,
|
||||||
|
main_file: &str,
|
||||||
|
engine: Option<TexEngine>,
|
||||||
|
tex_content: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let engine_name = match engine {
|
||||||
|
Some(TexEngine::XeLaTeX) | None => "xelatex",
|
||||||
|
Some(TexEngine::Latex) => "pdflatex",
|
||||||
|
Some(TexEngine::LuaLaTeX) => "lualatex",
|
||||||
|
};
|
||||||
|
|
||||||
|
let engine_path = find_texlive_binary(engine_name)?;
|
||||||
|
let env_path = texlive_env_path(&engine_path);
|
||||||
|
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.
|
||||||
|
// Absolute paths break when they contain ~ (e.g. iCloud's com~apple~CloudDocs)
|
||||||
|
// because TeX interprets ~ as a home directory shortcut.
|
||||||
|
let output_dir_arg = "-output-directory=.".to_string();
|
||||||
|
// Do NOT use -halt-on-error: xelatex is a pipeline (xetex → .xdv → xdvipdfmx → .pdf).
|
||||||
|
// 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 main_file_path = Path::new(main_file);
|
||||||
|
|
||||||
|
// Pass 1
|
||||||
|
run_texlive_pass(&engine_path, &common_args, main_file_path, work_dir)?;
|
||||||
|
|
||||||
|
// Bib pass (if needed)
|
||||||
|
let main_stem = Path::new(main_file)
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or("document");
|
||||||
|
|
||||||
|
match bib_tool {
|
||||||
|
BibTool::Biber => {
|
||||||
|
let biber_path = find_texlive_binary("biber")?;
|
||||||
|
let mut cmd = std::process::Command::new(&biber_path);
|
||||||
|
cmd.arg(main_stem)
|
||||||
|
.current_dir(work_dir)
|
||||||
|
.env("PATH", &env_path)
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::piped());
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||||
|
let output = cmd
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to run biber: {}", e))?;
|
||||||
|
if !output.status.success() {
|
||||||
|
eprintln!(
|
||||||
|
"[texlive] biber warning: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BibTool::BibTeX => {
|
||||||
|
let bibtex_path = find_texlive_binary("bibtex")?;
|
||||||
|
let aux_file = work_dir.join(format!("{}.aux", main_stem));
|
||||||
|
let mut cmd = std::process::Command::new(&bibtex_path);
|
||||||
|
cmd.arg(&aux_file)
|
||||||
|
.current_dir(work_dir)
|
||||||
|
.env("PATH", &env_path)
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::piped());
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||||
|
let output = cmd
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to run bibtex: {}", e))?;
|
||||||
|
if !output.status.success() {
|
||||||
|
eprintln!(
|
||||||
|
"[texlive] bibtex warning: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BibTool::None => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 2: resolve references / TOC
|
||||||
|
run_texlive_pass(&engine_path, &common_args, &main_file_path, work_dir)?;
|
||||||
|
|
||||||
|
// Pass 3: stabilize citations (only if bib was used)
|
||||||
|
if !matches!(bib_tool, BibTool::None) {
|
||||||
|
run_texlive_pass(&engine_path, &common_args, &main_file_path, work_dir)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let pdf_path = work_dir.join(format!("{}.pdf", main_stem));
|
||||||
|
let xdv_path = work_dir.join(format!("{}.xdv", main_stem));
|
||||||
|
|
||||||
|
// Fallback: if xelatex produced .xdv but no .pdf (e.g. xdvipdfmx was skipped due to
|
||||||
|
// warnings), manually run xdvipdfmx to convert .xdv → .pdf.
|
||||||
|
if !pdf_path.exists() && xdv_path.exists() {
|
||||||
|
eprintln!("[texlive] .xdv exists but no .pdf — running xdvipdfmx manually");
|
||||||
|
if let Ok(xdvipdfmx) = find_texlive_binary("xdvipdfmx") {
|
||||||
|
let mut cmd = std::process::Command::new(&xdvipdfmx);
|
||||||
|
cmd.args(["-o", &pdf_path.to_string_lossy()])
|
||||||
|
.arg(&xdv_path)
|
||||||
|
.current_dir(work_dir)
|
||||||
|
.env("PATH", &env_path)
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::piped());
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||||
|
let output = cmd
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to launch xdvipdfmx: {}", e))?;
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
if !stderr.trim().is_empty() {
|
||||||
|
eprintln!("[texlive] xdvipdfmx stderr: {}", stderr.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success is determined by whether the PDF exists, not by exit codes.
|
||||||
|
// The caller (compile_latex) checks pdf_path.exists() and reads the log for errors.
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// --- SyncTeX Native Parser ---
|
// --- SyncTeX Native Parser ---
|
||||||
|
|
||||||
struct SynctexNode {
|
struct SynctexNode {
|
||||||
|
|
@ -435,11 +763,50 @@ fn parse_synctex_node(s: &str, factor: f64, x_offset: f64, y_offset: f64) -> Opt
|
||||||
|
|
||||||
// --- Tauri Commands ---
|
// --- Tauri Commands ---
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
pub struct TexliveStatus {
|
||||||
|
pub available: bool,
|
||||||
|
pub engines: Vec<String>,
|
||||||
|
pub version: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn detect_texlive() -> TexliveStatus {
|
||||||
|
let engines_to_check = ["pdflatex", "xelatex", "lualatex"];
|
||||||
|
let mut found_engines = Vec::new();
|
||||||
|
|
||||||
|
for name in &engines_to_check {
|
||||||
|
if find_texlive_binary(name).is_ok() {
|
||||||
|
found_engines.push(name.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let version = find_texlive_binary("pdflatex").ok().and_then(|path| {
|
||||||
|
let mut cmd = std::process::Command::new(&path);
|
||||||
|
cmd.arg("--version")
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::piped());
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||||
|
cmd.output().ok().and_then(|o| {
|
||||||
|
let stdout = String::from_utf8_lossy(&o.stdout);
|
||||||
|
stdout.lines().next().map(|l| l.to_string())
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
TexliveStatus {
|
||||||
|
available: !found_engines.is_empty(),
|
||||||
|
engines: found_engines,
|
||||||
|
version,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn compile_latex(
|
pub async fn compile_latex(
|
||||||
state: tauri::State<'_, LatexCompilerState>,
|
state: tauri::State<'_, LatexCompilerState>,
|
||||||
project_dir: String,
|
project_dir: String,
|
||||||
main_file: String,
|
main_file: String,
|
||||||
|
use_texlive: Option<bool>,
|
||||||
) -> Result<tauri::ipc::Response, String> {
|
) -> Result<tauri::ipc::Response, String> {
|
||||||
// Acquire semaphore permit (non-blocking)
|
// Acquire semaphore permit (non-blocking)
|
||||||
let _permit = state
|
let _permit = state
|
||||||
|
|
@ -459,6 +826,7 @@ pub async fn compile_latex(
|
||||||
let _project_guard = project_lock.lock().await;
|
let _project_guard = project_lock.lock().await;
|
||||||
|
|
||||||
let t0 = std::time::Instant::now();
|
let t0 = std::time::Instant::now();
|
||||||
|
let use_texlive = use_texlive.unwrap_or(false);
|
||||||
|
|
||||||
let main_file_name = Path::new(&main_file)
|
let main_file_name = Path::new(&main_file)
|
||||||
.file_stem()
|
.file_stem()
|
||||||
|
|
@ -489,14 +857,15 @@ pub async fn compile_latex(
|
||||||
}
|
}
|
||||||
|
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[latex] +{:.0}ms {} ({})",
|
"[latex] +{:.0}ms {} ({}, backend={})",
|
||||||
t0.elapsed().as_millis(),
|
t0.elapsed().as_millis(),
|
||||||
if is_reuse {
|
if is_reuse {
|
||||||
"sync source files"
|
"sync source files"
|
||||||
} else {
|
} else {
|
||||||
"full copy"
|
"full copy"
|
||||||
},
|
},
|
||||||
if is_reuse { "reuse" } else { "first build" }
|
if is_reuse { "reuse" } else { "first build" },
|
||||||
|
if use_texlive { "texlive" } else { "tectonic" }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Remove stale PDF so a failed compile doesn't return the previous result.
|
// Remove stale PDF so a failed compile doesn't return the previous result.
|
||||||
|
|
@ -513,9 +882,23 @@ pub async fn compile_latex(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect TeX engine from magic comment
|
// Detect TeX engine from magic comment
|
||||||
if let Ok(content) = std::fs::read_to_string(&main_tex_path) {
|
let main_tex_content = std::fs::read_to_string(&main_tex_path).unwrap_or_default();
|
||||||
if let Some(engine) = detect_tex_engine(&content) {
|
let engine = detect_tex_engine(&main_tex_content);
|
||||||
if engine == TexEngine::LuaLaTeX {
|
|
||||||
|
// Save engine name before `engine` is moved into the spawn_blocking closure
|
||||||
|
let engine_name_for_label = match &engine {
|
||||||
|
Some(TexEngine::XeLaTeX) | None => "xelatex",
|
||||||
|
Some(TexEngine::Latex) => "pdflatex",
|
||||||
|
Some(TexEngine::LuaLaTeX) => "lualatex",
|
||||||
|
};
|
||||||
|
let backend_label = if use_texlive {
|
||||||
|
format!("TeXLive/{}", engine_name_for_label)
|
||||||
|
} else {
|
||||||
|
"Tectonic".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
if !use_texlive {
|
||||||
|
if let Some(TexEngine::LuaLaTeX) = engine {
|
||||||
return Err(
|
return Err(
|
||||||
"Compilation failed\n\nThis document requires LuaLaTeX (% !TEX program = lualatex), \
|
"Compilation failed\n\nThis document requires LuaLaTeX (% !TEX program = lualatex), \
|
||||||
which is not supported. Prism uses a XeTeX-based engine (Tectonic). \
|
which is not supported. Prism uses a XeTeX-based engine (Tectonic). \
|
||||||
|
|
@ -523,32 +906,46 @@ pub async fn compile_latex(
|
||||||
.to_string(),
|
.to_string(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// XeLaTeX → native (Tectonic is XeTeX-based), pdflatex → mostly compatible
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run Tectonic in a subprocess to isolate C-level global state (font cache, etc.).
|
let compile_result = if use_texlive {
|
||||||
// This prevents the font_cache assertion failure on retry after a failed compilation.
|
|
||||||
let work_dir_clone = work_dir.clone();
|
let work_dir_clone = work_dir.clone();
|
||||||
let main_file_clone = main_file.clone();
|
let main_file_clone = main_file.clone();
|
||||||
let compile_result = tokio::task::spawn_blocking(move || {
|
let result = tokio::task::spawn_blocking(move || {
|
||||||
|
lower_thread_priority();
|
||||||
|
compile_with_texlive(&work_dir_clone, &main_file_clone, engine, &main_tex_content)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Compilation task panicked: {}", e))?;
|
||||||
|
eprintln!(
|
||||||
|
"[latex] +{:.0}ms texlive done (ok={})",
|
||||||
|
t0.elapsed().as_millis(),
|
||||||
|
result.is_ok()
|
||||||
|
);
|
||||||
|
result
|
||||||
|
} else {
|
||||||
|
// Run Tectonic in a subprocess to isolate C-level global state (font cache, etc.).
|
||||||
|
let work_dir_clone = work_dir.clone();
|
||||||
|
let main_file_clone = main_file.clone();
|
||||||
|
let result = tokio::task::spawn_blocking(move || {
|
||||||
lower_thread_priority();
|
lower_thread_priority();
|
||||||
compile_with_tectonic_subprocess(&work_dir_clone, &main_file_clone)
|
compile_with_tectonic_subprocess(&work_dir_clone, &main_file_clone)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Compilation task panicked: {}", e))?;
|
.map_err(|e| format!("Compilation task panicked: {}", e))?;
|
||||||
|
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[latex] +{:.0}ms tectonic done (ok={})",
|
"[latex] +{:.0}ms tectonic done (ok={})",
|
||||||
t0.elapsed().as_millis(),
|
t0.elapsed().as_millis(),
|
||||||
compile_result.is_ok()
|
result.is_ok()
|
||||||
);
|
);
|
||||||
|
result
|
||||||
|
};
|
||||||
|
|
||||||
let log_path = work_dir.join(format!("{}.log", main_file_name));
|
let log_path = work_dir.join(format!("{}.log", main_file_name));
|
||||||
|
|
||||||
// Handle "No pages of output" — retry with \AtEndDocument{\null} injection
|
// Handle "No pages of output" — retry with \AtEndDocument{\null} injection (Tectonic only).
|
||||||
// Skip retry if there are real errors (e.g. missing packages) — retrying won't help.
|
// TeXLive multi-pass handles this differently; the injection is Tectonic-specific.
|
||||||
if !pdf_path.exists() {
|
if !use_texlive && !pdf_path.exists() {
|
||||||
let log_path_clone = log_path.clone();
|
let log_path_clone = log_path.clone();
|
||||||
let main_tex = work_dir.join(&main_file);
|
let main_tex = work_dir.join(&main_file);
|
||||||
let pdf_path_clone = pdf_path.clone();
|
let pdf_path_clone = pdf_path.clone();
|
||||||
|
|
@ -610,9 +1007,10 @@ pub async fn compile_latex(
|
||||||
.map_err(|e| format!("PDF read task panicked: {}", e))?
|
.map_err(|e| format!("PDF read task panicked: {}", e))?
|
||||||
.map_err(|e| format!("Failed to read PDF: {}", e))?;
|
.map_err(|e| format!("Failed to read PDF: {}", e))?;
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[latex] +{:.0}ms total (reuse={}) pdf_size={}KB",
|
"[latex] +{:.0}ms total (reuse={}, backend={}) pdf_size={}KB",
|
||||||
t0.elapsed().as_millis(),
|
t0.elapsed().as_millis(),
|
||||||
is_reuse,
|
is_reuse,
|
||||||
|
backend_label,
|
||||||
pdf_bytes.len() / 1024
|
pdf_bytes.len() / 1024
|
||||||
);
|
);
|
||||||
Ok(tauri::ipc::Response::new(pdf_bytes))
|
Ok(tauri::ipc::Response::new(pdf_bytes))
|
||||||
|
|
@ -627,7 +1025,7 @@ pub async fn compile_latex(
|
||||||
} else {
|
} else {
|
||||||
details
|
details
|
||||||
};
|
};
|
||||||
Err(format!("Compilation failed\n\n{}", msg))
|
Err(format!("Compilation failed ({})\n\n{}", backend_label, msg))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -705,6 +1103,45 @@ pub async fn cleanup_all_builds(state: &LatexCompilerState) {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
// --- detect_bib_tool ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_detect_bib_tool_biber() {
|
||||||
|
let content =
|
||||||
|
"\\documentclass{article}\n\\usepackage{biblatex}\n\\begin{document}\n\\end{document}";
|
||||||
|
assert_eq!(detect_bib_tool(content), BibTool::Biber);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_detect_bib_tool_biblatex_with_options() {
|
||||||
|
let content = "\\documentclass{article}\n\\usepackage[style=apa,backend=biber]{biblatex}\n\\begin{document}";
|
||||||
|
assert_eq!(detect_bib_tool(content), BibTool::Biber);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_detect_bib_tool_bibtex() {
|
||||||
|
let content = "\\documentclass{article}\n\\bibliography{refs}\n\\end{document}";
|
||||||
|
assert_eq!(detect_bib_tool(content), BibTool::BibTeX);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_detect_bib_tool_addbibresource() {
|
||||||
|
let content = "\\documentclass{article}\n\\addbibresource{refs.bib}\n\\end{document}";
|
||||||
|
assert_eq!(detect_bib_tool(content), BibTool::BibTeX);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_detect_bib_tool_none() {
|
||||||
|
let content = "\\documentclass{article}\n\\begin{document}\nHello\n\\end{document}";
|
||||||
|
assert_eq!(detect_bib_tool(content), BibTool::None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_detect_bib_tool_commented_out() {
|
||||||
|
let content = "\\documentclass{article}\n% \\bibliography{refs}\n% \\usepackage{biblatex}\n\\end{document}";
|
||||||
|
assert_eq!(detect_bib_tool(content), BibTool::None);
|
||||||
|
}
|
||||||
|
|
||||||
// --- extract_error_lines ---
|
// --- extract_error_lines ---
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -752,8 +1189,9 @@ mod tests {
|
||||||
log.push_str(&format!("! Error number {}\n", i));
|
log.push_str(&format!("! Error number {}\n", i));
|
||||||
}
|
}
|
||||||
let result = extract_error_lines(&log);
|
let result = extract_error_lines(&log);
|
||||||
|
assert!(result.contains("---- Engine output ----"));
|
||||||
let count = result.lines().count();
|
let count = result.lines().count();
|
||||||
assert!(count <= 10);
|
assert!(count <= 120);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- persistent_build_dir ---
|
// --- persistent_build_dir ---
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
|
#![recursion_limit = "512"]
|
||||||
|
|
||||||
|
mod anthropic_proxy;
|
||||||
mod claude;
|
mod claude;
|
||||||
|
mod claude_process;
|
||||||
mod history;
|
mod history;
|
||||||
mod latex;
|
mod latex;
|
||||||
mod skills;
|
mod skills;
|
||||||
|
|
@ -8,6 +12,7 @@ mod zotero;
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
|
use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
|
||||||
|
use tauri_plugin_fs::FsExt;
|
||||||
|
|
||||||
/// Entry point for the `--tectonic-compile` subprocess mode.
|
/// Entry point for the `--tectonic-compile` subprocess mode.
|
||||||
/// Runs tectonic compilation in an isolated process so that C-level global state
|
/// Runs tectonic compilation in an isolated process so that C-level global state
|
||||||
|
|
@ -182,6 +187,7 @@ fn create_new_window(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
.title("ClaudePrism")
|
.title("ClaudePrism")
|
||||||
.inner_size(1400.0, 900.0)
|
.inner_size(1400.0, 900.0)
|
||||||
.min_inner_size(800.0, 600.0)
|
.min_inner_size(800.0, 600.0)
|
||||||
|
.zoom_hotkeys_enabled(true)
|
||||||
.visible(false);
|
.visible(false);
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
|
|
@ -198,6 +204,241 @@ fn create_new_window(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
Ok(())
|
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();
|
||||||
|
fs_scope
|
||||||
|
.allow_directory(&root_path, true)
|
||||||
|
.map_err(|e| format!("Failed to allow project directory: {}", e))?;
|
||||||
|
|
||||||
|
let asset_scope = app.state::<tauri::scope::Scopes>();
|
||||||
|
asset_scope
|
||||||
|
.allow_directory(&root_path, true)
|
||||||
|
.map_err(|e| format!("Failed to allow project assets: {}", e))?;
|
||||||
|
|
||||||
|
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) ---
|
// --- Debug logging from JS (survives white-screen crashes) ---
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|
@ -220,6 +461,7 @@ fn open_debug_window(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
.title("ClaudePrism — Debug")
|
.title("ClaudePrism — Debug")
|
||||||
.inner_size(560.0, 700.0)
|
.inner_size(560.0, 700.0)
|
||||||
.min_inner_size(400.0, 400.0)
|
.min_inner_size(400.0, 400.0)
|
||||||
|
.zoom_hotkeys_enabled(true)
|
||||||
.visible(true)
|
.visible(true)
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| format!("Failed to create debug window: {}", e))?;
|
.map_err(|e| format!("Failed to create debug window: {}", e))?;
|
||||||
|
|
@ -326,27 +568,61 @@ pub fn run() {
|
||||||
.manage(claude::ClaudeProcessState::default())
|
.manage(claude::ClaudeProcessState::default())
|
||||||
.manage(latex::LatexCompilerState::default())
|
.manage(latex::LatexCompilerState::default())
|
||||||
.manage(zotero::ZoteroOAuthState::default())
|
.manage(zotero::ZoteroOAuthState::default())
|
||||||
.setup(|_app| Ok(()))
|
.setup(|app| {
|
||||||
|
// Safety net: force-show the main window after a timeout if the
|
||||||
|
// frontend JS never calls `getCurrentWindow().show()`.
|
||||||
|
// This prevents the window from staying permanently hidden when
|
||||||
|
// WKWebView fails to execute JS (e.g. WebKit top-level-await bug
|
||||||
|
// on macOS 12). See https://bugs.webkit.org/show_bug.cgi?id=242740
|
||||||
|
let handle = app.handle().clone();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
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");
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
create_new_window,
|
create_new_window,
|
||||||
|
set_native_window_theme,
|
||||||
|
allow_project_directory,
|
||||||
|
list_default_projects,
|
||||||
detect_editors,
|
detect_editors,
|
||||||
open_in_editor,
|
open_in_editor,
|
||||||
js_log,
|
js_log,
|
||||||
read_clipboard_file_paths,
|
read_clipboard_file_paths,
|
||||||
latex::compile_latex,
|
latex::compile_latex,
|
||||||
latex::synctex_edit,
|
latex::synctex_edit,
|
||||||
|
latex::detect_texlive,
|
||||||
claude::check_claude_status,
|
claude::check_claude_status,
|
||||||
claude::install_claude_cli,
|
claude::install_claude_cli,
|
||||||
claude::login_claude,
|
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::execute_claude_code,
|
||||||
claude::continue_claude_code,
|
claude::continue_claude_code,
|
||||||
claude::resume_claude_code,
|
claude::resume_claude_code,
|
||||||
claude::cancel_claude_execution,
|
claude::cancel_claude_execution,
|
||||||
|
claude::interrupt_claude_execution,
|
||||||
claude::run_shell_command,
|
claude::run_shell_command,
|
||||||
|
claude::migrate_project_sessions,
|
||||||
claude::get_claude_fast_mode,
|
claude::get_claude_fast_mode,
|
||||||
claude::set_claude_fast_mode,
|
claude::set_claude_fast_mode,
|
||||||
claude::list_claude_sessions,
|
claude::list_claude_sessions,
|
||||||
|
claude::generate_claude_session_title,
|
||||||
claude::load_session_history,
|
claude::load_session_history,
|
||||||
|
claude::delete_claude_session,
|
||||||
zotero::zotero_start_oauth,
|
zotero::zotero_start_oauth,
|
||||||
zotero::zotero_complete_oauth,
|
zotero::zotero_complete_oauth,
|
||||||
zotero::zotero_cancel_oauth,
|
zotero::zotero_cancel_oauth,
|
||||||
|
|
@ -364,8 +640,10 @@ pub fn run() {
|
||||||
slash_commands::slash_command_delete,
|
slash_commands::slash_command_delete,
|
||||||
skills::install_scientific_skills,
|
skills::install_scientific_skills,
|
||||||
skills::install_scientific_skills_global,
|
skills::install_scientific_skills_global,
|
||||||
|
skills::import_skill_from_folder,
|
||||||
skills::check_skills_installed,
|
skills::check_skills_installed,
|
||||||
skills::list_installed_skills,
|
skills::list_installed_skills,
|
||||||
|
skills::delete_installed_skill,
|
||||||
skills::uninstall_scientific_skills,
|
skills::uninstall_scientific_skills,
|
||||||
skills::get_skill_categories,
|
skills::get_skill_categories,
|
||||||
skills::get_skill_content,
|
skills::get_skill_content,
|
||||||
|
|
@ -419,7 +697,7 @@ pub fn run() {
|
||||||
let _ = window.eval(
|
let _ = window.eval(
|
||||||
"document.body.style.display='none';\
|
"document.body.style.display='none';\
|
||||||
document.body.offsetHeight;\
|
document.body.offsetHeight;\
|
||||||
document.body.style.display='';"
|
document.body.style.display='';",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let _ = window.emit("window-focus-restored", ());
|
let _ = window.emit("window-focus-restored", ());
|
||||||
|
|
|
||||||
|
|
@ -200,23 +200,16 @@ fn load_skills_from_dir(dir: &Path, scope: &str) -> Vec<SlashCommand> {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
let entries = match fs::read_dir(dir) {
|
let mut skill_dirs = Vec::new();
|
||||||
Ok(e) => e,
|
collect_skill_dirs(dir, &mut skill_dirs);
|
||||||
Err(_) => return Vec::new(),
|
skill_dirs.sort();
|
||||||
};
|
|
||||||
|
|
||||||
let mut skills = Vec::new();
|
let mut skills = Vec::new();
|
||||||
|
let mut seen_ids = std::collections::HashSet::new();
|
||||||
|
|
||||||
for entry in entries.flatten() {
|
for path in skill_dirs {
|
||||||
let path = entry.path();
|
let Some(skill_md) = find_skill_md(&path) else {
|
||||||
if !path.is_dir() {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
};
|
||||||
|
|
||||||
let skill_md = path.join("SKILL.md");
|
|
||||||
if !skill_md.exists() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let content = match fs::read_to_string(&skill_md) {
|
let content = match fs::read_to_string(&skill_md) {
|
||||||
Ok(c) => c,
|
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());
|
.map(|d| d.chars().take(200).collect());
|
||||||
|
|
||||||
let id = format!("skill-{}", folder_name);
|
let id = format!("skill-{}", folder_name);
|
||||||
|
if !seen_ids.insert(id.clone()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
skills.push(SlashCommand {
|
skills.push(SlashCommand {
|
||||||
id,
|
id,
|
||||||
|
|
@ -269,6 +265,51 @@ fn load_skills_from_dir(dir: &Path, scope: &str) -> Vec<SlashCommand> {
|
||||||
skills
|
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> {
|
fn create_default_commands() -> Vec<SlashCommand> {
|
||||||
vec![
|
vec![
|
||||||
SlashCommand {
|
SlashCommand {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use tauri::{Emitter, WebviewWindow};
|
use tauri::{Emitter, WebviewWindow};
|
||||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
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 {
|
fn path_with_venv(venv_dir: &std::path::Path) -> String {
|
||||||
let bin = venv_bin_dir(venv_dir);
|
let bin = venv_bin_dir(venv_dir);
|
||||||
let current = std::env::var("PATH").unwrap_or_default();
|
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)
|
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 Commands ───
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|
@ -143,7 +232,6 @@ pub async fn check_uv_status() -> Result<UvStatus, String> {
|
||||||
version_cmd.arg("--version");
|
version_cmd.arg("--version");
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
|
|
||||||
version_cmd.creation_flags(CREATE_NO_WINDOW);
|
version_cmd.creation_flags(CREATE_NO_WINDOW);
|
||||||
}
|
}
|
||||||
let version_output = version_cmd.output();
|
let version_output = version_cmd.output();
|
||||||
|
|
@ -214,7 +302,6 @@ pub async fn install_uv(window: WebviewWindow) -> Result<(), String> {
|
||||||
};
|
};
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
let mut cmd = {
|
let mut cmd = {
|
||||||
|
|
||||||
let mut c = tokio::process::Command::new("powershell");
|
let mut c = tokio::process::Command::new("powershell");
|
||||||
c.creation_flags(CREATE_NO_WINDOW);
|
c.creation_flags(CREATE_NO_WINDOW);
|
||||||
c.args([
|
c.args([
|
||||||
|
|
@ -230,38 +317,13 @@ pub async fn install_uv(window: WebviewWindow) -> Result<(), String> {
|
||||||
cmd.stdout(std::process::Stdio::piped());
|
cmd.stdout(std::process::Stdio::piped());
|
||||||
cmd.stderr(std::process::Stdio::piped());
|
cmd.stderr(std::process::Stdio::piped());
|
||||||
|
|
||||||
// Inherit essential environment variables
|
// Inherit essential environment variables (shared helper handles case-insensitive matching)
|
||||||
for (key, value) in std::env::vars() {
|
for (key, value) in std::env::vars() {
|
||||||
if key == "PATH"
|
if key.eq_ignore_ascii_case("PATH") || crate::claude::is_essential_env_var(&key) {
|
||||||
|| key == "HOME"
|
|
||||||
|| key == "USER"
|
|
||||||
|| key == "SHELL"
|
|
||||||
|| key == "LANG"
|
|
||||||
|| key.starts_with("LC_")
|
|
||||||
|| key == "HOMEBREW_PREFIX"
|
|
||||||
|| key == "HOMEBREW_CELLAR"
|
|
||||||
|| key == "HTTP_PROXY"
|
|
||||||
|| key == "HTTPS_PROXY"
|
|
||||||
|| key == "NO_PROXY"
|
|
||||||
|| key == "ALL_PROXY"
|
|
||||||
// Windows-essential variables
|
|
||||||
|| key == "USERPROFILE"
|
|
||||||
|| key == "LOCALAPPDATA"
|
|
||||||
|| key == "APPDATA"
|
|
||||||
|| key == "TEMP"
|
|
||||||
|| key == "TMP"
|
|
||||||
|| key == "SystemRoot"
|
|
||||||
|| key == "WINDIR"
|
|
||||||
|| key == "PROGRAMFILES"
|
|
||||||
|| key == "PROGRAMFILES(X86)"
|
|
||||||
|| key == "COMMONPROGRAMFILES"
|
|
||||||
|| key == "SystemDrive"
|
|
||||||
|| key == "COMPUTERNAME"
|
|
||||||
|| key == "USERNAME"
|
|
||||||
{
|
|
||||||
cmd.env(&key, &value);
|
cmd.env(&key, &value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
crate::claude::apply_proxy_env_to_command(&mut cmd, Some(&window));
|
||||||
|
|
||||||
let mut child = cmd
|
let mut child = cmd
|
||||||
.spawn()
|
.spawn()
|
||||||
|
|
@ -315,6 +377,7 @@ pub async fn setup_project_venv(project_path: String) -> Result<VenvInfo, String
|
||||||
|
|
||||||
// If venv already exists, just return info
|
// If venv already exists, just return info
|
||||||
if venv_dir.exists() {
|
if venv_dir.exists() {
|
||||||
|
ensure_venv_pip(&venv_dir).await?;
|
||||||
let python = venv_python(&venv_dir);
|
let python = venv_python(&venv_dir);
|
||||||
return Ok(VenvInfo {
|
return Ok(VenvInfo {
|
||||||
venv_path: venv_dir.to_string_lossy().to_string(),
|
venv_path: venv_dir.to_string_lossy().to_string(),
|
||||||
|
|
@ -327,11 +390,11 @@ pub async fn setup_project_venv(project_path: String) -> Result<VenvInfo, String
|
||||||
|
|
||||||
// Create venv: uv venv <project_path>/.venv
|
// Create venv: uv venv <project_path>/.venv
|
||||||
let mut venv_cmd = tokio::process::Command::new(&uv_bin);
|
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);
|
venv_cmd.current_dir(project);
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
|
|
||||||
venv_cmd.creation_flags(CREATE_NO_WINDOW);
|
venv_cmd.creation_flags(CREATE_NO_WINDOW);
|
||||||
}
|
}
|
||||||
let output = venv_cmd
|
let output = venv_cmd
|
||||||
|
|
@ -345,6 +408,7 @@ pub async fn setup_project_venv(project_path: String) -> Result<VenvInfo, String
|
||||||
}
|
}
|
||||||
|
|
||||||
let python = venv_python(&venv_dir);
|
let python = venv_python(&venv_dir);
|
||||||
|
ensure_venv_pip(&venv_dir).await?;
|
||||||
|
|
||||||
Ok(VenvInfo {
|
Ok(VenvInfo {
|
||||||
venv_path: venv_dir.to_string_lossy().to_string(),
|
venv_path: venv_dir.to_string_lossy().to_string(),
|
||||||
|
|
@ -372,10 +436,11 @@ pub async fn uv_add_packages(
|
||||||
pip_cmd.args(&args);
|
pip_cmd.args(&args);
|
||||||
pip_cmd.current_dir(&project_path);
|
pip_cmd.current_dir(&project_path);
|
||||||
pip_cmd.env("VIRTUAL_ENV", &venv_dir);
|
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));
|
pip_cmd.env("PATH", path_with_venv(&venv_dir));
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
|
|
||||||
pip_cmd.creation_flags(CREATE_NO_WINDOW);
|
pip_cmd.creation_flags(CREATE_NO_WINDOW);
|
||||||
}
|
}
|
||||||
let output = pip_cmd
|
let output = pip_cmd
|
||||||
|
|
@ -416,10 +481,12 @@ pub async fn uv_run_command(
|
||||||
run_cmd.args(args);
|
run_cmd.args(args);
|
||||||
run_cmd.current_dir(&project_path);
|
run_cmd.current_dir(&project_path);
|
||||||
run_cmd.env("VIRTUAL_ENV", &venv_dir);
|
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));
|
run_cmd.env("PATH", path_with_venv(&venv_dir));
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
|
|
||||||
run_cmd.creation_flags(CREATE_NO_WINDOW);
|
run_cmd.creation_flags(CREATE_NO_WINDOW);
|
||||||
}
|
}
|
||||||
let output = run_cmd
|
let output = run_cmd
|
||||||
|
|
|
||||||
50
apps/desktop/src-tauri/symbols.map
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
/* Version script to hide statically linked library symbols.
|
||||||
|
*
|
||||||
|
* Prevents symbol collisions between our statically linked
|
||||||
|
* ICU/HarfBuzz/FreeType/Fontconfig (from vcpkg, used by Tectonic)
|
||||||
|
* and the dynamically linked versions used by WebKit2GTK at runtime.
|
||||||
|
*
|
||||||
|
* Without this, the ELF dynamic linker resolves WebKit2GTK's ICU
|
||||||
|
* calls to our static symbols (different version), causing a segfault
|
||||||
|
* at startup. Fixes #100; preserves #91 fix (static ICU = no
|
||||||
|
* libicuuc.so.70 not-found error on newer distros).
|
||||||
|
*/
|
||||||
|
{
|
||||||
|
local:
|
||||||
|
/* ICU4C */
|
||||||
|
u_*;
|
||||||
|
ucnv_*;
|
||||||
|
ucol_*;
|
||||||
|
ures_*;
|
||||||
|
udata_*;
|
||||||
|
ucal_*;
|
||||||
|
udat_*;
|
||||||
|
unum_*;
|
||||||
|
uset_*;
|
||||||
|
ustr_*;
|
||||||
|
ubidi_*;
|
||||||
|
ubrk_*;
|
||||||
|
uidna_*;
|
||||||
|
uloc_*;
|
||||||
|
unorm_*;
|
||||||
|
unorm2_*;
|
||||||
|
utrans_*;
|
||||||
|
icu_*;
|
||||||
|
UCNV_*;
|
||||||
|
|
||||||
|
/* HarfBuzz */
|
||||||
|
hb_*;
|
||||||
|
|
||||||
|
/* FreeType */
|
||||||
|
FT_*;
|
||||||
|
ft_*;
|
||||||
|
|
||||||
|
/* Fontconfig */
|
||||||
|
Fc*;
|
||||||
|
|
||||||
|
/* Graphite2 */
|
||||||
|
gr_*;
|
||||||
|
|
||||||
|
/* libpng (often pulled in by FreeType) */
|
||||||
|
png_*;
|
||||||
|
};
|
||||||
|
|
@ -2,10 +2,10 @@
|
||||||
"$schema": "https://raw.githubusercontent.com/nicegui-unofficial/nicegui-tauri-template/main/src-tauri/tauri.conf-v2-schema.json",
|
"$schema": "https://raw.githubusercontent.com/nicegui-unofficial/nicegui-tauri-template/main/src-tauri/tauri.conf-v2-schema.json",
|
||||||
"identifier": "com.claude-prism.desktop",
|
"identifier": "com.claude-prism.desktop",
|
||||||
"productName": "ClaudePrism",
|
"productName": "ClaudePrism",
|
||||||
"version": "1.0.7",
|
"version": "1.3.0",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm dev",
|
"beforeDevCommand": "corepack pnpm dev",
|
||||||
"beforeBuildCommand": "pnpm build",
|
"beforeBuildCommand": "corepack pnpm build",
|
||||||
"devUrl": "http://localhost:1420",
|
"devUrl": "http://localhost:1420",
|
||||||
"frontendDist": "../dist"
|
"frontendDist": "../dist"
|
||||||
},
|
},
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
"visible": false,
|
"visible": false,
|
||||||
"titleBarStyle": "Overlay",
|
"titleBarStyle": "Overlay",
|
||||||
"hiddenTitle": true,
|
"hiddenTitle": true,
|
||||||
|
"zoomHotkeysEnabled": true,
|
||||||
"trafficLightPosition": {
|
"trafficLightPosition": {
|
||||||
"x": 12,
|
"x": 12,
|
||||||
"y": 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 { ErrorBoundary } from "react-error-boundary";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||||
|
|
@ -7,23 +7,79 @@ import { useDocumentStore } from "@/stores/document-store";
|
||||||
import { useClaudeChatStore } from "@/stores/claude-chat-store";
|
import { useClaudeChatStore } from "@/stores/claude-chat-store";
|
||||||
import { ProjectPicker } from "@/components/project-picker";
|
import { ProjectPicker } from "@/components/project-picker";
|
||||||
import { WorkspaceLayout } from "@/components/workspace/workspace-layout";
|
import { WorkspaceLayout } from "@/components/workspace/workspace-layout";
|
||||||
import { 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 { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import {
|
|
||||||
ScientificSkillsOnboarding,
|
|
||||||
shouldShowOnboarding,
|
|
||||||
} from "@/components/scientific-skills/scientific-skills-onboarding";
|
|
||||||
import { useUvSetupStore } from "@/stores/uv-setup-store";
|
import { useUvSetupStore } from "@/stores/uv-setup-store";
|
||||||
import { ErrorFallback } from "@/components/error-fallback";
|
import { ErrorFallback } from "@/components/error-fallback";
|
||||||
import { createLogger } from "@/lib/debug/logger";
|
import { createLogger } from "@/lib/debug/logger";
|
||||||
|
import { EnvironmentOnboarding } from "@/components/environment-onboarding";
|
||||||
|
|
||||||
const log = createLogger("app");
|
const log = createLogger("app");
|
||||||
|
|
||||||
|
const LazyDebugPage = lazy(() =>
|
||||||
|
import("@/components/debug/debug-page").then((m) => ({
|
||||||
|
default: m.DebugPage,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
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() {
|
function WorkspaceWithClaude() {
|
||||||
const projectRoot = useDocumentStore((s) => s.projectRoot);
|
const projectRoot = useDocumentStore((s) => s.projectRoot);
|
||||||
const initialized = useDocumentStore((s) => s.initialized);
|
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
|
// Update window title
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -33,15 +89,11 @@ function WorkspaceWithClaude() {
|
||||||
}
|
}
|
||||||
}, [projectRoot]);
|
}, [projectRoot]);
|
||||||
|
|
||||||
// Show scientific skills onboarding on first launch
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initialized) return;
|
if (chatProjectRef.current === projectRoot) return;
|
||||||
if (shouldShowOnboarding()) {
|
chatProjectRef.current = projectRoot;
|
||||||
// Small delay so the workspace renders first
|
useClaudeChatStore.getState().resetForProject(projectRoot ?? null);
|
||||||
const timer = setTimeout(() => setShowSkillsOnboarding(true), 800);
|
}, [projectRoot]);
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}, [initialized]);
|
|
||||||
|
|
||||||
// Auto-setup Python venv when project opens
|
// Auto-setup Python venv when project opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -60,6 +112,59 @@ function WorkspaceWithClaude() {
|
||||||
});
|
});
|
||||||
}, [initialized, projectRoot]);
|
}, [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
|
// Consume pending initial prompt from project wizard
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initialized) return;
|
if (!initialized) return;
|
||||||
|
|
@ -75,38 +180,87 @@ function WorkspaceWithClaude() {
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [initialized]);
|
}, [initialized]);
|
||||||
|
|
||||||
return (
|
return <WorkspaceLayout />;
|
||||||
<>
|
|
||||||
<WorkspaceLayout />
|
|
||||||
{showSkillsOnboarding && (
|
|
||||||
<ScientificSkillsOnboarding
|
|
||||||
onClose={() => setShowSkillsOnboarding(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function App({ onReady }: { onReady?: () => void }) {
|
export function App({ onReady }: { onReady?: () => void }) {
|
||||||
const projectRoot = useDocumentStore((s) => s.projectRoot);
|
const projectRoot = useDocumentStore((s) => s.projectRoot);
|
||||||
|
const [showDebug, setShowDebug] = useState(false);
|
||||||
|
|
||||||
// Register global keyboard shortcuts (Cmd+S, Cmd+N) at the app level
|
// Register global keyboard shortcuts (Cmd+S, Cmd+N) at the app level
|
||||||
useKeyboardShortcuts();
|
useKeyboardShortcuts();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const preventNativeContextMenu = (event: MouseEvent) => {
|
||||||
|
if (event.defaultPrevented) return;
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("contextmenu", preventNativeContextMenu);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("contextmenu", preventNativeContextMenu);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onReady?.();
|
onReady?.();
|
||||||
}, [onReady]);
|
}, [onReady]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!projectRoot) {
|
||||||
|
getCurrentWindow().setTitle("ClaudePrism");
|
||||||
|
}
|
||||||
|
}, [projectRoot]);
|
||||||
|
|
||||||
|
// Listen for debug panel toggle (Ctrl+Shift+D)
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => setShowDebug((prev) => !prev);
|
||||||
|
window.addEventListener("toggle-debug-panel", handler);
|
||||||
|
return () => window.removeEventListener("toggle-debug-panel", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
||||||
<ThemeProvider attribute="class" defaultTheme="light" enableSystem>
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
|
<NativeWindowThemeBridge />
|
||||||
{/* Global macOS titlebar drag region — sits above all content */}
|
{/* Global macOS titlebar drag region — sits above all content */}
|
||||||
<div
|
<div
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
className="fixed inset-x-0 top-0 z-[9999] h-[var(--titlebar-height)]"
|
className="fixed inset-x-0 top-0 z-[9999] h-[var(--titlebar-height)]"
|
||||||
/>
|
/>
|
||||||
{projectRoot ? <WorkspaceWithClaude /> : <ProjectPicker />}
|
{projectRoot ? <WorkspaceWithClaude /> : <ProjectPicker />}
|
||||||
|
<EnvironmentOnboarding />
|
||||||
|
{showDebug && (
|
||||||
|
<div className="fixed inset-0 z-[9998] flex items-end justify-center">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/20"
|
||||||
|
onClick={() => setShowDebug(false)}
|
||||||
|
/>
|
||||||
|
<div className="relative h-[60vh] w-full border-border border-t bg-background shadow-lg">
|
||||||
|
<div className="flex h-8 items-center justify-between border-border border-b bg-muted/50 px-3">
|
||||||
|
<span className="font-medium text-xs">Debug Panel</span>
|
||||||
|
<button
|
||||||
|
className="text-muted-foreground text-xs hover:text-foreground"
|
||||||
|
onClick={() => setShowDebug(false)}
|
||||||
|
>
|
||||||
|
Close (Ctrl+Shift+D)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="h-[calc(60vh-2rem)] overflow-auto">
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="p-4 text-muted-foreground text-sm">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LazyDebugPage />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|
|
||||||
184
apps/desktop/src/__tests__/lib/app-zoom.test.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
||||||
|
import {
|
||||||
|
APP_ZOOM_STORAGE_KEY,
|
||||||
|
DEFAULT_APP_ZOOM,
|
||||||
|
LOCAL_ZOOM_SHORTCUTS_ATTR,
|
||||||
|
MAX_APP_ZOOM,
|
||||||
|
MIN_APP_ZOOM,
|
||||||
|
clampAppZoom,
|
||||||
|
getAppZoomAction,
|
||||||
|
initializeAppZoom,
|
||||||
|
persistAppZoom,
|
||||||
|
readStoredAppZoom,
|
||||||
|
resetAppZoom,
|
||||||
|
shouldHandleAppZoomShortcut,
|
||||||
|
zoomInApp,
|
||||||
|
zoomOutApp,
|
||||||
|
} from "@/lib/app-zoom";
|
||||||
|
|
||||||
|
describe("app zoom", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the default zoom when storage is empty or invalid", () => {
|
||||||
|
expect(readStoredAppZoom()).toBe(DEFAULT_APP_ZOOM);
|
||||||
|
|
||||||
|
localStorage.setItem(APP_ZOOM_STORAGE_KEY, "not-a-number");
|
||||||
|
expect(readStoredAppZoom()).toBe(DEFAULT_APP_ZOOM);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps stored zoom values into the supported range", () => {
|
||||||
|
localStorage.setItem(APP_ZOOM_STORAGE_KEY, "999");
|
||||||
|
expect(readStoredAppZoom()).toBe(MAX_APP_ZOOM);
|
||||||
|
|
||||||
|
localStorage.setItem(APP_ZOOM_STORAGE_KEY, "0.1");
|
||||||
|
expect(readStoredAppZoom()).toBe(MIN_APP_ZOOM);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies persisted zoom through the webview API", async () => {
|
||||||
|
const webview = getCurrentWebview();
|
||||||
|
|
||||||
|
await persistAppZoom(1.25);
|
||||||
|
|
||||||
|
expect(webview.setZoom).toHaveBeenCalledWith(1.25);
|
||||||
|
expect(localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe("1.25");
|
||||||
|
});
|
||||||
|
|
||||||
|
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(DEFAULT_APP_ZOOM);
|
||||||
|
expect(localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("zooms in, zooms out, and resets around the stored value", async () => {
|
||||||
|
const webview = getCurrentWebview();
|
||||||
|
localStorage.setItem(APP_ZOOM_STORAGE_KEY, "1.2");
|
||||||
|
|
||||||
|
await zoomInApp();
|
||||||
|
expect(webview.setZoom).toHaveBeenLastCalledWith(1.3);
|
||||||
|
expect(localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe("1.3");
|
||||||
|
|
||||||
|
await zoomOutApp();
|
||||||
|
expect(webview.setZoom).toHaveBeenLastCalledWith(1.2);
|
||||||
|
expect(localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe("1.2");
|
||||||
|
|
||||||
|
await resetAppZoom();
|
||||||
|
expect(webview.setZoom).toHaveBeenLastCalledWith(DEFAULT_APP_ZOOM);
|
||||||
|
expect(localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe(
|
||||||
|
DEFAULT_APP_ZOOM.toString(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rounds and clamps zoom values consistently", () => {
|
||||||
|
expect(clampAppZoom(1.234)).toBe(1.23);
|
||||||
|
expect(clampAppZoom(10)).toBe(MAX_APP_ZOOM);
|
||||||
|
expect(clampAppZoom(0.01)).toBe(MIN_APP_ZOOM);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAppZoomAction", () => {
|
||||||
|
it("detects zoom-in shortcuts", () => {
|
||||||
|
expect(
|
||||||
|
getAppZoomAction({
|
||||||
|
metaKey: true,
|
||||||
|
ctrlKey: false,
|
||||||
|
altKey: false,
|
||||||
|
key: "+",
|
||||||
|
code: "Equal",
|
||||||
|
}),
|
||||||
|
).toBe("in");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getAppZoomAction({
|
||||||
|
metaKey: false,
|
||||||
|
ctrlKey: true,
|
||||||
|
altKey: false,
|
||||||
|
key: "=",
|
||||||
|
code: "Equal",
|
||||||
|
}),
|
||||||
|
).toBe("in");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects zoom-out shortcuts", () => {
|
||||||
|
expect(
|
||||||
|
getAppZoomAction({
|
||||||
|
metaKey: true,
|
||||||
|
ctrlKey: false,
|
||||||
|
altKey: false,
|
||||||
|
key: "-",
|
||||||
|
code: "Minus",
|
||||||
|
}),
|
||||||
|
).toBe("out");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getAppZoomAction({
|
||||||
|
metaKey: false,
|
||||||
|
ctrlKey: true,
|
||||||
|
altKey: false,
|
||||||
|
key: "_",
|
||||||
|
code: "Minus",
|
||||||
|
}),
|
||||||
|
).toBe("out");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects zoom reset shortcuts", () => {
|
||||||
|
expect(
|
||||||
|
getAppZoomAction({
|
||||||
|
metaKey: true,
|
||||||
|
ctrlKey: false,
|
||||||
|
altKey: false,
|
||||||
|
key: "0",
|
||||||
|
code: "Digit0",
|
||||||
|
}),
|
||||||
|
).toBe("reset");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores unrelated shortcuts", () => {
|
||||||
|
expect(
|
||||||
|
getAppZoomAction({
|
||||||
|
metaKey: false,
|
||||||
|
ctrlKey: false,
|
||||||
|
altKey: false,
|
||||||
|
key: "+",
|
||||||
|
code: "Equal",
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getAppZoomAction({
|
||||||
|
metaKey: true,
|
||||||
|
ctrlKey: false,
|
||||||
|
altKey: true,
|
||||||
|
key: "+",
|
||||||
|
code: "Equal",
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shouldHandleAppZoomShortcut", () => {
|
||||||
|
it("handles global zoom when the target is outside a local zoom surface", () => {
|
||||||
|
const target = document.createElement("div");
|
||||||
|
expect(shouldHandleAppZoomShortcut(target)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips global zoom inside local zoom surfaces", () => {
|
||||||
|
const wrapper = document.createElement("div");
|
||||||
|
wrapper.setAttribute(LOCAL_ZOOM_SHORTCUTS_ATTR, "true");
|
||||||
|
const target = document.createElement("button");
|
||||||
|
wrapper.appendChild(target);
|
||||||
|
|
||||||
|
expect(shouldHandleAppZoomShortcut(target)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to handling zoom for non-element targets", () => {
|
||||||
|
expect(shouldHandleAppZoomShortcut(null)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,173 +1,109 @@
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { readDir, stat } from "@tauri-apps/plugin-fs";
|
||||||
|
import {
|
||||||
|
getProjectFileType,
|
||||||
|
scanProjectFolder,
|
||||||
|
shouldSkipProjectDirectory,
|
||||||
|
} from "@/lib/tauri/fs";
|
||||||
|
|
||||||
// getFileType is not exported, so we test via the module's behavior.
|
describe("tauri fs helpers", () => {
|
||||||
// We need to import from the source and test the classification logic.
|
beforeEach(() => {
|
||||||
// Since getFileType is private, we'll extract the logic into a testable pattern.
|
vi.clearAllMocks();
|
||||||
// For now, test the exported types and the classification indirectly.
|
});
|
||||||
|
|
||||||
// We can test the file type classification logic by reimplementing the same
|
describe("getProjectFileType", () => {
|
||||||
// pattern as the source and verifying consistency, or we test via scanProjectFolder.
|
it("classifies editable project files", () => {
|
||||||
// Since scanProjectFolder requires Tauri filesystem mocks with complex async behavior,
|
expect(getProjectFileType("main.tex")).toBe("tex");
|
||||||
// let's test the pure classification logic directly by accessing the private function
|
expect(getProjectFileType("chapter.TEX")).toBe("tex");
|
||||||
// via a small wrapper test.
|
expect(getProjectFileType("refs.bib")).toBe("bib");
|
||||||
|
expect(getProjectFileType("output.pdf")).toBe("pdf");
|
||||||
|
expect(getProjectFileType("figure.png")).toBe("image");
|
||||||
|
expect(getProjectFileType("custom.sty")).toBe("style");
|
||||||
|
expect(getProjectFileType("notes.md")).toBe("other");
|
||||||
|
expect(getProjectFileType("script.py")).toBe("other");
|
||||||
|
});
|
||||||
|
|
||||||
// Actually, the simplest approach: the getFileType function is module-private.
|
it("ignores LaTeX and compiled build artifacts", () => {
|
||||||
// We'll test it by examining the constants and logic as documented.
|
expect(getProjectFileType("main.aux")).toBeNull();
|
||||||
|
expect(getProjectFileType("main.synctex.gz")).toBeNull();
|
||||||
|
expect(getProjectFileType("module.pyc")).toBeNull();
|
||||||
|
expect(getProjectFileType("native.pyd")).toBeNull();
|
||||||
|
expect(getProjectFileType("libnative.so")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
describe("getFileType logic", () => {
|
it("keeps imported files with arbitrary extensions visible", () => {
|
||||||
// Replicate the classification logic for testing
|
expect(getProjectFileType("archive.zip")).toBe("other");
|
||||||
const IMAGE_EXTENSIONS = new Set([
|
expect(getProjectFileType("paper.docx")).toBe("other");
|
||||||
".png",
|
expect(getProjectFileType("data.xlsx")).toBe("other");
|
||||||
".jpg",
|
expect(getProjectFileType("movie.mp4")).toBe("other");
|
||||||
".jpeg",
|
});
|
||||||
".gif",
|
});
|
||||||
".svg",
|
|
||||||
".bmp",
|
describe("shouldSkipProjectDirectory", () => {
|
||||||
".webp",
|
it("skips hidden and generated dependency directories", () => {
|
||||||
|
expect(shouldSkipProjectDirectory(".git")).toBe(true);
|
||||||
|
expect(shouldSkipProjectDirectory(".venv")).toBe(true);
|
||||||
|
expect(shouldSkipProjectDirectory("node_modules")).toBe(true);
|
||||||
|
expect(shouldSkipProjectDirectory("__pycache__")).toBe(true);
|
||||||
|
expect(shouldSkipProjectDirectory("venv")).toBe(true);
|
||||||
|
expect(shouldSkipProjectDirectory("ENV")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps normal project folders visible", () => {
|
||||||
|
expect(shouldSkipProjectDirectory("chapters")).toBe(false);
|
||||||
|
expect(shouldSkipProjectDirectory("figures")).toBe(false);
|
||||||
|
expect(shouldSkipProjectDirectory("attachments")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("scanProjectFolder", () => {
|
||||||
|
it("does not recurse into generated cache directories", async () => {
|
||||||
|
vi.mocked(readDir).mockImplementation(async (dir: string | URL) => {
|
||||||
|
const dirPath = String(dir);
|
||||||
|
if (dirPath === "/project") {
|
||||||
|
return [
|
||||||
|
{ name: "__pycache__", isDirectory: true },
|
||||||
|
{ name: "node_modules", isDirectory: true },
|
||||||
|
{ name: "main.tex", isDirectory: false },
|
||||||
|
{ name: "chapters", isDirectory: true },
|
||||||
|
] as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dirPath === "/project/chapters") {
|
||||||
|
return [{ name: "intro.tex", isDirectory: false }] as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unexpected readDir path: ${dirPath}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await scanProjectFolder("/project");
|
||||||
|
|
||||||
|
expect(readDir).toHaveBeenCalledWith("/project");
|
||||||
|
expect(readDir).toHaveBeenCalledWith("/project/chapters");
|
||||||
|
expect(readDir).not.toHaveBeenCalledWith("/project/__pycache__");
|
||||||
|
expect(readDir).not.toHaveBeenCalledWith("/project/node_modules");
|
||||||
|
expect(result.folders).toEqual(["chapters"]);
|
||||||
|
expect(result.files.map((file) => file.relativePath)).toEqual([
|
||||||
|
"main.tex",
|
||||||
|
"chapters/intro.tex",
|
||||||
]);
|
]);
|
||||||
const STYLE_EXTENSIONS = new Set([
|
});
|
||||||
".sty",
|
|
||||||
".cls",
|
it("keeps arbitrary file formats visible as other files", async () => {
|
||||||
".bst",
|
vi.mocked(readDir).mockResolvedValue([
|
||||||
".def",
|
{ name: "worker.py", isDirectory: false },
|
||||||
".cfg",
|
{ name: "notes.txt", isDirectory: false },
|
||||||
".fd",
|
] as any);
|
||||||
".dtx",
|
vi.mocked(stat).mockResolvedValue({ size: 128 } as any);
|
||||||
".ins",
|
|
||||||
|
const result = await scanProjectFolder("/project");
|
||||||
|
|
||||||
|
expect(result.files.map((file) => file.relativePath)).toEqual([
|
||||||
|
"worker.py",
|
||||||
|
"notes.txt",
|
||||||
]);
|
]);
|
||||||
const IGNORED_EXTENSIONS = new Set([
|
expect(stat).toHaveBeenCalledTimes(2);
|
||||||
".aux",
|
expect(result.files.every((file) => file.type === "other")).toBe(true);
|
||||||
".log",
|
});
|
||||||
".out",
|
|
||||||
".toc",
|
|
||||||
".lof",
|
|
||||||
".lot",
|
|
||||||
".fls",
|
|
||||||
".fdb_latexmk",
|
|
||||||
".synctex.gz",
|
|
||||||
".synctex",
|
|
||||||
".blg",
|
|
||||||
".bbl",
|
|
||||||
".nav",
|
|
||||||
".snm",
|
|
||||||
".vrb",
|
|
||||||
".run.xml",
|
|
||||||
".bcf",
|
|
||||||
// Binary / non-text files
|
|
||||||
".hwp",
|
|
||||||
".hwpx",
|
|
||||||
".doc",
|
|
||||||
".docx",
|
|
||||||
".xls",
|
|
||||||
".xlsx",
|
|
||||||
".xlsm",
|
|
||||||
".ppt",
|
|
||||||
".pptx",
|
|
||||||
".accdb",
|
|
||||||
".mdb",
|
|
||||||
".zip",
|
|
||||||
".rar",
|
|
||||||
".7z",
|
|
||||||
".tar",
|
|
||||||
".gz",
|
|
||||||
".exe",
|
|
||||||
".dll",
|
|
||||||
".so",
|
|
||||||
".dylib",
|
|
||||||
".o",
|
|
||||||
".obj",
|
|
||||||
".bin",
|
|
||||||
".dat",
|
|
||||||
".iso",
|
|
||||||
".dmg",
|
|
||||||
".msi",
|
|
||||||
".mp3",
|
|
||||||
".mp4",
|
|
||||||
".avi",
|
|
||||||
".mov",
|
|
||||||
".mkv",
|
|
||||||
".wav",
|
|
||||||
".flac",
|
|
||||||
".psd",
|
|
||||||
".ai",
|
|
||||||
".sketch",
|
|
||||||
".fig",
|
|
||||||
".sqlite",
|
|
||||||
".db",
|
|
||||||
]);
|
|
||||||
|
|
||||||
function getFileType(name: string): string | null {
|
|
||||||
const lower = name.toLowerCase();
|
|
||||||
for (const ext of IGNORED_EXTENSIONS) {
|
|
||||||
if (lower.endsWith(ext)) return null;
|
|
||||||
}
|
|
||||||
if (lower.endsWith(".tex") || lower.endsWith(".ltx")) return "tex";
|
|
||||||
if (lower.endsWith(".bib")) return "bib";
|
|
||||||
if (lower.endsWith(".pdf")) return "pdf";
|
|
||||||
for (const ext of IMAGE_EXTENSIONS) {
|
|
||||||
if (lower.endsWith(ext)) return "image";
|
|
||||||
}
|
|
||||||
for (const ext of STYLE_EXTENSIONS) {
|
|
||||||
if (lower.endsWith(ext)) return "style";
|
|
||||||
}
|
|
||||||
return "other";
|
|
||||||
}
|
|
||||||
|
|
||||||
it("classifies .tex files", () => {
|
|
||||||
expect(getFileType("main.tex")).toBe("tex");
|
|
||||||
expect(getFileType("chapter.TEX")).toBe("tex");
|
|
||||||
expect(getFileType("doc.ltx")).toBe("tex");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("classifies .bib files", () => {
|
|
||||||
expect(getFileType("refs.bib")).toBe("bib");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("classifies .pdf files", () => {
|
|
||||||
expect(getFileType("output.pdf")).toBe("pdf");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("classifies image files", () => {
|
|
||||||
expect(getFileType("fig.png")).toBe("image");
|
|
||||||
expect(getFileType("photo.jpg")).toBe("image");
|
|
||||||
expect(getFileType("icon.svg")).toBe("image");
|
|
||||||
expect(getFileType("anim.gif")).toBe("image");
|
|
||||||
expect(getFileType("pic.webp")).toBe("image");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("classifies style files", () => {
|
|
||||||
expect(getFileType("custom.sty")).toBe("style");
|
|
||||||
expect(getFileType("report.cls")).toBe("style");
|
|
||||||
expect(getFileType("plain.bst")).toBe("style");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores build artifacts", () => {
|
|
||||||
expect(getFileType("main.aux")).toBeNull();
|
|
||||||
expect(getFileType("main.log")).toBeNull();
|
|
||||||
expect(getFileType("main.toc")).toBeNull();
|
|
||||||
expect(getFileType("main.synctex.gz")).toBeNull();
|
|
||||||
expect(getFileType("main.fdb_latexmk")).toBeNull();
|
|
||||||
expect(getFileType("main.bbl")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores binary and non-text files", () => {
|
|
||||||
expect(getFileType("document.docx")).toBeNull();
|
|
||||||
expect(getFileType("spreadsheet.xlsx")).toBeNull();
|
|
||||||
expect(getFileType("report.hwp")).toBeNull();
|
|
||||||
expect(getFileType("data.accdb")).toBeNull();
|
|
||||||
expect(getFileType("archive.zip")).toBeNull();
|
|
||||||
expect(getFileType("app.exe")).toBeNull();
|
|
||||||
expect(getFileType("song.mp3")).toBeNull();
|
|
||||||
expect(getFileType("video.mp4")).toBeNull();
|
|
||||||
expect(getFileType("image.psd")).toBeNull();
|
|
||||||
expect(getFileType("database.sqlite")).toBeNull();
|
|
||||||
expect(getFileType("library.dll")).toBeNull();
|
|
||||||
expect(getFileType("presentation.pptx")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("classifies unknown extensions as other", () => {
|
|
||||||
expect(getFileType("readme.txt")).toBe("other");
|
|
||||||
expect(getFileType("notes.md")).toBe("other");
|
|
||||||
expect(getFileType("data.csv")).toBe("other");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,54 @@
|
||||||
import { vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
|
|
||||||
|
const storageData = new Map<string, string>();
|
||||||
|
const mockStorage = {
|
||||||
|
getItem: vi.fn((key: string) => storageData.get(key) ?? null),
|
||||||
|
setItem: vi.fn((key: string, value: string) => {
|
||||||
|
storageData.set(key, value);
|
||||||
|
}),
|
||||||
|
removeItem: vi.fn((key: string) => {
|
||||||
|
storageData.delete(key);
|
||||||
|
}),
|
||||||
|
clear: vi.fn(() => {
|
||||||
|
storageData.clear();
|
||||||
|
}),
|
||||||
|
key: vi.fn((index: number) => Array.from(storageData.keys())[index] ?? null),
|
||||||
|
get length() {
|
||||||
|
return storageData.size;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockWebview = {
|
||||||
|
setZoom: vi.fn(() => Promise.resolve()),
|
||||||
|
onDragDropEvent: vi.fn(() => Promise.resolve(() => {})),
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.defineProperty(window, "localStorage", {
|
||||||
|
value: mockStorage,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, "localStorage", {
|
||||||
|
value: mockStorage,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
// Mock @tauri-apps/api/core
|
// Mock @tauri-apps/api/core
|
||||||
vi.mock("@tauri-apps/api/core", () => ({
|
vi.mock("@tauri-apps/api/core", () => ({
|
||||||
invoke: vi.fn(),
|
invoke: vi.fn(),
|
||||||
convertFileSrc: vi.fn((path: string) => `asset://localhost/${path}`),
|
convertFileSrc: vi.fn((path: string) => `asset://localhost/${path}`),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Mock @tauri-apps/api/event
|
||||||
|
vi.mock("@tauri-apps/api/event", () => ({
|
||||||
|
emit: vi.fn(() => Promise.resolve()),
|
||||||
|
listen: vi.fn(() => Promise.resolve(() => {})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock @tauri-apps/api/webview
|
||||||
|
vi.mock("@tauri-apps/api/webview", () => ({
|
||||||
|
getCurrentWebview: vi.fn(() => mockWebview),
|
||||||
|
}));
|
||||||
|
|
||||||
// Mock @tauri-apps/api/path
|
// Mock @tauri-apps/api/path
|
||||||
vi.mock("@tauri-apps/api/path", () => ({
|
vi.mock("@tauri-apps/api/path", () => ({
|
||||||
join: vi.fn((...args: string[]) => Promise.resolve(args.join("/"))),
|
join: vi.fn((...args: string[]) => Promise.resolve(args.join("/"))),
|
||||||
|
|
@ -16,6 +59,7 @@ vi.mock("@tauri-apps/plugin-fs", () => ({
|
||||||
readTextFile: vi.fn(),
|
readTextFile: vi.fn(),
|
||||||
writeTextFile: vi.fn(),
|
writeTextFile: vi.fn(),
|
||||||
readDir: vi.fn(),
|
readDir: vi.fn(),
|
||||||
|
stat: vi.fn(),
|
||||||
exists: vi.fn(),
|
exists: vi.fn(),
|
||||||
mkdir: vi.fn(),
|
mkdir: vi.fn(),
|
||||||
readFile: vi.fn(),
|
readFile: vi.fn(),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,444 @@
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
const { mockDocumentState, getDocumentState, createSnapshotMock } = vi.hoisted(
|
||||||
|
() => ({
|
||||||
|
mockDocumentState: {} as any,
|
||||||
|
getDocumentState: vi.fn(),
|
||||||
|
createSnapshotMock: vi.fn(() => Promise.resolve(null)),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mock("@/stores/document-store", () => ({
|
||||||
|
useDocumentStore: {
|
||||||
|
getState: getDocumentState,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/stores/history-store", () => ({
|
||||||
|
useHistoryStore: {
|
||||||
|
getState: vi.fn(() => ({
|
||||||
|
createSnapshot: createSnapshotMock,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
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,
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
draft: { input: "", pinnedContexts: [] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
activeTabId: "tab-default",
|
||||||
|
activeProjectPath: "/project",
|
||||||
|
pendingInitialPrompt: null,
|
||||||
|
pendingAttachments: [],
|
||||||
|
pendingPinnedContextRemovalLabels: [],
|
||||||
|
selectedModel: "opus",
|
||||||
|
selectedProviderCredentialId: CLAUDE_CODE_PROVIDER_ID,
|
||||||
|
selectedProviderModels: {},
|
||||||
|
effortLevel: "medium",
|
||||||
|
_cancelledByUser: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMockDocumentState(overrides: Partial<any> = {}) {
|
||||||
|
const content = ["Line 1", "Line 2", "Line 3", "Line 4"].join("\n");
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
projectRoot: "/project",
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
id: "main.tex",
|
||||||
|
name: "main.tex",
|
||||||
|
relativePath: "main.tex",
|
||||||
|
absolutePath: "/project/main.tex",
|
||||||
|
type: "tex",
|
||||||
|
content,
|
||||||
|
isDirty: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
activeFileId: "main.tex",
|
||||||
|
selectionRange: null,
|
||||||
|
saveAllFiles: vi.fn(() => Promise.resolve()),
|
||||||
|
refreshFiles: vi.fn(() => Promise.resolve()),
|
||||||
|
reloadFile: vi.fn(() => Promise.resolve()),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.keys(mockDocumentState).forEach(
|
||||||
|
(key) => delete mockDocumentState[key],
|
||||||
|
);
|
||||||
|
Object.assign(mockDocumentState, state);
|
||||||
|
getDocumentState.mockImplementation(() => mockDocumentState);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useClaudeChatStore.sendPrompt context assembly", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
resetClaudeChatStore();
|
||||||
|
setMockDocumentState();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a plain file label and full file content for whole-file mentions", async () => {
|
||||||
|
const wholeFileText =
|
||||||
|
"\\section{Intro}\nThis is the full file.\n\\textbf{Important note}";
|
||||||
|
|
||||||
|
await useClaudeChatStore.getState().sendPrompt("Please revise this", {
|
||||||
|
label: "@main.tex",
|
||||||
|
filePath: "main.tex",
|
||||||
|
selectedText: wholeFileText,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(invoke).toHaveBeenCalledWith(
|
||||||
|
"execute_claude_code",
|
||||||
|
expect.objectContaining({
|
||||||
|
projectPath: "/project",
|
||||||
|
tabId: "tab-default",
|
||||||
|
prompt: expect.stringContaining("[Selection: @main.tex]"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const prompt = (vi.mocked(invoke).mock.calls[0]?.[1] as any)
|
||||||
|
?.prompt as string;
|
||||||
|
expect(prompt).toContain("[Currently open file: main.tex]");
|
||||||
|
expect(prompt).toContain("[Selection: @main.tex]");
|
||||||
|
expect(prompt).toContain(wholeFileText);
|
||||||
|
|
||||||
|
const userText =
|
||||||
|
useClaudeChatStore.getState().messages[0].message?.content?.[0].text;
|
||||||
|
expect(userText).toBe("@main.tex\nPlease revise this");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a line-range label and only the selected slice for selection context", async () => {
|
||||||
|
const state = setMockDocumentState({
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
id: "main.tex",
|
||||||
|
name: "main.tex",
|
||||||
|
relativePath: "main.tex",
|
||||||
|
absolutePath: "/project/main.tex",
|
||||||
|
type: "tex",
|
||||||
|
content: "alpha\nbeta\ngamma\ndelta",
|
||||||
|
isDirty: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionRange: { start: 6, end: 16 },
|
||||||
|
});
|
||||||
|
|
||||||
|
await useClaudeChatStore.getState().sendPrompt("Please revise this");
|
||||||
|
|
||||||
|
expect(invoke).toHaveBeenCalledWith(
|
||||||
|
"execute_claude_code",
|
||||||
|
expect.objectContaining({
|
||||||
|
projectPath: "/project",
|
||||||
|
tabId: "tab-default",
|
||||||
|
prompt: expect.stringContaining("[Selection: @main.tex:2:1-3:6]"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const prompt = (vi.mocked(invoke).mock.calls[0]?.[1] as any)
|
||||||
|
?.prompt as string;
|
||||||
|
expect(prompt).toContain("[Currently open file: main.tex]");
|
||||||
|
expect(prompt).toContain("[Selection: @main.tex:2:1-3:6]");
|
||||||
|
expect(prompt).toContain("[Selected text:\nbeta\ngamma\n]");
|
||||||
|
expect(prompt).not.toContain("alpha\na");
|
||||||
|
expect(prompt).not.toContain("\ndelta");
|
||||||
|
|
||||||
|
const userText =
|
||||||
|
useClaudeChatStore.getState().messages[0].message?.content?.[0].text;
|
||||||
|
expect(userText).toBe("@main.tex:2:1-3:6\nPlease revise this");
|
||||||
|
expect(state.saveAllFiles).not.toHaveBeenCalled();
|
||||||
|
expect(createSnapshotMock).toHaveBeenCalledWith(
|
||||||
|
"/project",
|
||||||
|
"[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 { beforeEach, describe, expect, it } from "vitest";
|
||||||
import { offsetToLineCol } from "@/stores/claude-chat-store";
|
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", () => {
|
describe("offsetToLineCol", () => {
|
||||||
it("returns line 1, col 1 for offset 0 on empty string", () => {
|
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 });
|
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
|
// advanceSteps is module-private — replicate for testing
|
||||||
type StepStatus = "pending" | "active" | "complete" | "error";
|
type StepStatus = "pending" | "active" | "complete" | "error";
|
||||||
|
|
@ -95,3 +97,384 @@ describe("advanceSteps", () => {
|
||||||
expect(result[2].status).toBe("pending");
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,20 @@
|
||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
import { writeTextFile } from "@tauri-apps/plugin-fs";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import {
|
||||||
|
readDir,
|
||||||
|
readTextFile,
|
||||||
|
rename,
|
||||||
|
stat,
|
||||||
|
writeTextFile,
|
||||||
|
} from "@tauri-apps/plugin-fs";
|
||||||
import {
|
import {
|
||||||
useDocumentStore,
|
useDocumentStore,
|
||||||
getCurrentPdfBytes,
|
getCurrentPdfBytes,
|
||||||
|
getCurrentPdfRootId,
|
||||||
clearPdfBytesCache,
|
clearPdfBytesCache,
|
||||||
type ProjectFile,
|
type ProjectFile,
|
||||||
} from "@/stores/document-store";
|
} from "@/stores/document-store";
|
||||||
|
import { useProjectStore } from "@/stores/project-store";
|
||||||
|
|
||||||
// Mock history store
|
// Mock history store
|
||||||
vi.mock("@/stores/history-store", () => ({
|
vi.mock("@/stores/history-store", () => ({
|
||||||
|
|
@ -14,6 +23,7 @@ vi.mock("@/stores/history-store", () => ({
|
||||||
init: vi.fn(() => Promise.resolve()),
|
init: vi.fn(() => Promise.resolve()),
|
||||||
loadSnapshots: vi.fn(() => Promise.resolve()),
|
loadSnapshots: vi.fn(() => Promise.resolve()),
|
||||||
createSnapshot: vi.fn(() => Promise.resolve()),
|
createSnapshot: vi.fn(() => Promise.resolve()),
|
||||||
|
reset: vi.fn(),
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
@ -42,6 +52,7 @@ function makeFile(overrides: Partial<ProjectFile> = {}): ProjectFile {
|
||||||
|
|
||||||
describe("useDocumentStore", () => {
|
describe("useDocumentStore", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
clearPdfBytesCache();
|
clearPdfBytesCache();
|
||||||
useDocumentStore.setState({
|
useDocumentStore.setState({
|
||||||
projectRoot: "/project",
|
projectRoot: "/project",
|
||||||
|
|
@ -59,6 +70,10 @@ describe("useDocumentStore", () => {
|
||||||
isSaving: false,
|
isSaving: false,
|
||||||
initialized: true,
|
initialized: true,
|
||||||
});
|
});
|
||||||
|
useProjectStore.setState({
|
||||||
|
recentProjects: [],
|
||||||
|
lastProjectFolder: null,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getActiveFile logic", () => {
|
describe("getActiveFile logic", () => {
|
||||||
|
|
@ -83,6 +98,142 @@ describe("useDocumentStore", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("openProject", () => {
|
||||||
|
it("re-authorizes the project directory before scanning", async () => {
|
||||||
|
const projectPath = "E:\\overleaf-cache\\论文项目";
|
||||||
|
let resolveAuthorization!: () => void;
|
||||||
|
const authorizationPromise = new Promise<void>((resolve) => {
|
||||||
|
resolveAuthorization = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(invoke).mockReturnValue(
|
||||||
|
authorizationPromise as ReturnType<typeof invoke>,
|
||||||
|
);
|
||||||
|
vi.mocked(readDir).mockResolvedValue([
|
||||||
|
{ name: "main.tex", isDirectory: false },
|
||||||
|
] as any);
|
||||||
|
vi.mocked(readTextFile).mockResolvedValue("\\documentclass{article}");
|
||||||
|
|
||||||
|
const openProjectPromise = useDocumentStore
|
||||||
|
.getState()
|
||||||
|
.openProject(projectPath);
|
||||||
|
|
||||||
|
expect(invoke).toHaveBeenCalledWith("allow_project_directory", {
|
||||||
|
rootPath: projectPath,
|
||||||
|
});
|
||||||
|
expect(readDir).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
resolveAuthorization();
|
||||||
|
await openProjectPromise;
|
||||||
|
|
||||||
|
expect(readDir).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
const dirPath = String(dir);
|
||||||
|
if (dirPath === "/project") {
|
||||||
|
return [
|
||||||
|
{ name: "__pycache__", isDirectory: true },
|
||||||
|
{ name: "main.tex", isDirectory: false },
|
||||||
|
{ name: "tool.py", isDirectory: false },
|
||||||
|
{ name: "compiled.pyc", isDirectory: false },
|
||||||
|
] as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unexpected readDir path: ${dirPath}`);
|
||||||
|
});
|
||||||
|
vi.mocked(stat).mockResolvedValue({ size: 32 } as any);
|
||||||
|
vi.mocked(readTextFile).mockImplementation(async (path: string | URL) => {
|
||||||
|
const filePath = String(path);
|
||||||
|
if (filePath === "/project/main.tex") {
|
||||||
|
return "\\documentclass{article}";
|
||||||
|
}
|
||||||
|
if (filePath === "/project/tool.py") {
|
||||||
|
return "print('hello')";
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected readTextFile path: ${filePath}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await useDocumentStore.getState().openProject("/project");
|
||||||
|
|
||||||
|
expect(readDir).toHaveBeenCalledWith("/project");
|
||||||
|
expect(readDir).not.toHaveBeenCalledWith("/project/__pycache__");
|
||||||
|
expect(stat).toHaveBeenCalledTimes(1);
|
||||||
|
expect(stat).toHaveBeenCalledWith("/project/tool.py");
|
||||||
|
expect(readTextFile).toHaveBeenCalledTimes(2);
|
||||||
|
expect(readTextFile).not.toHaveBeenCalledWith("/project/compiled.pyc");
|
||||||
|
expect(
|
||||||
|
useDocumentStore.getState().files.map((file) => file.relativePath),
|
||||||
|
).toEqual(["main.tex", "tool.py"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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", () => {
|
describe("insertAtCursor", () => {
|
||||||
it("inserts text at cursor position", () => {
|
it("inserts text at cursor position", () => {
|
||||||
useDocumentStore.getState().insertAtCursor(", Beautiful");
|
useDocumentStore.getState().insertAtCursor(", Beautiful");
|
||||||
|
|
@ -293,6 +444,39 @@ describe("useDocumentStore", () => {
|
||||||
expect(state.cursorPosition).toBe(100);
|
expect(state.cursorPosition).toBe(100);
|
||||||
expect(state.selectionRange).toBeNull();
|
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", () => {
|
describe("saveFile", () => {
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ function resetStores() {
|
||||||
messages: [],
|
messages: [],
|
||||||
sessionId: null,
|
sessionId: null,
|
||||||
isStreaming: false,
|
isStreaming: false,
|
||||||
|
streamingStartedAt: null,
|
||||||
error: null,
|
error: null,
|
||||||
totalInputTokens: 0,
|
totalInputTokens: 0,
|
||||||
totalOutputTokens: 0,
|
totalOutputTokens: 0,
|
||||||
|
|
@ -43,9 +44,13 @@ function resetStores() {
|
||||||
{
|
{
|
||||||
id: "tab-default",
|
id: "tab-default",
|
||||||
title: "New Chat",
|
title: "New Chat",
|
||||||
|
projectPath: "/project",
|
||||||
sessionId: null,
|
sessionId: null,
|
||||||
|
providerKey: null,
|
||||||
|
sessionProviderKey: null,
|
||||||
messages: [],
|
messages: [],
|
||||||
isStreaming: false,
|
isStreaming: false,
|
||||||
|
streamingStartedAt: null,
|
||||||
error: null,
|
error: null,
|
||||||
totalInputTokens: 0,
|
totalInputTokens: 0,
|
||||||
totalOutputTokens: 0,
|
totalOutputTokens: 0,
|
||||||
|
|
@ -53,6 +58,7 @@ function resetStores() {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
activeTabId: "tab-default",
|
activeTabId: "tab-default",
|
||||||
|
activeProjectPath: "/project",
|
||||||
_cancelledByUser: false,
|
_cancelledByUser: false,
|
||||||
});
|
});
|
||||||
useProposedChangesStore.setState({ changes: [] });
|
useProposedChangesStore.setState({ changes: [] });
|
||||||
|
|
@ -238,6 +244,32 @@ describe("Multi-tab merge triggers", () => {
|
||||||
expect(tabBState.isStreaming).toBe(true);
|
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", () => {
|
it("_appendMessage routes to the specified tab, not the active tab", () => {
|
||||||
const chat = useClaudeChatStore.getState();
|
const chat = useClaudeChatStore.getState();
|
||||||
const tabB = chat.createTab();
|
const tabB = chat.createTab();
|
||||||
|
|
@ -271,6 +303,89 @@ describe("Multi-tab merge triggers", () => {
|
||||||
expect(state.messages).toHaveLength(0);
|
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", () => {
|
it("_setSessionId routes to the specified tab", () => {
|
||||||
const chat = useClaudeChatStore.getState();
|
const chat = useClaudeChatStore.getState();
|
||||||
const tabB = chat.createTab();
|
const tabB = chat.createTab();
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,18 @@ describe("useProjectStore", () => {
|
||||||
expect(recentProjects[1].path).toBe("/b");
|
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", () => {
|
it("limits to MAX_RECENT (10) entries", () => {
|
||||||
const store = useProjectStore.getState();
|
const store = useProjectStore.getState();
|
||||||
for (let i = 0; i < 12; i++) {
|
for (let i = 0; i < 12; i++) {
|
||||||
|
|
@ -67,4 +79,38 @@ describe("useProjectStore", () => {
|
||||||
expect(recentProjects[0].path).toBe("/b");
|
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,27 +1,37 @@
|
||||||
import { type FC, memo, useEffect, useMemo, useRef, useState } from "react";
|
import { type FC, memo, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { AlertCircleIcon } from "lucide-react";
|
import {
|
||||||
|
AlertCircleIcon,
|
||||||
|
CheckIcon,
|
||||||
|
CopyIcon,
|
||||||
|
CornerDownRightIcon,
|
||||||
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
useClaudeChatStore,
|
useClaudeChatStore,
|
||||||
type ClaudeStreamMessage,
|
type ClaudeStreamMessage,
|
||||||
type ContentBlock,
|
type ContentBlock,
|
||||||
|
type QueuedGuidance,
|
||||||
} from "@/stores/claude-chat-store";
|
} 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 { MarkdownRenderer } from "./markdown-renderer";
|
||||||
import { ThinkingWidget, ToolWidget } from "./tool-widgets";
|
import { ThinkingWidget, ToolWidget } from "./tool-widgets";
|
||||||
|
|
||||||
// ─── Streaming Indicator (isolated to prevent re-render storms) ───
|
// ─── Streaming Indicator (isolated to prevent re-render storms) ───
|
||||||
|
|
||||||
const StreamingIndicator: FC = memo(() => {
|
const StreamingIndicator: FC<{ startedAt: number | null }> = memo(
|
||||||
const [elapsed, setElapsed] = useState(0);
|
({ startedAt }) => {
|
||||||
const startRef = useRef(Date.now());
|
const calculateElapsed = () =>
|
||||||
|
startedAt ? Math.max(0, Math.floor((Date.now() - startedAt) / 1000)) : 0;
|
||||||
|
|
||||||
|
const [elapsed, setElapsed] = useState(calculateElapsed);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
startRef.current = Date.now();
|
setElapsed(calculateElapsed());
|
||||||
setElapsed(0);
|
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
setElapsed(Math.floor((Date.now() - startRef.current) / 1000));
|
setElapsed(calculateElapsed());
|
||||||
}, 1000);
|
}, 1000);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, []);
|
}, [startedAt]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1.5 px-1 py-1.5 text-muted-foreground">
|
<div className="flex items-center gap-1.5 px-1 py-1.5 text-muted-foreground">
|
||||||
|
|
@ -49,13 +59,67 @@ const StreamingIndicator: FC = memo(() => {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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={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" />
|
||||||
|
)}
|
||||||
|
</TooltipIconButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// ─── Chat Messages (main component) ───
|
// ─── Chat Messages (main component) ───
|
||||||
|
|
||||||
export const ChatMessages: FC = () => {
|
export const ChatMessages: FC = () => {
|
||||||
const messages = useClaudeChatStore((s) => s.messages) ?? [];
|
const messages = useClaudeChatStore((s) => s.messages) ?? [];
|
||||||
const isStreaming = useClaudeChatStore((s) => s.isStreaming);
|
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 viewportRef = useRef<HTMLDivElement>(null);
|
||||||
const shouldAutoScrollRef = useRef(true);
|
const shouldAutoScrollRef = useRef(true);
|
||||||
const userHasScrolledRef = useRef(false);
|
const userHasScrolledRef = useRef(false);
|
||||||
|
|
@ -120,7 +184,7 @@ export const ChatMessages: FC = () => {
|
||||||
behavior: "smooth",
|
behavior: "smooth",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [displayMessages]);
|
}, [displayMessages, pendingGuidance]);
|
||||||
|
|
||||||
// Reset auto-scroll when streaming stops
|
// Reset auto-scroll when streaming stops
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -148,19 +212,36 @@ export const ChatMessages: FC = () => {
|
||||||
<div
|
<div
|
||||||
ref={viewportRef}
|
ref={viewportRef}
|
||||||
onScroll={handleScroll}
|
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 && (
|
{displayMessages.length === 0 &&
|
||||||
|
pendingGuidance.length === 0 &&
|
||||||
|
!isStreaming && (
|
||||||
<div className="flex h-full items-center justify-center text-muted-foreground text-sm">
|
<div className="flex h-full items-center justify-center text-muted-foreground text-sm">
|
||||||
Ask Claude about your LaTeX document...
|
Ask Claude about your LaTeX document...
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{displayMessages.map((msg, idx) => (
|
{displayMessages.map((msg, idx) => (
|
||||||
<MessageBubble key={idx} message={msg} toolResultMap={toolResultMap} />
|
<div key={idx} className={cn("mx-auto w-full", THREAD_MAX_WIDTH)}>
|
||||||
|
<MessageBubble message={msg} toolResultMap={toolResultMap} />
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{isStreaming && <StreamingIndicator />}
|
{isStreaming && (
|
||||||
|
<div className={cn("mx-auto w-full px-2", THREAD_MAX_WIDTH)}>
|
||||||
|
<StreamingIndicator startedAt={streamingStartedAt} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pendingGuidance.map((guidance) => (
|
||||||
|
<div
|
||||||
|
key={guidance.id}
|
||||||
|
className={cn("mx-auto w-full", THREAD_MAX_WIDTH)}
|
||||||
|
>
|
||||||
|
<PendingGuidanceMessage guidance={guidance} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -198,10 +279,15 @@ const UserMessage: FC<{ message: ClaudeStreamMessage }> = ({ message }) => {
|
||||||
|
|
||||||
if (!textContent) return null;
|
if (!textContent) return null;
|
||||||
|
|
||||||
// Parse leading @file:line:col or ~@file:line context reference
|
const firstLineMatch = textContent.match(/^([^\n]+)\n([\s\S]*)$/);
|
||||||
const contextMatch = textContent.match(/^(~?@[^\n]+)\n([\s\S]*)$/);
|
const firstLine = firstLineMatch?.[1]?.trim() ?? "";
|
||||||
const contextLabel = contextMatch?.[1] ?? null;
|
const hasContextLabel =
|
||||||
const bodyText = contextMatch ? contextMatch[2] : textContent;
|
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:
|
// Parse error block patterns for styled rendering:
|
||||||
// Lint single: "[Lint error in FILE:LINE]\n[Error: MSG]\n\nPrompt"
|
// Lint single: "[Lint error in FILE:LINE]\n[Error: MSG]\n\nPrompt"
|
||||||
|
|
@ -223,10 +309,13 @@ const UserMessage: FC<{ message: ClaudeStreamMessage }> = ({ message }) => {
|
||||||
errors: { message: string; location?: string }[],
|
errors: { message: string; location?: string }[],
|
||||||
prompt: string,
|
prompt: string,
|
||||||
) => (
|
) => (
|
||||||
<div className="flex w-full flex-col items-end py-1.5">
|
<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="max-w-[85%] rounded-xl bg-muted px-3 py-2 text-foreground text-sm">
|
<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-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="mb-1.5 font-medium text-red-400 text-xs">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{errors.map((e, i) => (
|
{errors.map((e, i) => (
|
||||||
<div key={i} className="flex items-start gap-1.5">
|
<div key={i} className="flex items-start gap-1.5">
|
||||||
|
|
@ -246,6 +335,10 @@ const UserMessage: FC<{ message: ClaudeStreamMessage }> = ({ message }) => {
|
||||||
<span className="text-muted-foreground">{prompt}</span>
|
<span className="text-muted-foreground">{prompt}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="col-span-full col-start-1 row-start-2 -mr-1 flex justify-end">
|
||||||
|
<MessageActions text={bodyText} align="right" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (lintSingleMatch) {
|
if (lintSingleMatch) {
|
||||||
|
|
@ -287,8 +380,9 @@ const UserMessage: FC<{ message: ClaudeStreamMessage }> = ({ message }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col items-end py-1.5">
|
<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="max-w-[85%] rounded-xl bg-muted px-3 py-1.5 text-foreground text-sm">
|
<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 && (
|
{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">
|
<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}
|
{contextLabel}
|
||||||
|
|
@ -301,11 +395,49 @@ const UserMessage: FC<{ message: ClaudeStreamMessage }> = ({ message }) => {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Assistant 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<{
|
const AssistantMessage: FC<{
|
||||||
message: ClaudeStreamMessage;
|
message: ClaudeStreamMessage;
|
||||||
toolResultMap: Map<string, ContentBlock>;
|
toolResultMap: Map<string, ContentBlock>;
|
||||||
|
|
@ -322,19 +454,15 @@ const AssistantMessage: FC<{
|
||||||
|
|
||||||
if (!hasRenderableContent) return null;
|
if (!hasRenderableContent) return null;
|
||||||
|
|
||||||
|
const copyText = content
|
||||||
|
.filter((block) => block.type === "text" && block.text)
|
||||||
|
.map((block) => block.text)
|
||||||
|
.join("\n\n");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full py-1.5">
|
<div className="fade-in slide-in-from-bottom-1 relative mx-auto w-full animate-in py-3 duration-150">
|
||||||
<div className="px-1 text-foreground text-sm leading-relaxed">
|
<div className="wrap-break-word px-2 text-foreground text-sm leading-relaxed">
|
||||||
{content.map((block, idx) => {
|
{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) {
|
if (block.type === "text" && block.text) {
|
||||||
return (
|
return (
|
||||||
<MarkdownRenderer
|
<MarkdownRenderer
|
||||||
|
|
@ -348,9 +476,21 @@ const AssistantMessage: FC<{
|
||||||
const result = toolResultMap.get(block.id);
|
const result = toolResultMap.get(block.id);
|
||||||
return <ToolWidget key={idx} toolUse={block} toolResult={result} />;
|
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;
|
return null;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="-mb-7.5 ml-2 flex min-h-7.5 items-center pt-1.5">
|
||||||
|
<MessageActions text={copyText} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -364,8 +504,8 @@ const ResultMessage: FC<{ message: ClaudeStreamMessage }> = ({ message }) => {
|
||||||
if (!resultText) return null;
|
if (!resultText) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full py-1.5">
|
<div className="fade-in slide-in-from-bottom-1 relative mx-auto w-full animate-in py-3 duration-150">
|
||||||
<div className="px-1 text-foreground text-sm leading-relaxed">
|
<div className="wrap-break-word px-2 text-foreground text-sm leading-relaxed">
|
||||||
{isError ? (
|
{isError ? (
|
||||||
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-destructive text-sm">
|
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-destructive text-sm">
|
||||||
{resultText}
|
{resultText}
|
||||||
|
|
@ -377,6 +517,9 @@ const ResultMessage: FC<{ message: ClaudeStreamMessage }> = ({ message }) => {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 && (
|
{message.cost_usd != null && (
|
||||||
<div className="mt-1 px-1 text-right text-muted-foreground text-xs">
|
<div className="mt-1 px-1 text-right text-muted-foreground text-xs">
|
||||||
Cost: ${message.cost_usd.toFixed(4)}
|
Cost: ${message.cost_usd.toFixed(4)}
|
||||||
|
|
|
||||||
|
|
@ -95,11 +95,11 @@ export function ChatTabBar() {
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center">
|
<div className="flex shrink-0 items-center gap-0.5 pr-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCreate}
|
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"
|
aria-label="New tab"
|
||||||
>
|
>
|
||||||
<PlusIcon className="size-3.5" />
|
<PlusIcon className="size-3.5" />
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import { ChatMessages } from "./chat-messages";
|
||||||
import { ChatComposer } from "./chat-composer";
|
import { ChatComposer } from "./chat-composer";
|
||||||
import { ChatTabBar } from "./chat-tab-bar";
|
import { ChatTabBar } from "./chat-tab-bar";
|
||||||
|
|
||||||
const MIN_HEIGHT = 150;
|
const MIN_HEIGHT = 260;
|
||||||
const DEFAULT_HEIGHT = 360;
|
const DEFAULT_HEIGHT = 360;
|
||||||
|
|
||||||
export function ClaudeChatDrawer() {
|
export function ClaudeChatDrawer() {
|
||||||
|
|
@ -43,11 +43,14 @@ export function ClaudeChatDrawer() {
|
||||||
if (shouldOpen && !isOpen) {
|
if (shouldOpen && !isOpen) {
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
const parent = containerRef.current?.parentElement;
|
const parent = containerRef.current?.parentElement;
|
||||||
const maxHeight = parent ? parent.clientHeight * 0.5 : 400;
|
const maxHeight = parent
|
||||||
setHeight(maxHeight);
|
? Math.max(MIN_HEIGHT, parent.clientHeight * 0.5)
|
||||||
heightRef.current = maxHeight;
|
: 400;
|
||||||
|
const nextHeight = Math.max(maxHeight, MIN_HEIGHT);
|
||||||
|
setHeight(nextHeight);
|
||||||
|
heightRef.current = nextHeight;
|
||||||
if (panelRef.current) {
|
if (panelRef.current) {
|
||||||
panelRef.current.style.height = `${maxHeight}px`;
|
panelRef.current.style.height = `${nextHeight}px`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [anyStreaming, isOpen, pendingAttachments]);
|
}, [anyStreaming, isOpen, pendingAttachments]);
|
||||||
|
|
@ -66,7 +69,9 @@ export function ClaudeChatDrawer() {
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
hasDraggedRef.current = true;
|
hasDraggedRef.current = true;
|
||||||
const parent = containerRef.current?.parentElement;
|
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 delta = startY - e.clientY;
|
||||||
const newHeight = Math.min(
|
const newHeight = Math.min(
|
||||||
Math.max(startHeight + delta, MIN_HEIGHT),
|
Math.max(startHeight + delta, MIN_HEIGHT),
|
||||||
|
|
@ -108,7 +113,12 @@ export function ClaudeChatDrawer() {
|
||||||
const dims = getExpandedDimensions();
|
const dims = getExpandedDimensions();
|
||||||
return { height: dims.height, maxWidth: dims.width, borderRadius: 0 };
|
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 (
|
return (
|
||||||
|
|
@ -196,7 +206,7 @@ export function ClaudeChatDrawer() {
|
||||||
|
|
||||||
{/* Error banner */}
|
{/* Error banner */}
|
||||||
{error && (
|
{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}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { invoke } from "@tauri-apps/api/core";
|
||||||
import "katex/dist/katex.min.css";
|
import "katex/dist/katex.min.css";
|
||||||
|
|
||||||
import { useDocumentStore } from "@/stores/document-store";
|
import { useDocumentStore } from "@/stores/document-store";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
// ─── Shell Detection ───
|
// ─── Shell Detection ───
|
||||||
|
|
||||||
|
|
@ -83,11 +84,56 @@ export const MarkdownRenderer: FC<MarkdownRendererProps> = ({
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkGfm, remarkMath]}
|
remarkPlugins={[remarkGfm, remarkMath]}
|
||||||
rehypePlugins={[rehypeKatex]}
|
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={{
|
components={{
|
||||||
pre({ children }) {
|
pre({ children }) {
|
||||||
return <>{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 }) {
|
code({ className: codeClassName, children, node, ...props }) {
|
||||||
const match = /language-(\w+)/.exec(codeClassName || "");
|
const match = /language-(\w+)/.exec(codeClassName || "");
|
||||||
const language = match?.[1];
|
const language = match?.[1];
|
||||||
|
|
@ -98,7 +144,13 @@ export const MarkdownRenderer: FC<MarkdownRendererProps> = ({
|
||||||
|
|
||||||
if (!match && !isBlock) {
|
if (!match && !isBlock) {
|
||||||
return (
|
return (
|
||||||
<code className={codeClassName} {...props}>
|
<code
|
||||||
|
className={cn(
|
||||||
|
"break-words [overflow-wrap:anywhere]",
|
||||||
|
codeClassName,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</code>
|
</code>
|
||||||
);
|
);
|
||||||
|
|
@ -220,7 +272,7 @@ const CodeBlock: FC<{ language: string; code: string }> = ({
|
||||||
<span className="text-xs">
|
<span className="text-xs">
|
||||||
Run in{" "}
|
Run in{" "}
|
||||||
<code className="rounded bg-muted px-1 text-xs">
|
<code className="rounded bg-muted px-1 text-xs">
|
||||||
{projectRoot?.split("/").pop()}/
|
{projectRoot?.split(/[/\\]/).pop()}/
|
||||||
</code>
|
</code>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { type FC } from "react";
|
import { type FC, useEffect, useRef, useState } from "react";
|
||||||
import { Check, X } from "lucide-react";
|
import { Check, X } from "lucide-react";
|
||||||
import { type ProposedChange } from "@/stores/proposed-changes-store";
|
import { type ProposedChange } from "@/stores/proposed-changes-store";
|
||||||
|
|
||||||
|
|
@ -17,42 +17,79 @@ export const ProposedChangesPanel: FC<ProposedChangesPanelProps> = ({
|
||||||
onKeep,
|
onKeep,
|
||||||
onUndo,
|
onUndo,
|
||||||
}) => {
|
}) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [containerWidth, setContainerWidth] = useState(0);
|
||||||
const oldLines = change.oldContent.split("\n").length;
|
const oldLines = change.oldContent.split("\n").length;
|
||||||
const newLines = change.newContent.split("\n").length;
|
const newLines = change.newContent.split("\n").length;
|
||||||
const added = Math.max(0, newLines - oldLines);
|
const added = Math.max(0, newLines - oldLines);
|
||||||
const removed = Math.max(0, oldLines - newLines);
|
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 (
|
return (
|
||||||
<div className="flex items-center justify-between border-border border-t bg-muted/50 px-3 py-1.5">
|
<div
|
||||||
<div className="flex items-center gap-2 text-sm">
|
ref={containerRef}
|
||||||
<span className="font-medium text-foreground">Proposed Changes</span>
|
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 && (
|
{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
|
{changeIndex + 1}/{totalChanges} files
|
||||||
</span>
|
</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>
|
||||||
<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
|
<button
|
||||||
|
type="button"
|
||||||
onClick={onKeep}
|
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" />
|
<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]">
|
<kbd className="ml-1 rounded bg-green-600/20 px-1 py-0.5 font-mono text-[10px]">
|
||||||
⌘Y
|
⌘Y
|
||||||
</kbd>
|
</kbd>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={onUndo}
|
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" />
|
<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]">
|
<kbd className="ml-1 rounded bg-red-600/20 px-1 py-0.5 font-mono text-[10px]">
|
||||||
⌘N
|
⌘N
|
||||||
</kbd>
|
</kbd>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback, useMemo } from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
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 {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
|
|
@ -9,6 +15,15 @@ import {
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} 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 { useClaudeChatStore } from "@/stores/claude-chat-store";
|
||||||
import { useDocumentStore } from "@/stores/document-store";
|
import { useDocumentStore } from "@/stores/document-store";
|
||||||
import { createLogger } from "@/lib/debug/logger";
|
import { createLogger } from "@/lib/debug/logger";
|
||||||
|
|
@ -37,11 +52,31 @@ function formatRelativeTime(unixSeconds: number): string {
|
||||||
export function SessionSelector() {
|
export function SessionSelector() {
|
||||||
const [sessions, setSessions] = useState<ClaudeSessionInfo[]>([]);
|
const [sessions, setSessions] = useState<ClaudeSessionInfo[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
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 sessionId = useClaudeChatStore((s) => s.sessionId);
|
||||||
const isStreaming = useClaudeChatStore((s) => s.isStreaming);
|
const tabs = useClaudeChatStore((s) => s.tabs);
|
||||||
const newSession = useClaudeChatStore((s) => s.newSession);
|
const newSession = useClaudeChatStore((s) => s.newSession);
|
||||||
const resumeSession = useClaudeChatStore((s) => s.resumeSession);
|
const resumeSession = useClaudeChatStore((s) => s.resumeSession);
|
||||||
|
const setSessionTitle = useClaudeChatStore((s) => s._setSessionTitle);
|
||||||
const projectRoot = useDocumentStore((s) => s.projectRoot);
|
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 () => {
|
const loadSessions = useCallback(async () => {
|
||||||
if (!projectRoot) return;
|
if (!projectRoot) return;
|
||||||
|
|
@ -50,16 +85,20 @@ export function SessionSelector() {
|
||||||
try {
|
try {
|
||||||
const result = await invoke<ClaudeSessionInfo[]>("list_claude_sessions", {
|
const result = await invoke<ClaudeSessionInfo[]>("list_claude_sessions", {
|
||||||
projectPath: projectRoot,
|
projectPath: projectRoot,
|
||||||
|
generateTitles: false,
|
||||||
});
|
});
|
||||||
log.debug("loaded sessions", { count: result.length });
|
log.debug("loaded sessions", { count: result.length });
|
||||||
setSessions(result);
|
setSessions(result);
|
||||||
|
for (const session of result) {
|
||||||
|
setSessionTitle(session.session_id, session.title);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error("Failed to load sessions", { error: String(err) });
|
log.error("Failed to load sessions", { error: String(err) });
|
||||||
setSessions([]);
|
setSessions([]);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [projectRoot]);
|
}, [projectRoot, setSessionTitle]);
|
||||||
|
|
||||||
const handleOpenChange = useCallback(
|
const handleOpenChange = useCallback(
|
||||||
(open: boolean) => {
|
(open: boolean) => {
|
||||||
|
|
@ -71,21 +110,52 @@ export function SessionSelector() {
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelectSession = useCallback(
|
const handleSelectSession = useCallback(
|
||||||
(sid: string) => {
|
(session: ClaudeSessionInfo) => {
|
||||||
if (isStreaming) return;
|
if (deletingId === session.session_id) return;
|
||||||
if (sid === sessionId) return;
|
if (session.session_id === sessionId) return;
|
||||||
log.debug(`selecting session: ${sid}`);
|
log.debug(`selecting session: ${session.session_id}`);
|
||||||
resumeSession(sid);
|
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(() => {
|
const handleNewChat = useCallback(() => {
|
||||||
if (isStreaming) return;
|
|
||||||
newSession();
|
newSession();
|
||||||
}, [isStreaming, newSession]);
|
}, [newSession]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<DropdownMenu onOpenChange={handleOpenChange}>
|
<DropdownMenu onOpenChange={handleOpenChange}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
|
|
@ -106,7 +176,7 @@ export function SessionSelector() {
|
||||||
>
|
>
|
||||||
<DropdownMenuLabel>Sessions</DropdownMenuLabel>
|
<DropdownMenuLabel>Sessions</DropdownMenuLabel>
|
||||||
|
|
||||||
<DropdownMenuItem onSelect={handleNewChat} disabled={isStreaming}>
|
<DropdownMenuItem onSelect={handleNewChat}>
|
||||||
<PlusIcon className="size-4" />
|
<PlusIcon className="size-4" />
|
||||||
<span>New Chat</span>
|
<span>New Chat</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
@ -122,12 +192,16 @@ export function SessionSelector() {
|
||||||
No previous sessions
|
No previous sessions
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
sessions.map((session) => (
|
sessions.map((session) => {
|
||||||
|
const sessionIsStreaming = streamingSessionIds.has(
|
||||||
|
session.session_id,
|
||||||
|
);
|
||||||
|
return (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={session.session_id}
|
key={session.session_id}
|
||||||
onSelect={() => handleSelectSession(session.session_id)}
|
onSelect={() => handleSelectSession(session)}
|
||||||
disabled={isStreaming}
|
disabled={deletingId === session.session_id}
|
||||||
className="flex items-start gap-2"
|
className="group flex items-start gap-2"
|
||||||
>
|
>
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
<span className="truncate text-sm">{session.title}</span>
|
<span className="truncate text-sm">{session.title}</span>
|
||||||
|
|
@ -135,13 +209,105 @@ export function SessionSelector() {
|
||||||
{formatRelativeTime(session.last_modified)}
|
{formatRelativeTime(session.last_modified)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{session.session_id === sessionId && (
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
<CheckIcon className="size-4 shrink-0 text-primary" />
|
{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>
|
</DropdownMenuItem>
|
||||||
))
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open && !deletingId) {
|
||||||
|
setDeleteTarget(null);
|
||||||
|
setDeleteError(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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}
|
||||||
|
>
|
||||||
|
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" />
|
||||||
|
)}
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { type FC, useState } from "react";
|
import { type FC, useState } from "react";
|
||||||
import {
|
import {
|
||||||
BotIcon,
|
BrainIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
|
|
@ -37,12 +37,18 @@ export const ToolWidget: FC<ToolWidgetProps> = ({ toolUse, toolResult }) => {
|
||||||
return <ReadWidget input={toolUse.input} result={toolResult} />;
|
return <ReadWidget input={toolUse.input} result={toolResult} />;
|
||||||
if (name === "bash")
|
if (name === "bash")
|
||||||
return <BashWidget input={toolUse.input} result={toolResult} />;
|
return <BashWidget input={toolUse.input} result={toolResult} />;
|
||||||
|
if (name === "powershell" || name === "pwsh")
|
||||||
|
return (
|
||||||
|
<BashWidget input={toolUse.input} result={toolResult} prefix="PS>" />
|
||||||
|
);
|
||||||
if (name === "glob")
|
if (name === "glob")
|
||||||
return <GlobWidget input={toolUse.input} result={toolResult} />;
|
return <GlobWidget input={toolUse.input} result={toolResult} />;
|
||||||
if (name === "grep")
|
if (name === "grep")
|
||||||
return <GrepWidget input={toolUse.input} result={toolResult} />;
|
return <GrepWidget input={toolUse.input} result={toolResult} />;
|
||||||
if (name === "askuserquestion")
|
if (name === "askuserquestion")
|
||||||
return <AskUserQuestionWidget input={toolUse.input} result={toolResult} />;
|
return <AskUserQuestionWidget input={toolUse.input} result={toolResult} />;
|
||||||
|
if (name === "exitplanmode")
|
||||||
|
return <ExitPlanModeWidget input={toolUse.input} result={toolResult} />;
|
||||||
if (name === "todowrite")
|
if (name === "todowrite")
|
||||||
return <TodoWriteWidget input={toolUse.input} result={toolResult} />;
|
return <TodoWriteWidget input={toolUse.input} result={toolResult} />;
|
||||||
|
|
||||||
|
|
@ -160,26 +166,27 @@ const ReadWidget: FC<{ input: any; result?: ContentBlock }> = ({
|
||||||
|
|
||||||
// ─── Bash Widget ───
|
// ─── Bash Widget ───
|
||||||
|
|
||||||
const BashWidget: FC<{ input: any; result?: ContentBlock }> = ({
|
const BashWidget: FC<{
|
||||||
input,
|
input: any;
|
||||||
result,
|
result?: ContentBlock;
|
||||||
}) => {
|
prefix?: string;
|
||||||
|
}> = ({ input, result, prefix = "$" }) => {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const command = input?.command || input?.description || "";
|
const command = input?.command || input?.description || "";
|
||||||
const resultContent =
|
const resultContent =
|
||||||
typeof result?.content === "string" ? result.content : "";
|
typeof result?.content === "string" ? result.content : "";
|
||||||
|
|
||||||
return (
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center gap-2 px-3 py-2"
|
className="flex w-full items-center gap-2 px-3 py-2"
|
||||||
onClick={() => setExpanded(!expanded)}
|
onClick={() => setExpanded(!expanded)}
|
||||||
>
|
>
|
||||||
<StatusIcon result={result} />
|
<StatusIcon result={result} />
|
||||||
<TerminalIcon className="size-3.5 shrink-0 text-green-400" />
|
<TerminalIcon className="size-3.5 shrink-0 text-emerald-600 dark:text-emerald-400" />
|
||||||
<code className="min-w-0 truncate text-green-300 text-xs">
|
<code className="min-w-0 truncate text-emerald-700 text-xs dark:text-emerald-300">
|
||||||
$ {truncate(command, 80)}
|
{prefix} {truncate(command, 80)}
|
||||||
</code>
|
</code>
|
||||||
{result &&
|
{result &&
|
||||||
(expanded ? (
|
(expanded ? (
|
||||||
|
|
@ -190,7 +197,7 @@ const BashWidget: FC<{ input: any; result?: ContentBlock }> = ({
|
||||||
</button>
|
</button>
|
||||||
{expanded && resultContent && (
|
{expanded && resultContent && (
|
||||||
<div className="max-h-40 overflow-auto border-border/50 border-t px-3 py-2">
|
<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)}
|
{truncate(resultContent, 2000)}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</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 ───
|
// ─── TodoWrite Widget ───
|
||||||
|
|
||||||
const TodoWriteWidget: FC<{ input: any; result?: ContentBlock }> = ({
|
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"
|
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="flex items-center gap-2">
|
||||||
<div className="relative">
|
<div className="flex size-5 items-center justify-center rounded-full bg-muted-foreground/10">
|
||||||
<BotIcon className="size-4 text-muted-foreground" />
|
<BrainIcon className="size-3.5 text-muted-foreground" />
|
||||||
<SparklesIcon className="absolute -top-1 -right-1 size-2.5 animate-pulse text-muted-foreground/70" />
|
|
||||||
</div>
|
</div>
|
||||||
<span className="font-medium text-muted-foreground text-sm italic">
|
<span className="font-medium text-muted-foreground text-sm italic">
|
||||||
Thinking...
|
Thinking...
|
||||||
|
|
|
||||||
509
apps/desktop/src/components/environment-onboarding.tsx
Normal file
|
|
@ -0,0 +1,509 @@
|
||||||
|
import {
|
||||||
|
type ComponentType,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import { open as shellOpen } from "@tauri-apps/plugin-shell";
|
||||||
|
import {
|
||||||
|
AlertCircleIcon,
|
||||||
|
CheckCircle2Icon,
|
||||||
|
CircleIcon,
|
||||||
|
DownloadIcon,
|
||||||
|
FlaskConicalIcon,
|
||||||
|
GitBranchIcon,
|
||||||
|
KeyRoundIcon,
|
||||||
|
Loader2Icon,
|
||||||
|
RefreshCwIcon,
|
||||||
|
TerminalIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { ClaudeSetup } from "@/components/claude-setup";
|
||||||
|
import { useClaudeSetupStore } from "@/stores/claude-setup-store";
|
||||||
|
import { useUvSetupStore } from "@/stores/uv-setup-store";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type SetupItemState = "ready" | "loading" | "blocked" | "error";
|
||||||
|
|
||||||
|
interface SkillsStatus {
|
||||||
|
installed: boolean;
|
||||||
|
skill_count: number;
|
||||||
|
location: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EnvironmentOnboarding() {
|
||||||
|
const [initialCheckComplete, setInitialCheckComplete] = useState(false);
|
||||||
|
const keepOpenDuringCheckRef = useRef(false);
|
||||||
|
const [hasOpenedForSetup, setHasOpenedForSetup] = useState(false);
|
||||||
|
const [completedDismissed, setCompletedDismissed] = useState(false);
|
||||||
|
const [providerDialogOpen, setProviderDialogOpen] = useState(false);
|
||||||
|
const [skillsStatus, setSkillsStatus] = useState<SkillsStatus | null>(null);
|
||||||
|
const [skillsChecking, setSkillsChecking] = useState(true);
|
||||||
|
const [skillsError, setSkillsError] = useState<string | null>(null);
|
||||||
|
const [skillsDialogOpen, setSkillsDialogOpen] = useState(false);
|
||||||
|
const [SkillsOnboardingComponent, setSkillsOnboardingComponent] =
|
||||||
|
useState<ComponentType<{ onClose: () => void }> | null>(null);
|
||||||
|
|
||||||
|
const claudeStatus = useClaudeSetupStore((s) => s.status);
|
||||||
|
const claudeVersion = useClaudeSetupStore((s) => s.version);
|
||||||
|
const claudeError = useClaudeSetupStore((s) => s.error);
|
||||||
|
const isClaudeInstalling = useClaudeSetupStore((s) => s.isInstalling);
|
||||||
|
const providerKind = useClaudeSetupStore((s) => s.providerKind);
|
||||||
|
const claudeProviderConfigured = useClaudeSetupStore(
|
||||||
|
(s) => s.claudeProviderConfigured,
|
||||||
|
);
|
||||||
|
const openAiCredentials = useClaudeSetupStore((s) => s.openAiCredentials);
|
||||||
|
const checkClaudeStatus = useClaudeSetupStore((s) => s.checkStatus);
|
||||||
|
const installClaude = useClaudeSetupStore((s) => s.install);
|
||||||
|
|
||||||
|
const uvStatus = useUvSetupStore((s) => s.status);
|
||||||
|
const uvVersion = useUvSetupStore((s) => s.version);
|
||||||
|
const uvError = useUvSetupStore((s) => s.error);
|
||||||
|
const isUvInstalling = useUvSetupStore((s) => s.isInstalling);
|
||||||
|
const checkUvStatus = useUvSetupStore((s) => s.checkStatus);
|
||||||
|
const installUv = useUvSetupStore((s) => s.install);
|
||||||
|
const finishUvInstall = useUvSetupStore((s) => s._finishInstall);
|
||||||
|
|
||||||
|
const checkSkillsStatus = useCallback(async () => {
|
||||||
|
setSkillsChecking(true);
|
||||||
|
setSkillsError(null);
|
||||||
|
try {
|
||||||
|
const status = await invoke<SkillsStatus>("check_skills_installed", {
|
||||||
|
projectPath: null,
|
||||||
|
});
|
||||||
|
setSkillsStatus(status);
|
||||||
|
} catch (err) {
|
||||||
|
setSkillsStatus(null);
|
||||||
|
setSkillsError(String(err));
|
||||||
|
} finally {
|
||||||
|
setSkillsChecking(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
Promise.allSettled([
|
||||||
|
checkClaudeStatus(),
|
||||||
|
checkUvStatus(),
|
||||||
|
checkSkillsStatus(),
|
||||||
|
]).finally(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setInitialCheckComplete(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [checkClaudeStatus, checkSkillsStatus, checkUvStatus]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unlisten = listen<boolean>("uv-install-complete", (event) => {
|
||||||
|
finishUvInstall(event.payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unlisten.then((fn) => fn());
|
||||||
|
};
|
||||||
|
}, [finishUvInstall]);
|
||||||
|
|
||||||
|
const isClaudeInstalled =
|
||||||
|
claudeStatus === "ready" || claudeStatus === "not-authenticated";
|
||||||
|
const isClaudeReady = claudeStatus === "ready";
|
||||||
|
const isUvReady = uvStatus === "ready";
|
||||||
|
const isSkillsReady = !!skillsStatus?.installed;
|
||||||
|
const claudeNeedsAttention =
|
||||||
|
isClaudeInstalling || (claudeStatus !== "checking" && !isClaudeReady);
|
||||||
|
const uvNeedsAttention =
|
||||||
|
isUvInstalling || (uvStatus !== "checking" && !isUvReady);
|
||||||
|
const skillsNeedsAttention =
|
||||||
|
!skillsChecking && (!isSkillsReady || !!skillsError);
|
||||||
|
const needsAttention =
|
||||||
|
claudeNeedsAttention || uvNeedsAttention || skillsNeedsAttention;
|
||||||
|
const isCheckingSetup =
|
||||||
|
claudeStatus === "checking" || uvStatus === "checking" || skillsChecking;
|
||||||
|
const setupComplete =
|
||||||
|
initialCheckComplete && !needsAttention && !isCheckingSetup;
|
||||||
|
const shouldShow =
|
||||||
|
initialCheckComplete &&
|
||||||
|
!completedDismissed &&
|
||||||
|
(needsAttention ||
|
||||||
|
(isCheckingSetup && keepOpenDuringCheckRef.current) ||
|
||||||
|
hasOpenedForSetup);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (needsAttention) {
|
||||||
|
keepOpenDuringCheckRef.current = true;
|
||||||
|
setHasOpenedForSetup(true);
|
||||||
|
setCompletedDismissed(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isCheckingSetup) {
|
||||||
|
keepOpenDuringCheckRef.current = false;
|
||||||
|
}
|
||||||
|
}, [isCheckingSetup, needsAttention]);
|
||||||
|
|
||||||
|
const handleDone = () => {
|
||||||
|
if (!setupComplete) return;
|
||||||
|
keepOpenDuringCheckRef.current = false;
|
||||||
|
setHasOpenedForSetup(false);
|
||||||
|
setCompletedDismissed(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openSkillsDialog = () => {
|
||||||
|
setSkillsDialogOpen(true);
|
||||||
|
if (!SkillsOnboardingComponent) {
|
||||||
|
import(
|
||||||
|
"@/components/scientific-skills/scientific-skills-onboarding"
|
||||||
|
).then((mod) =>
|
||||||
|
setSkillsOnboardingComponent(() => mod.ScientificSkillsOnboarding),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const providerDetail = useMemo(() => {
|
||||||
|
if (!isClaudeInstalled) {
|
||||||
|
return "Install Claude Code before adding a provider";
|
||||||
|
}
|
||||||
|
if (!isClaudeReady) {
|
||||||
|
return "Add an API key or sign in";
|
||||||
|
}
|
||||||
|
const openAiProviderCount = Math.max(
|
||||||
|
openAiCredentials.length,
|
||||||
|
providerKind === "openai-compatible" ? 1 : 0,
|
||||||
|
);
|
||||||
|
const includesClaudeProvider =
|
||||||
|
claudeProviderConfigured || providerKind === "claude-code";
|
||||||
|
const count = openAiProviderCount + (includesClaudeProvider ? 1 : 0);
|
||||||
|
return `${count} provider${count === 1 ? "" : "s"} configured`;
|
||||||
|
}, [
|
||||||
|
claudeProviderConfigured,
|
||||||
|
isClaudeInstalled,
|
||||||
|
isClaudeReady,
|
||||||
|
openAiCredentials.length,
|
||||||
|
providerKind,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={shouldShow} onOpenChange={() => undefined}>
|
||||||
|
<DialogContent
|
||||||
|
showCloseButton={false}
|
||||||
|
onEscapeKeyDown={(event) => event.preventDefault()}
|
||||||
|
onInteractOutside={(event) => event.preventDefault()}
|
||||||
|
className="w-[min(29rem,calc(100vw-2rem))] gap-0 overflow-hidden rounded-2xl border-border/70 p-0 shadow-xl sm:max-w-none"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center px-6 pt-6 pb-4 text-center">
|
||||||
|
<img
|
||||||
|
src="/icon-192.png"
|
||||||
|
alt="ClaudePrism"
|
||||||
|
className="size-14 object-contain"
|
||||||
|
/>
|
||||||
|
<DialogHeader className="mt-3 items-center gap-1.5 text-center">
|
||||||
|
<DialogTitle className="font-semibold text-xl">
|
||||||
|
ClaudePrism
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="max-w-sm text-sm leading-relaxed">
|
||||||
|
Set up the local tools and model provider required before
|
||||||
|
entering the workspace.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-4.5 pb-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<SetupItem
|
||||||
|
state={
|
||||||
|
isClaudeInstalling || claudeStatus === "checking"
|
||||||
|
? "loading"
|
||||||
|
: claudeStatus === "error"
|
||||||
|
? "error"
|
||||||
|
: isClaudeInstalled
|
||||||
|
? "ready"
|
||||||
|
: "blocked"
|
||||||
|
}
|
||||||
|
icon={TerminalIcon}
|
||||||
|
title="Claude Code"
|
||||||
|
detail={
|
||||||
|
isClaudeInstalling
|
||||||
|
? "Installing..."
|
||||||
|
: claudeStatus === "checking"
|
||||||
|
? "Checking..."
|
||||||
|
: claudeStatus === "missing-git"
|
||||||
|
? "Git for Windows is required first"
|
||||||
|
: claudeStatus === "not-installed"
|
||||||
|
? "Required for AI writing"
|
||||||
|
: claudeStatus === "error"
|
||||||
|
? claudeError || "Installation needs attention"
|
||||||
|
: claudeVersion
|
||||||
|
? `Installed ${claudeVersion}`
|
||||||
|
: "Installed"
|
||||||
|
}
|
||||||
|
action={
|
||||||
|
claudeStatus === "missing-git"
|
||||||
|
? {
|
||||||
|
label: "Git",
|
||||||
|
icon: GitBranchIcon,
|
||||||
|
onClick: () => {
|
||||||
|
shellOpen("https://git-scm.com/downloads/win");
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: claudeStatus === "not-installed" ||
|
||||||
|
claudeStatus === "error"
|
||||||
|
? {
|
||||||
|
label: isClaudeInstalling ? "Installing" : "Install",
|
||||||
|
icon: isClaudeInstalling ? Loader2Icon : DownloadIcon,
|
||||||
|
loading: isClaudeInstalling,
|
||||||
|
onClick: installClaude,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
label: "Check",
|
||||||
|
icon: RefreshCwIcon,
|
||||||
|
onClick: checkClaudeStatus,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SetupItem
|
||||||
|
state={
|
||||||
|
isUvInstalling || uvStatus === "checking"
|
||||||
|
? "loading"
|
||||||
|
: uvStatus === "error"
|
||||||
|
? "error"
|
||||||
|
: isUvReady
|
||||||
|
? "ready"
|
||||||
|
: "blocked"
|
||||||
|
}
|
||||||
|
icon={TerminalIcon}
|
||||||
|
title="Python (uv)"
|
||||||
|
detail={
|
||||||
|
isUvInstalling
|
||||||
|
? "Installing..."
|
||||||
|
: uvStatus === "checking"
|
||||||
|
? "Checking..."
|
||||||
|
: uvStatus === "not-installed"
|
||||||
|
? "Required for Python workflows"
|
||||||
|
: uvStatus === "error"
|
||||||
|
? uvError || "Installation needs attention"
|
||||||
|
: uvVersion || "Installed"
|
||||||
|
}
|
||||||
|
action={
|
||||||
|
uvStatus === "not-installed" || uvStatus === "error"
|
||||||
|
? {
|
||||||
|
label: isUvInstalling ? "Installing" : "Install",
|
||||||
|
icon: isUvInstalling ? Loader2Icon : DownloadIcon,
|
||||||
|
loading: isUvInstalling,
|
||||||
|
onClick: installUv,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
label: "Check",
|
||||||
|
icon: RefreshCwIcon,
|
||||||
|
onClick: checkUvStatus,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SetupItem
|
||||||
|
state={
|
||||||
|
claudeStatus === "checking"
|
||||||
|
? "loading"
|
||||||
|
: claudeStatus === "error"
|
||||||
|
? "error"
|
||||||
|
: isClaudeReady
|
||||||
|
? "ready"
|
||||||
|
: "blocked"
|
||||||
|
}
|
||||||
|
icon={KeyRoundIcon}
|
||||||
|
title="AI Provider"
|
||||||
|
detail={providerDetail}
|
||||||
|
action={
|
||||||
|
isClaudeInstalled
|
||||||
|
? {
|
||||||
|
label: isClaudeReady ? "Manage" : "Configure",
|
||||||
|
icon: KeyRoundIcon,
|
||||||
|
onClick: () => setProviderDialogOpen(true),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
label: "Locked",
|
||||||
|
icon: KeyRoundIcon,
|
||||||
|
disabled: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SetupItem
|
||||||
|
state={
|
||||||
|
skillsChecking
|
||||||
|
? "loading"
|
||||||
|
: skillsError
|
||||||
|
? "error"
|
||||||
|
: isSkillsReady
|
||||||
|
? "ready"
|
||||||
|
: "blocked"
|
||||||
|
}
|
||||||
|
icon={FlaskConicalIcon}
|
||||||
|
title="Scientific Skills"
|
||||||
|
detail={
|
||||||
|
skillsChecking
|
||||||
|
? "Checking..."
|
||||||
|
: skillsError
|
||||||
|
? skillsError
|
||||||
|
: isSkillsReady
|
||||||
|
? `${skillsStatus?.skill_count ?? 0} skills installed`
|
||||||
|
: "Required for scientific writing"
|
||||||
|
}
|
||||||
|
action={
|
||||||
|
skillsError
|
||||||
|
? {
|
||||||
|
label: "Check",
|
||||||
|
icon: RefreshCwIcon,
|
||||||
|
onClick: checkSkillsStatus,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
label: isSkillsReady ? "Manage" : "Install",
|
||||||
|
icon: isSkillsReady ? FlaskConicalIcon : DownloadIcon,
|
||||||
|
onClick: openSkillsDialog,
|
||||||
|
disabled: skillsChecking,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-center px-6 pt-1 pb-4">
|
||||||
|
<Button
|
||||||
|
disabled={!setupComplete}
|
||||||
|
className="h-10 min-w-28 justify-center rounded-full px-7"
|
||||||
|
onClick={handleDone}
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={providerDialogOpen} onOpenChange={setProviderDialogOpen}>
|
||||||
|
<DialogContent className="max-h-[85vh] w-[min(42rem,calc(100vw-2rem))] overflow-y-auto overflow-x-hidden sm:max-w-none">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add AI Provider</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Configure Anthropic or another model provider for this project.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<ClaudeSetup
|
||||||
|
variant="provider-dialog"
|
||||||
|
onCancel={() => setProviderDialogOpen(false)}
|
||||||
|
onSaved={() => {
|
||||||
|
setProviderDialogOpen(false);
|
||||||
|
checkClaudeStatus();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{skillsDialogOpen && SkillsOnboardingComponent && (
|
||||||
|
<SkillsOnboardingComponent
|
||||||
|
onClose={() => {
|
||||||
|
setSkillsDialogOpen(false);
|
||||||
|
checkSkillsStatus();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SetupItem({
|
||||||
|
state,
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
detail,
|
||||||
|
action,
|
||||||
|
}: {
|
||||||
|
state: SetupItemState;
|
||||||
|
icon: typeof TerminalIcon;
|
||||||
|
title: string;
|
||||||
|
detail: string;
|
||||||
|
action?: {
|
||||||
|
label: string;
|
||||||
|
icon: typeof TerminalIcon;
|
||||||
|
onClick?: () => void;
|
||||||
|
loading?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
const ActionIcon = action?.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid min-h-[3.75rem] w-full max-w-full grid-cols-[2.25rem_minmax(0,1fr)_auto] items-center gap-2.5 overflow-hidden rounded-xl bg-muted/35 px-3 py-2.5 transition-colors hover:bg-muted/50">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex size-9 shrink-0 items-center justify-center",
|
||||||
|
state === "ready" && "text-green-600 dark:text-green-400",
|
||||||
|
state === "loading" && "text-muted-foreground",
|
||||||
|
state === "blocked" && "text-muted-foreground/75",
|
||||||
|
state === "error" && "text-destructive",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{state === "ready" ? (
|
||||||
|
<CheckCircle2Icon className="size-6" />
|
||||||
|
) : state === "loading" ? (
|
||||||
|
<Loader2Icon className="size-5 animate-spin" />
|
||||||
|
) : state === "error" ? (
|
||||||
|
<AlertCircleIcon className="size-5" />
|
||||||
|
) : (
|
||||||
|
<Icon className="size-5" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="truncate font-semibold text-sm">{title}</span>
|
||||||
|
{state === "blocked" && (
|
||||||
|
<CircleIcon className="size-2.5 shrink-0 text-muted-foreground/50" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"mt-0.5 truncate text-sm",
|
||||||
|
state === "error" ? "text-destructive" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
title={detail}
|
||||||
|
>
|
||||||
|
{detail}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{action && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={state === "ready" ? "outline" : "default"}
|
||||||
|
size="sm"
|
||||||
|
className="h-8 max-w-24 shrink-0 gap-1.5 rounded-lg px-2.5 text-sm"
|
||||||
|
onClick={action.onClick}
|
||||||
|
disabled={action.disabled || action.loading}
|
||||||
|
>
|
||||||
|
{ActionIcon && (
|
||||||
|
<ActionIcon
|
||||||
|
className={cn("size-3.5", action.loading && "animate-spin")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{action.label}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
apps/desktop/src/components/model-capability-badges.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { EyeIcon, MessageCircleIcon } from "lucide-react";
|
||||||
|
import { getModelCapabilities } from "@/lib/model-capabilities";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ModelCapabilityBadgesProps {
|
||||||
|
label?: string | null;
|
||||||
|
baseUrl?: string | null;
|
||||||
|
model?: string | null;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelCapabilityBadges({
|
||||||
|
label,
|
||||||
|
baseUrl,
|
||||||
|
model,
|
||||||
|
className,
|
||||||
|
}: ModelCapabilityBadgesProps) {
|
||||||
|
const capabilities = getModelCapabilities({ label, baseUrl, model });
|
||||||
|
const badgeClass =
|
||||||
|
"inline-flex h-5 items-center justify-center rounded-md border border-neutral-300 bg-white text-neutral-600 dark:border-neutral-600 dark:bg-neutral-950 dark:text-neutral-300";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn("inline-flex shrink-0 items-center gap-0.5", className)}
|
||||||
|
>
|
||||||
|
{capabilities.chat && (
|
||||||
|
<span className={cn(badgeClass, "min-w-5 px-1")} title="Chat model">
|
||||||
|
<MessageCircleIcon className="size-3" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{capabilities.vision && (
|
||||||
|
<span className={cn(badgeClass, "min-w-5 px-1")} title="Vision input">
|
||||||
|
<EyeIcon className="size-3" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import { open } from "@tauri-apps/plugin-dialog";
|
||||||
import { mkdir, writeTextFile } from "@tauri-apps/plugin-fs";
|
import { mkdir, writeTextFile } from "@tauri-apps/plugin-fs";
|
||||||
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
||||||
import { homeDir } from "@tauri-apps/api/path";
|
import { homeDir } from "@tauri-apps/api/path";
|
||||||
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
FolderOpenIcon,
|
FolderOpenIcon,
|
||||||
|
|
@ -29,40 +30,14 @@ import {
|
||||||
} from "@/lib/template-registry";
|
} from "@/lib/template-registry";
|
||||||
import { TemplateGallery } from "@/components/template-gallery";
|
import { TemplateGallery } from "@/components/template-gallery";
|
||||||
import { DEFAULT_CLAUDE_MD } from "@/lib/default-claude-md";
|
import { DEFAULT_CLAUDE_MD } from "@/lib/default-claude-md";
|
||||||
|
import {
|
||||||
|
buildReferenceFilesSection,
|
||||||
|
importReferenceFiles,
|
||||||
|
} from "@/lib/project-attachments";
|
||||||
|
import { getProjectNameError, normalizeProjectName } from "@/lib/project-name";
|
||||||
|
|
||||||
// ─── Helpers ───
|
// ─── Helpers ───
|
||||||
|
|
||||||
function randomProjectName(): string {
|
|
||||||
const adjectives = [
|
|
||||||
"swift",
|
|
||||||
"bright",
|
|
||||||
"calm",
|
|
||||||
"bold",
|
|
||||||
"keen",
|
|
||||||
"warm",
|
|
||||||
"pure",
|
|
||||||
"vast",
|
|
||||||
"deep",
|
|
||||||
"fair",
|
|
||||||
];
|
|
||||||
const nouns = [
|
|
||||||
"paper",
|
|
||||||
"draft",
|
|
||||||
"thesis",
|
|
||||||
"note",
|
|
||||||
"study",
|
|
||||||
"essay",
|
|
||||||
"report",
|
|
||||||
"brief",
|
|
||||||
"folio",
|
|
||||||
"opus",
|
|
||||||
];
|
|
||||||
const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
|
|
||||||
const noun = nouns[Math.floor(Math.random() * nouns.length)];
|
|
||||||
const id = Math.random().toString(36).slice(2, 6);
|
|
||||||
return `${adj}-${noun}-${id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Wizard Component ───
|
// ─── Wizard Component ───
|
||||||
|
|
||||||
export type CreationMode = "template" | "scratch";
|
export type CreationMode = "template" | "scratch";
|
||||||
|
|
@ -106,12 +81,14 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
|
||||||
const [purpose, setPurpose] = useState("");
|
const [purpose, setPurpose] = useState("");
|
||||||
const [attachments, setAttachments] = useState<string[]>([]);
|
const [attachments, setAttachments] = useState<string[]>([]);
|
||||||
const [projectFolder, setProjectFolder] = useState<string | null>(null);
|
const [projectFolder, setProjectFolder] = useState<string | null>(null);
|
||||||
const [projectName, setProjectName] = useState(randomProjectName);
|
const [projectName, setProjectName] = useState("");
|
||||||
|
const [projectNameError, setProjectNameError] = useState("");
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
const [refFilesOpen, setRefFilesOpen] = useState(false);
|
const [refFilesOpen, setRefFilesOpen] = useState(false);
|
||||||
const [locationOpen, setLocationOpen] = useState(false);
|
const [locationOpen, setLocationOpen] = useState(false);
|
||||||
|
|
||||||
|
const projectNameRef = useRef<HTMLInputElement>(null);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
const addRecentProject = useProjectStore((s) => s.addRecentProject);
|
const addRecentProject = useProjectStore((s) => s.addRecentProject);
|
||||||
|
|
@ -122,7 +99,7 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
|
||||||
const template = getTemplateById("blank")!;
|
const template = getTemplateById("blank")!;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => textareaRef.current?.focus(), 100);
|
const timer = setTimeout(() => projectNameRef.current?.focus(), 100);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -133,11 +110,13 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
|
||||||
} else {
|
} else {
|
||||||
homeDir()
|
homeDir()
|
||||||
.then((home) => join(home, "Documents", "ClaudePrism"))
|
.then((home) => join(home, "Documents", "ClaudePrism"))
|
||||||
.then((dir) => {
|
.then(async (dir) => {
|
||||||
mkdir(dir, { recursive: true }).catch(() => {});
|
await mkdir(dir, { recursive: true }).catch(() => {});
|
||||||
setProjectFolder(dir);
|
setProjectFolder(dir);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch((err) =>
|
||||||
|
console.warn("Failed to resolve default project folder:", err),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
|
@ -157,26 +136,6 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
|
||||||
const selected = await open({
|
const selected = await open({
|
||||||
multiple: true,
|
multiple: true,
|
||||||
title: "Add Reference Files",
|
title: "Add Reference Files",
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
name: "Documents & Images",
|
|
||||||
extensions: [
|
|
||||||
"pdf",
|
|
||||||
"tex",
|
|
||||||
"bib",
|
|
||||||
"txt",
|
|
||||||
"md",
|
|
||||||
"png",
|
|
||||||
"jpg",
|
|
||||||
"jpeg",
|
|
||||||
"gif",
|
|
||||||
"svg",
|
|
||||||
"csv",
|
|
||||||
"tsv",
|
|
||||||
"json",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
if (selected) {
|
if (selected) {
|
||||||
const paths = Array.isArray(selected) ? selected : [selected];
|
const paths = Array.isArray(selected) ? selected : [selected];
|
||||||
|
|
@ -229,12 +188,21 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!template || !projectFolder || !projectName.trim()) return;
|
const name = normalizeProjectName(projectName);
|
||||||
|
const nameError = getProjectNameError(projectName);
|
||||||
|
if (!template || !projectFolder || nameError) {
|
||||||
|
setProjectNameError(nameError ?? "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setIsCreating(true);
|
setIsCreating(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const projectPath = await join(projectFolder, projectName.trim());
|
const projectPath = await join(projectFolder, name);
|
||||||
await mkdir(projectPath, { recursive: true }).catch(() => {});
|
if (await exists(projectPath)) {
|
||||||
|
setProjectNameError("A folder with this name already exists here");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await mkdir(projectPath, { recursive: true });
|
||||||
|
|
||||||
// Create CLAUDE.md for Claude Code context
|
// Create CLAUDE.md for Claude Code context
|
||||||
const claudeMdPath = await join(projectPath, "CLAUDE.md");
|
const claudeMdPath = await join(projectPath, "CLAUDE.md");
|
||||||
|
|
@ -257,19 +225,13 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attachments.length > 0) {
|
const referenceFiles =
|
||||||
const attachmentsDir = await join(projectPath, "attachments");
|
attachments.length > 0
|
||||||
await mkdir(attachmentsDir, { recursive: true }).catch(() => {});
|
? await importReferenceFiles(projectPath, attachments)
|
||||||
}
|
: [];
|
||||||
|
|
||||||
if (purpose.trim()) {
|
if (purpose.trim()) {
|
||||||
const attachmentNames = attachments
|
const attachmentSection = buildReferenceFilesSection(referenceFiles);
|
||||||
.map((p) => p.split("/").pop())
|
|
||||||
.filter(Boolean);
|
|
||||||
const attachmentSection =
|
|
||||||
attachmentNames.length > 0
|
|
||||||
? `\n### Reference Files\n${attachmentNames.map((n) => `- \`${n}\``).join("\n")}\n\nPlease review them and incorporate relevant information.\n`
|
|
||||||
: "";
|
|
||||||
|
|
||||||
const prompt = [
|
const prompt = [
|
||||||
`## New ${template.name} Project`,
|
`## New ${template.name} Project`,
|
||||||
|
|
@ -295,20 +257,19 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
|
||||||
setLastProjectFolder(projectFolder);
|
setLastProjectFolder(projectFolder);
|
||||||
addRecentProject(projectPath);
|
addRecentProject(projectPath);
|
||||||
await openProject(projectPath);
|
await openProject(projectPath);
|
||||||
|
|
||||||
if (attachments.length > 0) {
|
|
||||||
await useDocumentStore
|
|
||||||
.getState()
|
|
||||||
.importFiles(attachments, "attachments");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to create project:", err);
|
console.error("Failed to create project:", err);
|
||||||
|
toast.error("Failed to create project", {
|
||||||
|
description: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const canCreate = template && projectFolder && projectName.trim();
|
const canCreate = Boolean(
|
||||||
|
template && projectFolder && !getProjectNameError(projectName),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col bg-background">
|
<div className="flex h-full flex-col bg-background">
|
||||||
|
|
@ -328,6 +289,32 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
|
||||||
{/* Form */}
|
{/* Form */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="mx-auto w-full max-w-[520px] space-y-4 px-6 py-10">
|
<div className="mx-auto w-full max-w-[520px] space-y-4 px-6 py-10">
|
||||||
|
{/* Project name */}
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold text-sm">Project name</span>
|
||||||
|
<p className="mt-0.5 text-muted-foreground text-xs leading-relaxed">
|
||||||
|
This becomes the folder name on disk.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
ref={projectNameRef}
|
||||||
|
placeholder="e.g., flashvid-paper"
|
||||||
|
value={projectName}
|
||||||
|
onChange={(e) => {
|
||||||
|
setProjectName(e.target.value);
|
||||||
|
setProjectNameError("");
|
||||||
|
}}
|
||||||
|
onBlur={() =>
|
||||||
|
setProjectNameError(getProjectNameError(projectName) ?? "")
|
||||||
|
}
|
||||||
|
className="rounded-xl border-border/60 bg-card/30 text-sm focus-visible:bg-card/50"
|
||||||
|
/>
|
||||||
|
{projectNameError && (
|
||||||
|
<p className="text-destructive text-xs">{projectNameError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Purpose */}
|
{/* Purpose */}
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-2.5">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -383,7 +370,7 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
|
||||||
>
|
>
|
||||||
<PaperclipIcon className="size-3 shrink-0 text-muted-foreground/70" />
|
<PaperclipIcon className="size-3 shrink-0 text-muted-foreground/70" />
|
||||||
<span className="max-w-[140px] truncate text-foreground/80">
|
<span className="max-w-[140px] truncate text-foreground/80">
|
||||||
{path.split("/").pop()}
|
{path.split(/[/\\]/).pop()}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleRemoveAttachment(path)}
|
onClick={() => handleRemoveAttachment(path)}
|
||||||
|
|
@ -447,7 +434,8 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
|
||||||
</div>
|
</div>
|
||||||
{!locationOpen && projectFolder && projectName.trim() && (
|
{!locationOpen && projectFolder && projectName.trim() && (
|
||||||
<span className="min-w-0 max-w-[180px] truncate rounded-md bg-muted/40 px-2 py-0.5 font-mono text-[11px] text-muted-foreground/60">
|
<span className="min-w-0 max-w-[180px] truncate rounded-md bg-muted/40 px-2 py-0.5 font-mono text-[11px] text-muted-foreground/60">
|
||||||
.../{projectFolder.split("/").pop()}/{projectName.trim()}
|
.../{projectFolder.split(/[/\\]/).pop()}/
|
||||||
|
{normalizeProjectName(projectName)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<ChevronDownIcon
|
<ChevronDownIcon
|
||||||
|
|
@ -456,13 +444,12 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
|
||||||
</button>
|
</button>
|
||||||
{locationOpen && (
|
{locationOpen && (
|
||||||
<div className="space-y-2.5 px-4 pb-4">
|
<div className="space-y-2.5 px-4 pb-4">
|
||||||
<div className="flex gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<p className="min-w-0 flex-1 truncate rounded-md bg-muted/30 px-2.5 py-1.5 font-mono text-[11px] text-muted-foreground/60">
|
||||||
placeholder="Project name"
|
{projectFolder
|
||||||
value={projectName}
|
? `${projectFolder}/${normalizeProjectName(projectName) || "..."}`
|
||||||
onChange={(e) => setProjectName(e.target.value)}
|
: "Choose a location"}
|
||||||
className="flex-1 rounded-lg border-border/60 bg-background/50"
|
</p>
|
||||||
/>
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -473,11 +460,6 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
|
||||||
{projectFolder ? "Change" : "Choose"}
|
{projectFolder ? "Change" : "Choose"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{projectFolder && (
|
|
||||||
<p className="truncate rounded-md bg-muted/30 px-2.5 py-1.5 font-mono text-[11px] text-muted-foreground/60">
|
|
||||||
{projectFolder}/{projectName.trim() || "..."}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useMemo } from "react";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
|
|
||||||
const PHASE_MAP: Record<string, number> = {
|
const PHASE_MAP: Record<string, number> = {
|
||||||
|
"Preparing installer": 0,
|
||||||
"Checking directory permissions...": 5,
|
"Checking directory permissions...": 5,
|
||||||
"Directory permissions OK": 10,
|
"Directory permissions OK": 10,
|
||||||
"Git available": 15,
|
"Git available": 15,
|
||||||
"cloning repository": 20,
|
"cloning repository": 20,
|
||||||
|
"Downloading skills": 20,
|
||||||
"downloading tarball": 20,
|
"downloading tarball": 20,
|
||||||
"Download complete": 60,
|
"Download complete": 60,
|
||||||
"Copying skills": 70,
|
"Copying skills": 70,
|
||||||
|
|
@ -16,6 +16,18 @@ const PHASE_MAP: Record<string, number> = {
|
||||||
};
|
};
|
||||||
|
|
||||||
function pctFromLog(log: string): number | null {
|
function pctFromLog(log: string): number | null {
|
||||||
|
const downloadMatch = log.match(/^Download progress\s+(\d+)%/i);
|
||||||
|
if (downloadMatch?.[1]) {
|
||||||
|
const downloadPct = Math.max(0, Math.min(100, Number(downloadMatch[1])));
|
||||||
|
return Math.round(20 + downloadPct * 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadedMatch = log.match(/^Downloaded\s+(\d+)\s+MiB/i);
|
||||||
|
if (downloadedMatch?.[1]) {
|
||||||
|
const mib = Math.max(0, Number(downloadedMatch[1]));
|
||||||
|
return Math.min(55, 20 + mib);
|
||||||
|
}
|
||||||
|
|
||||||
for (const [key, pct] of Object.entries(PHASE_MAP)) {
|
for (const [key, pct] of Object.entries(PHASE_MAP)) {
|
||||||
if (log.toLowerCase().includes(key.toLowerCase())) return pct;
|
if (log.toLowerCase().includes(key.toLowerCase())) return pct;
|
||||||
}
|
}
|
||||||
|
|
@ -26,37 +38,21 @@ interface InstallProgressProps {
|
||||||
isInstalling: boolean;
|
isInstalling: boolean;
|
||||||
isComplete: boolean;
|
isComplete: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
logs: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InstallProgress({
|
export function InstallProgress({
|
||||||
isInstalling,
|
|
||||||
isComplete,
|
isComplete,
|
||||||
error,
|
error,
|
||||||
|
logs,
|
||||||
}: InstallProgressProps) {
|
}: InstallProgressProps) {
|
||||||
const [logs, setLogs] = useState<string[]>([]);
|
const pct = useMemo(() => {
|
||||||
const [pct, setPct] = useState(0);
|
if (isComplete) return 100;
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
return logs.reduce((current, line) => {
|
||||||
|
const next = pctFromLog(line);
|
||||||
useEffect(() => {
|
return next === null ? current : Math.max(current, next);
|
||||||
if (!isInstalling) return;
|
}, 0);
|
||||||
const unlisten = listen<string>("skills-install-log", (event) => {
|
}, [isComplete, logs]);
|
||||||
setLogs((prev) => [...prev, event.payload]);
|
|
||||||
const p = pctFromLog(event.payload);
|
|
||||||
if (p !== null) setPct(p);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
unlisten.then((fn) => fn());
|
|
||||||
};
|
|
||||||
}, [isInstalling]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isComplete) setPct(100);
|
|
||||||
}, [isComplete]);
|
|
||||||
|
|
||||||
// Auto-scroll to bottom
|
|
||||||
useEffect(() => {
|
|
||||||
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
|
|
||||||
}, [logs]);
|
|
||||||
|
|
||||||
const label = isComplete
|
const label = isComplete
|
||||||
? "Done"
|
? "Done"
|
||||||
|
|
@ -77,18 +73,6 @@ export function InstallProgress({
|
||||||
{pct}%
|
{pct}%
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{logs.length > 0 && (
|
|
||||||
<ScrollArea className="h-28 rounded-md border border-border/60 bg-muted/30">
|
|
||||||
<div
|
|
||||||
ref={scrollRef}
|
|
||||||
className="p-2 font-mono text-[11px] text-muted-foreground leading-relaxed"
|
|
||||||
>
|
|
||||||
{logs.map((line, i) => (
|
|
||||||
<div key={i}>{line}</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
FlaskConicalIcon,
|
FlaskConicalIcon,
|
||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
|
|
@ -10,6 +13,8 @@ import {
|
||||||
Trash2Icon,
|
Trash2Icon,
|
||||||
Loader2Icon,
|
Loader2Icon,
|
||||||
ChevronLeftIcon,
|
ChevronLeftIcon,
|
||||||
|
FolderPlusIcon,
|
||||||
|
XIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -45,6 +50,14 @@ interface SkillsStatus {
|
||||||
location: string;
|
location: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SkillInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
domain: string;
|
||||||
|
description: string;
|
||||||
|
folder: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface ScientificSkillsOnboardingProps {
|
interface ScientificSkillsOnboardingProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
@ -56,22 +69,60 @@ export function ScientificSkillsOnboarding({
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
const [isInstalling, setIsInstalling] = useState(false);
|
const [isInstalling, setIsInstalling] = useState(false);
|
||||||
const [isComplete, setIsComplete] = useState(false);
|
const [isComplete, setIsComplete] = useState(false);
|
||||||
|
const [installLogs, setInstallLogs] = useState<string[]>([]);
|
||||||
const [installResult, setInstallResult] = useState<InstallResult | null>(
|
const [installResult, setInstallResult] = useState<InstallResult | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [status, setStatus] = useState<SkillsStatus | null>(null);
|
const [status, setStatus] = useState<SkillsStatus | null>(null);
|
||||||
|
const [installedSkills, setInstalledSkills] = useState<SkillInfo[]>([]);
|
||||||
const [isUninstalling, setIsUninstalling] = useState(false);
|
const [isUninstalling, setIsUninstalling] = useState(false);
|
||||||
|
const [isImporting, setIsImporting] = useState(false);
|
||||||
|
const [confirmUninstallAllOpen, setConfirmUninstallAllOpen] = useState(false);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<SkillEntryData | null>(null);
|
||||||
|
const [deletingSkillFolder, setDeletingSkillFolder] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
const installBackendLogSeenRef = useRef(false);
|
||||||
|
|
||||||
// Check global install status
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unlisten = listen<string>("skills-install-log", (event) => {
|
||||||
|
installBackendLogSeenRef.current = true;
|
||||||
|
setInstallLogs((previous) => {
|
||||||
|
const last = previous[previous.length - 1];
|
||||||
|
if (last === event.payload) return previous;
|
||||||
|
return [...previous, event.payload];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unlisten.then((fn) => fn());
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Skills are global Claude Code assets under ~/.claude/skills.
|
||||||
const checkStatus = useCallback(async () => {
|
const checkStatus = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const gs = await invoke<SkillsStatus>("check_skills_installed", {
|
const [gs, skills] = await Promise.all([
|
||||||
|
invoke<SkillsStatus>("check_skills_installed", {
|
||||||
projectPath: null,
|
projectPath: null,
|
||||||
});
|
}),
|
||||||
|
invoke<SkillInfo[]>("list_installed_skills", {
|
||||||
|
projectPath: null,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
setStatus(gs);
|
setStatus(gs);
|
||||||
|
setInstalledSkills(skills);
|
||||||
} catch {
|
} catch {
|
||||||
setStatus(null);
|
setStatus(null);
|
||||||
|
setInstalledSkills([]);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -88,24 +139,88 @@ export function ScientificSkillsOnboarding({
|
||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const totalSkills = categories.reduce((sum, c) => sum + c.skill_count, 0);
|
const knownSkillFolders = new Set(
|
||||||
const selected = categories.find((c) => c.id === selectedId) ?? null;
|
categories.flatMap((category) =>
|
||||||
|
category.skills.map((skill) => skill.folder),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const importedSkills = installedSkills.filter(
|
||||||
|
(skill) => !knownSkillFolders.has(skill.folder),
|
||||||
|
);
|
||||||
|
const installedSkillFolders = new Set(
|
||||||
|
installedSkills.map((skill) => skill.folder),
|
||||||
|
);
|
||||||
|
const displayCategories: SkillCategoryData[] =
|
||||||
|
importedSkills.length > 0
|
||||||
|
? [
|
||||||
|
...categories,
|
||||||
|
{
|
||||||
|
id: "imported",
|
||||||
|
name: "Imported Skills",
|
||||||
|
icon: "settings",
|
||||||
|
skill_count: importedSkills.length,
|
||||||
|
skills: importedSkills.map((skill) => ({
|
||||||
|
name: skill.name,
|
||||||
|
folder: skill.folder,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: categories;
|
||||||
|
|
||||||
|
const totalSkills = displayCategories.reduce(
|
||||||
|
(sum, c) => sum + c.skill_count,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const selected =
|
||||||
|
displayCategories.find((c) => c.id === selectedId) ??
|
||||||
|
displayCategories[0] ??
|
||||||
|
null;
|
||||||
const isInstalled = status?.installed ?? false;
|
const isInstalled = status?.installed ?? false;
|
||||||
|
|
||||||
const handleInstall = useCallback(async () => {
|
const handleInstall = useCallback(async () => {
|
||||||
|
installBackendLogSeenRef.current = false;
|
||||||
|
let noBackendLogTimer: number | undefined;
|
||||||
|
setInstallLogs(["Preparing installer..."]);
|
||||||
setIsInstalling(true);
|
setIsInstalling(true);
|
||||||
|
setIsComplete(false);
|
||||||
|
setInstallResult(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
noBackendLogTimer = window.setTimeout(() => {
|
||||||
|
if (installBackendLogSeenRef.current || !mountedRef.current) return;
|
||||||
|
setInstallLogs((previous) => {
|
||||||
|
const hasBackendLog = previous.some(
|
||||||
|
(line) => !line.startsWith("Preparing installer"),
|
||||||
|
);
|
||||||
|
if (hasBackendLog) return previous;
|
||||||
|
return [...previous, "Waiting for the installer command to start..."];
|
||||||
|
});
|
||||||
|
}, 2500);
|
||||||
|
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, 150));
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
|
||||||
const result = await invoke<InstallResult>(
|
const result = await invoke<InstallResult>(
|
||||||
"install_scientific_skills_global",
|
"install_scientific_skills_global",
|
||||||
|
{},
|
||||||
);
|
);
|
||||||
|
if (noBackendLogTimer !== undefined) {
|
||||||
|
window.clearTimeout(noBackendLogTimer);
|
||||||
|
}
|
||||||
|
if (!mountedRef.current) return;
|
||||||
setInstallResult(result);
|
setInstallResult(result);
|
||||||
setIsComplete(true);
|
setIsComplete(true);
|
||||||
localStorage.setItem(STORAGE_KEY, "true");
|
localStorage.setItem(STORAGE_KEY, "true");
|
||||||
await checkStatus();
|
await checkStatus();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
if (noBackendLogTimer !== undefined) {
|
||||||
|
window.clearTimeout(noBackendLogTimer);
|
||||||
|
}
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
const message = String(e);
|
||||||
|
setInstallLogs((previous) => [...previous, message]);
|
||||||
|
setError(message);
|
||||||
setIsInstalling(false);
|
setIsInstalling(false);
|
||||||
}
|
}
|
||||||
}, [checkStatus]);
|
}, [checkStatus]);
|
||||||
|
|
@ -113,7 +228,9 @@ export function ScientificSkillsOnboarding({
|
||||||
const handleUninstall = useCallback(async () => {
|
const handleUninstall = useCallback(async () => {
|
||||||
setIsUninstalling(true);
|
setIsUninstalling(true);
|
||||||
try {
|
try {
|
||||||
await invoke("uninstall_scientific_skills", { projectPath: null });
|
await invoke("uninstall_scientific_skills", {
|
||||||
|
projectPath: null,
|
||||||
|
});
|
||||||
await checkStatus();
|
await checkStatus();
|
||||||
const gsAfter = await invoke<SkillsStatus>("check_skills_installed", {
|
const gsAfter = await invoke<SkillsStatus>("check_skills_installed", {
|
||||||
projectPath: null,
|
projectPath: null,
|
||||||
|
|
@ -121,31 +238,110 @@ export function ScientificSkillsOnboarding({
|
||||||
if (!gsAfter.installed) {
|
if (!gsAfter.installed) {
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
}
|
}
|
||||||
|
toast.success("All skills uninstalled");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to uninstall:", e);
|
console.error("Failed to uninstall:", e);
|
||||||
|
toast.error("Failed to uninstall skills", {
|
||||||
|
description: String(e),
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsUninstalling(false);
|
setIsUninstalling(false);
|
||||||
}
|
}
|
||||||
}, [checkStatus]);
|
}, [checkStatus]);
|
||||||
|
|
||||||
|
const handleImportSkill = useCallback(async () => {
|
||||||
|
setIsImporting(true);
|
||||||
|
try {
|
||||||
|
const selectedFolder = await open({
|
||||||
|
directory: true,
|
||||||
|
multiple: false,
|
||||||
|
title: "Import Claude Skill Folder",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof selectedFolder !== "string") return;
|
||||||
|
|
||||||
|
const skills = await invoke<SkillInfo[]>("import_skill_from_folder", {
|
||||||
|
sourcePath: selectedFolder,
|
||||||
|
});
|
||||||
|
|
||||||
|
localStorage.setItem(STORAGE_KEY, "true");
|
||||||
|
await checkStatus();
|
||||||
|
setSelectedId("imported");
|
||||||
|
toast.success("Skill imported", {
|
||||||
|
description:
|
||||||
|
skills.length === 1
|
||||||
|
? skills[0]?.name
|
||||||
|
: `${skills.length} skills imported`,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Failed to import skill", {
|
||||||
|
description: String(e),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsImporting(false);
|
||||||
|
}
|
||||||
|
}, [checkStatus]);
|
||||||
|
|
||||||
|
const handleConfirmDeleteSkill = useCallback(async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
setDeletingSkillFolder(deleteTarget.folder);
|
||||||
|
try {
|
||||||
|
await invoke("delete_installed_skill", {
|
||||||
|
skillFolder: deleteTarget.folder,
|
||||||
|
});
|
||||||
|
toast.success("Skill deleted", {
|
||||||
|
description: deleteTarget.name,
|
||||||
|
});
|
||||||
|
setDeleteTarget(null);
|
||||||
|
await checkStatus();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Failed to delete skill", {
|
||||||
|
description: String(e),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setDeletingSkillFolder(null);
|
||||||
|
}
|
||||||
|
}, [checkStatus, deleteTarget]);
|
||||||
|
|
||||||
// ─── Installing / Complete state ───
|
// ─── Installing / Complete state ───
|
||||||
if (isInstalling || isComplete) {
|
if (isInstalling || isComplete || error) {
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open
|
open
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open && (isComplete || error)) onClose();
|
if (!open) onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent
|
||||||
|
showCloseButton={false}
|
||||||
|
className="gap-3 px-6 pt-6 pb-4 sm:max-w-md"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Close"
|
||||||
|
className="absolute top-4 right-4 z-10 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XIcon className="size-4" />
|
||||||
|
</button>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2 text-sm">
|
<DialogTitle className="flex items-center gap-2 text-sm">
|
||||||
{isComplete ? (
|
{isComplete ? (
|
||||||
<CheckCircle2Icon className="size-5 text-foreground" />
|
<CheckCircle2Icon className="size-5 text-foreground" />
|
||||||
|
) : error ? (
|
||||||
|
<AlertCircleIcon className="size-5 text-destructive" />
|
||||||
) : (
|
) : (
|
||||||
<FlaskConicalIcon className="size-5 text-muted-foreground" />
|
<FlaskConicalIcon className="size-5 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
{isComplete ? "Installation Complete" : "Installing Skills"}
|
{isComplete
|
||||||
|
? "Installation Complete"
|
||||||
|
: error
|
||||||
|
? "Installation Failed"
|
||||||
|
: "Installing Skills"}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
{isComplete && (
|
{isComplete && (
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
|
|
@ -159,6 +355,7 @@ export function ScientificSkillsOnboarding({
|
||||||
isInstalling={isInstalling}
|
isInstalling={isInstalling}
|
||||||
isComplete={isComplete}
|
isComplete={isComplete}
|
||||||
error={error}
|
error={error}
|
||||||
|
logs={installLogs}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|
@ -175,10 +372,7 @@ export function ScientificSkillsOnboarding({
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={handleInstall}
|
||||||
setError(null);
|
|
||||||
setIsInstalling(false);
|
|
||||||
}}
|
|
||||||
className="gap-1.5"
|
className="gap-1.5"
|
||||||
>
|
>
|
||||||
<RefreshCwIcon className="size-3.5" />
|
<RefreshCwIcon className="size-3.5" />
|
||||||
|
|
@ -198,6 +392,7 @@ export function ScientificSkillsOnboarding({
|
||||||
|
|
||||||
// ─── Browse state — two-column layout ───
|
// ─── Browse state — two-column layout ───
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Dialog
|
<Dialog
|
||||||
open
|
open
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
|
|
@ -212,12 +407,13 @@ export function ScientificSkillsOnboarding({
|
||||||
<DialogHeader className="shrink-0 border-border border-b px-6 py-3">
|
<DialogHeader className="shrink-0 border-border border-b px-6 py-3">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<DialogTitle className="text-sm">Scientific Skills</DialogTitle>
|
<DialogTitle className="text-sm">Skills</DialogTitle>
|
||||||
<DialogDescription className="mt-0.5 text-xs">
|
<DialogDescription className="mt-0.5 text-xs">
|
||||||
{totalSkills} AI skills across {categories.length} domains —
|
{totalSkills} skills across {displayCategories.length} groups
|
||||||
powered by{" "}
|
- install curated scientific skills or import a local Claude
|
||||||
|
skill. Curated set powered by{" "}
|
||||||
<a
|
<a
|
||||||
href="https://github.com/K-Dense-AI/claude-scientific-skills"
|
href="https://github.com/K-Dense-AI/scientific-agent-skills"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center gap-0.5 underline decoration-border underline-offset-2 hover:text-foreground"
|
className="inline-flex items-center gap-0.5 underline decoration-border underline-offset-2 hover:text-foreground"
|
||||||
|
|
@ -227,7 +423,7 @@ export function ScientificSkillsOnboarding({
|
||||||
</a>
|
</a>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-3">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
{isInstalled ? (
|
{isInstalled ? (
|
||||||
<>
|
<>
|
||||||
<Badge variant="secondary" className="gap-1 text-xs">
|
<Badge variant="secondary" className="gap-1 text-xs">
|
||||||
|
|
@ -246,7 +442,7 @@ export function ScientificSkillsOnboarding({
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleUninstall}
|
onClick={() => setConfirmUninstallAllOpen(true)}
|
||||||
disabled={isUninstalling}
|
disabled={isUninstalling}
|
||||||
className="gap-1.5 text-destructive hover:text-destructive"
|
className="gap-1.5 text-destructive hover:text-destructive"
|
||||||
>
|
>
|
||||||
|
|
@ -264,6 +460,20 @@ export function ScientificSkillsOnboarding({
|
||||||
Install All
|
Install All
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleImportSkill}
|
||||||
|
disabled={isImporting || isInstalling || isUninstalling}
|
||||||
|
className="gap-1.5 border-border/70 bg-muted/30 text-foreground shadow-none hover:bg-muted/60 hover:text-foreground"
|
||||||
|
>
|
||||||
|
{isImporting ? (
|
||||||
|
<Loader2Icon className="size-3.5 animate-spin text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<FolderPlusIcon className="size-3.5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
Import Skill
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
@ -271,10 +481,10 @@ export function ScientificSkillsOnboarding({
|
||||||
{/* Body — sidebar + detail */}
|
{/* Body — sidebar + detail */}
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
{/* Category sidebar */}
|
{/* Category sidebar */}
|
||||||
<nav className="w-64 shrink-0 overflow-hidden border-border border-r">
|
<nav className="w-64 max-w-64 shrink-0 overflow-hidden border-border border-r">
|
||||||
<ScrollArea className="h-full">
|
<ScrollArea className="h-full w-full overflow-hidden [&_[data-slot=scroll-area-scrollbar]]:hidden">
|
||||||
<div className="flex flex-col gap-0.5 p-2">
|
<div className="box-border flex w-full min-w-0 flex-col gap-0.5 overflow-x-hidden p-2">
|
||||||
{categories.map((cat) => {
|
{displayCategories.map((cat) => {
|
||||||
const Icon = ICON_MAP[cat.icon] || FlaskConicalIcon;
|
const Icon = ICON_MAP[cat.icon] || FlaskConicalIcon;
|
||||||
const isActive = selectedId === cat.id;
|
const isActive = selectedId === cat.id;
|
||||||
return (
|
return (
|
||||||
|
|
@ -282,19 +492,17 @@ export function ScientificSkillsOnboarding({
|
||||||
key={cat.id}
|
key={cat.id}
|
||||||
onClick={() => setSelectedId(cat.id)}
|
onClick={() => setSelectedId(cat.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2.5 overflow-hidden rounded-lg px-3 py-2 text-left text-sm transition-colors",
|
"box-border grid w-full min-w-0 max-w-full grid-cols-[1rem_minmax(0,1fr)] items-center gap-2.5 overflow-hidden rounded-lg px-3 py-2 text-left text-sm transition-colors",
|
||||||
isActive
|
isActive
|
||||||
? "bg-accent font-medium text-accent-foreground"
|
? "bg-accent font-medium text-accent-foreground"
|
||||||
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
||||||
)}
|
)}
|
||||||
|
title={cat.name}
|
||||||
>
|
>
|
||||||
<Icon className="size-4 shrink-0" />
|
<Icon className="size-4 shrink-0" />
|
||||||
<span className="min-w-0 flex-1 truncate">
|
<span className="block min-w-0 truncate">
|
||||||
{cat.name}
|
{cat.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted-foreground text-xs tabular-nums">
|
|
||||||
{cat.skill_count}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -310,6 +518,9 @@ export function ScientificSkillsOnboarding({
|
||||||
<CategoryDetail
|
<CategoryDetail
|
||||||
category={selected}
|
category={selected}
|
||||||
isInstalled={isInstalled}
|
isInstalled={isInstalled}
|
||||||
|
installedSkillFolders={installedSkillFolders}
|
||||||
|
deletingSkillFolder={deletingSkillFolder}
|
||||||
|
onDeleteSkill={setDeleteTarget}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
@ -324,7 +535,7 @@ export function ScientificSkillsOnboarding({
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="flex shrink-0 items-center justify-between border-border border-t bg-muted/20 px-6 py-2.5">
|
<div className="flex shrink-0 items-center justify-between border-border border-t bg-muted/20 px-6 py-2.5">
|
||||||
<p className="font-mono text-[11px] text-muted-foreground/60">
|
<p className="font-mono text-[11px] text-muted-foreground/60">
|
||||||
{isInstalled ? status?.location : "~/.claude/skills/"}
|
{status?.location ?? "~/.claude/skills/"}
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -337,6 +548,98 @@ export function ScientificSkillsOnboarding({
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={deleteTarget !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open && !deletingSkillFolder) setDeleteTarget(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete Skill</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Delete {deleteTarget?.name ?? "this skill"} from ~/.claude/skills.
|
||||||
|
This cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="rounded-lg border border-border/60 bg-muted/30 px-3 py-2 font-mono text-muted-foreground text-xs">
|
||||||
|
{deleteTarget?.folder}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={deletingSkillFolder !== null}
|
||||||
|
onClick={() => setDeleteTarget(null)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
disabled={deletingSkillFolder !== null}
|
||||||
|
onClick={handleConfirmDeleteSkill}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
{deletingSkillFolder ? (
|
||||||
|
<Loader2Icon className="size-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2Icon className="size-3.5" />
|
||||||
|
)}
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={confirmUninstallAllOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open && !isUninstalling) setConfirmUninstallAllOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Uninstall All Skills</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This will delete every skill under ~/.claude/skills, including
|
||||||
|
imported local skills. This cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-destructive text-xs">
|
||||||
|
{status?.skill_count ?? 0} installed skills will be removed.
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={isUninstalling}
|
||||||
|
onClick={() => setConfirmUninstallAllOpen(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
disabled={isUninstalling}
|
||||||
|
onClick={async () => {
|
||||||
|
await handleUninstall();
|
||||||
|
setConfirmUninstallAllOpen(false);
|
||||||
|
}}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
{isUninstalling ? (
|
||||||
|
<Loader2Icon className="size-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2Icon className="size-3.5" />
|
||||||
|
)}
|
||||||
|
Uninstall All
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -345,9 +648,15 @@ export function ScientificSkillsOnboarding({
|
||||||
function CategoryDetail({
|
function CategoryDetail({
|
||||||
category,
|
category,
|
||||||
isInstalled,
|
isInstalled,
|
||||||
|
installedSkillFolders,
|
||||||
|
deletingSkillFolder,
|
||||||
|
onDeleteSkill,
|
||||||
}: {
|
}: {
|
||||||
category: SkillCategoryData;
|
category: SkillCategoryData;
|
||||||
isInstalled: boolean;
|
isInstalled: boolean;
|
||||||
|
installedSkillFolders: Set<string>;
|
||||||
|
deletingSkillFolder: string | null;
|
||||||
|
onDeleteSkill: (skill: SkillEntryData) => void;
|
||||||
}) {
|
}) {
|
||||||
const Icon = ICON_MAP[category.icon] || FlaskConicalIcon;
|
const Icon = ICON_MAP[category.icon] || FlaskConicalIcon;
|
||||||
const [selectedSkill, setSelectedSkill] = useState<SkillEntryData | null>(
|
const [selectedSkill, setSelectedSkill] = useState<SkillEntryData | null>(
|
||||||
|
|
@ -417,6 +726,22 @@ function CategoryDetail({
|
||||||
{selectedSkill.folder}
|
{selectedSkill.folder}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{installedSkillFolders.has(selectedSkill.folder) && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-8 shrink-0 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||||
|
disabled={deletingSkillFolder === selectedSkill.folder}
|
||||||
|
onClick={() => onDeleteSkill(selectedSkill)}
|
||||||
|
title="Delete skill"
|
||||||
|
>
|
||||||
|
{deletingSkillFolder === selectedSkill.folder ? (
|
||||||
|
<Loader2Icon className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2Icon className="size-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator className="my-4" />
|
<Separator className="my-4" />
|
||||||
|
|
@ -473,16 +798,44 @@ function CategoryDetail({
|
||||||
Skills
|
Skills
|
||||||
</h4>
|
</h4>
|
||||||
<div className="grid grid-cols-2 gap-1.5">
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
{category.skills.map((skill) => (
|
{category.skills.map((skill) => {
|
||||||
<button
|
const canDelete = installedSkillFolders.has(skill.folder);
|
||||||
|
const isDeleting = deletingSkillFolder === skill.folder;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
key={skill.folder}
|
key={skill.folder}
|
||||||
|
className="group flex min-w-0 items-center rounded-lg border border-border/60 bg-card/30 transition-colors hover:border-border hover:bg-accent/30"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => handleSkillClick(skill)}
|
onClick={() => handleSkillClick(skill)}
|
||||||
className="flex items-center gap-2 rounded-lg border border-border/60 bg-card/30 px-3 py-2 text-left text-sm transition-colors hover:border-border hover:bg-accent/30"
|
className="flex min-w-0 flex-1 items-center gap-2 px-3 py-2 text-left text-sm"
|
||||||
>
|
>
|
||||||
<span className="size-1.5 shrink-0 rounded-full bg-foreground/40" />
|
<span className="size-1.5 shrink-0 rounded-full bg-foreground/40" />
|
||||||
{skill.name}
|
<span className="min-w-0 truncate">{skill.name}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
{canDelete && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Delete ${skill.name}`}
|
||||||
|
title="Delete skill"
|
||||||
|
disabled={isDeleting}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onDeleteSkill(skill);
|
||||||
|
}}
|
||||||
|
className="mr-1 flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground opacity-70 transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-50 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
{isDeleting ? (
|
||||||
|
<Loader2Icon className="size-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2Icon className="size-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@ import { useState, useCallback, useEffect, useRef } from "react";
|
||||||
import { open } from "@tauri-apps/plugin-dialog";
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
import { mkdir, writeTextFile } from "@tauri-apps/plugin-fs";
|
import { mkdir, writeTextFile } from "@tauri-apps/plugin-fs";
|
||||||
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
||||||
import { documentDir } from "@tauri-apps/api/path";
|
import { homeDir } from "@tauri-apps/api/path";
|
||||||
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
ChevronLeftIcon,
|
ChevronLeftIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
|
|
@ -42,42 +43,16 @@ import { getMupdfClient } from "@/lib/mupdf/mupdf-client";
|
||||||
import { exists, join } from "@/lib/tauri/fs";
|
import { exists, join } from "@/lib/tauri/fs";
|
||||||
import type { PageSize } from "@/lib/mupdf/types";
|
import type { PageSize } from "@/lib/mupdf/types";
|
||||||
import { createLogger } from "@/lib/debug/logger";
|
import { createLogger } from "@/lib/debug/logger";
|
||||||
|
import {
|
||||||
|
buildReferenceFilesSection,
|
||||||
|
importReferenceFiles,
|
||||||
|
} from "@/lib/project-attachments";
|
||||||
|
import { getProjectNameError, normalizeProjectName } from "@/lib/project-name";
|
||||||
|
|
||||||
const log = createLogger("template-preview");
|
const log = createLogger("template-preview");
|
||||||
|
|
||||||
// ─── Helpers ───
|
// ─── Helpers ───
|
||||||
|
|
||||||
function randomProjectName(): string {
|
|
||||||
const adjectives = [
|
|
||||||
"swift",
|
|
||||||
"bright",
|
|
||||||
"calm",
|
|
||||||
"bold",
|
|
||||||
"keen",
|
|
||||||
"warm",
|
|
||||||
"pure",
|
|
||||||
"vast",
|
|
||||||
"deep",
|
|
||||||
"fair",
|
|
||||||
];
|
|
||||||
const nouns = [
|
|
||||||
"paper",
|
|
||||||
"draft",
|
|
||||||
"thesis",
|
|
||||||
"note",
|
|
||||||
"study",
|
|
||||||
"essay",
|
|
||||||
"report",
|
|
||||||
"brief",
|
|
||||||
"folio",
|
|
||||||
"opus",
|
|
||||||
];
|
|
||||||
const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
|
|
||||||
const noun = nouns[Math.floor(Math.random() * nouns.length)];
|
|
||||||
const id = Math.random().toString(36).slice(2, 6);
|
|
||||||
return `${adj}-${noun}-${id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Component ───
|
// ─── Component ───
|
||||||
|
|
||||||
type ModalStep = "preview" | "details";
|
type ModalStep = "preview" | "details";
|
||||||
|
|
@ -107,11 +82,13 @@ export function TemplatePreview() {
|
||||||
const [purpose, setPurpose] = useState("");
|
const [purpose, setPurpose] = useState("");
|
||||||
const [attachments, setAttachments] = useState<string[]>([]);
|
const [attachments, setAttachments] = useState<string[]>([]);
|
||||||
const [projectFolder, setProjectFolder] = useState<string | null>(null);
|
const [projectFolder, setProjectFolder] = useState<string | null>(null);
|
||||||
const [projectName, setProjectName] = useState(randomProjectName);
|
const [projectName, setProjectName] = useState("");
|
||||||
|
const [projectNameError, setProjectNameError] = useState("");
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
const [refFilesOpen, setRefFilesOpen] = useState(false);
|
const [refFilesOpen, setRefFilesOpen] = useState(false);
|
||||||
const [locationOpen, setLocationOpen] = useState(false);
|
const [locationOpen, setLocationOpen] = useState(false);
|
||||||
|
const projectNameRef = useRef<HTMLInputElement>(null);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
// ── Store access ──
|
// ── Store access ──
|
||||||
|
|
@ -147,7 +124,8 @@ export function TemplatePreview() {
|
||||||
setModalStep("preview");
|
setModalStep("preview");
|
||||||
setPurpose("");
|
setPurpose("");
|
||||||
setAttachments([]);
|
setAttachments([]);
|
||||||
setProjectName(randomProjectName());
|
setProjectName("");
|
||||||
|
setProjectNameError("");
|
||||||
setRefFilesOpen(false);
|
setRefFilesOpen(false);
|
||||||
setLocationOpen(false);
|
setLocationOpen(false);
|
||||||
}
|
}
|
||||||
|
|
@ -159,16 +137,22 @@ export function TemplatePreview() {
|
||||||
if (lastProjectFolder) {
|
if (lastProjectFolder) {
|
||||||
setProjectFolder(lastProjectFolder);
|
setProjectFolder(lastProjectFolder);
|
||||||
} else {
|
} else {
|
||||||
documentDir()
|
homeDir()
|
||||||
.then((dir) => setProjectFolder(dir))
|
.then((home) => join(home, "Documents", "ClaudePrism"))
|
||||||
.catch(() => {});
|
.then(async (dir) => {
|
||||||
|
await mkdir(dir, { recursive: true }).catch(() => {});
|
||||||
|
setProjectFolder(dir);
|
||||||
|
})
|
||||||
|
.catch((err) =>
|
||||||
|
console.warn("Failed to resolve default project folder:", err),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// Auto-focus textarea in details step
|
// Auto-focus textarea in details step
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (modalStep === "details") {
|
if (modalStep === "details") {
|
||||||
const timer = setTimeout(() => textareaRef.current?.focus(), 150);
|
const timer = setTimeout(() => projectNameRef.current?.focus(), 150);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
}, [modalStep]);
|
}, [modalStep]);
|
||||||
|
|
@ -347,26 +331,6 @@ export function TemplatePreview() {
|
||||||
const selected = await open({
|
const selected = await open({
|
||||||
multiple: true,
|
multiple: true,
|
||||||
title: "Add Reference Files",
|
title: "Add Reference Files",
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
name: "Documents & Images",
|
|
||||||
extensions: [
|
|
||||||
"pdf",
|
|
||||||
"tex",
|
|
||||||
"bib",
|
|
||||||
"txt",
|
|
||||||
"md",
|
|
||||||
"png",
|
|
||||||
"jpg",
|
|
||||||
"jpeg",
|
|
||||||
"gif",
|
|
||||||
"svg",
|
|
||||||
"csv",
|
|
||||||
"tsv",
|
|
||||||
"json",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
if (selected) {
|
if (selected) {
|
||||||
const paths = Array.isArray(selected) ? selected : [selected];
|
const paths = Array.isArray(selected) ? selected : [selected];
|
||||||
|
|
@ -395,12 +359,21 @@ export function TemplatePreview() {
|
||||||
|
|
||||||
// ── Create project ──
|
// ── Create project ──
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!template || !projectFolder || !projectName.trim()) return;
|
const name = normalizeProjectName(projectName);
|
||||||
|
const nameError = getProjectNameError(projectName);
|
||||||
|
if (!template || !projectFolder || nameError) {
|
||||||
|
setProjectNameError(nameError ?? "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setIsCreating(true);
|
setIsCreating(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const projectPath = await join(projectFolder, projectName.trim());
|
const projectPath = await join(projectFolder, name);
|
||||||
await mkdir(projectPath, { recursive: true }).catch(() => {});
|
if (await exists(projectPath)) {
|
||||||
|
setProjectNameError("A folder with this name already exists here");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await mkdir(projectPath, { recursive: true });
|
||||||
|
|
||||||
const mainTexPath = await join(projectPath, template.mainFileName);
|
const mainTexPath = await join(projectPath, template.mainFileName);
|
||||||
const mainExists = await exists(mainTexPath);
|
const mainExists = await exists(mainTexPath);
|
||||||
|
|
@ -416,19 +389,13 @@ export function TemplatePreview() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attachments.length > 0) {
|
const referenceFiles =
|
||||||
const attachmentsDir = await join(projectPath, "attachments");
|
attachments.length > 0
|
||||||
await mkdir(attachmentsDir, { recursive: true }).catch(() => {});
|
? await importReferenceFiles(projectPath, attachments)
|
||||||
}
|
: [];
|
||||||
|
|
||||||
if (purpose.trim()) {
|
if (purpose.trim()) {
|
||||||
const attachmentNames = attachments
|
const attachmentSection = buildReferenceFilesSection(referenceFiles);
|
||||||
.map((p) => p.split("/").pop())
|
|
||||||
.filter(Boolean);
|
|
||||||
const attachmentSection =
|
|
||||||
attachmentNames.length > 0
|
|
||||||
? `\n### Reference Files\n${attachmentNames.map((n) => `- \`${n}\``).join("\n")}\n\nPlease review them and incorporate relevant information.\n`
|
|
||||||
: "";
|
|
||||||
|
|
||||||
const prompt = [
|
const prompt = [
|
||||||
`## New ${template.name} Project`,
|
`## New ${template.name} Project`,
|
||||||
|
|
@ -455,22 +422,21 @@ export function TemplatePreview() {
|
||||||
addRecentProject(projectPath);
|
addRecentProject(projectPath);
|
||||||
await openProject(projectPath);
|
await openProject(projectPath);
|
||||||
|
|
||||||
if (attachments.length > 0) {
|
|
||||||
await useDocumentStore
|
|
||||||
.getState()
|
|
||||||
.importFiles(attachments, "attachments");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close modal on success
|
// Close modal on success
|
||||||
closePreview();
|
closePreview();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to create project:", err);
|
console.error("Failed to create project:", err);
|
||||||
|
toast.error("Failed to create project", {
|
||||||
|
description: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const canCreate = template && projectFolder && projectName.trim();
|
const canCreate = Boolean(
|
||||||
|
template && projectFolder && !getProjectNameError(projectName),
|
||||||
|
);
|
||||||
|
|
||||||
if (!template) return null;
|
if (!template) return null;
|
||||||
|
|
||||||
|
|
@ -593,6 +559,34 @@ export function TemplatePreview() {
|
||||||
{/* Form content */}
|
{/* Form content */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="space-y-4 p-5">
|
<div className="space-y-4 p-5">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold text-sm">Project name</span>
|
||||||
|
<p className="mt-0.5 text-muted-foreground text-xs leading-relaxed">
|
||||||
|
This becomes the folder name on disk.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
ref={projectNameRef}
|
||||||
|
placeholder="e.g., conference-paper"
|
||||||
|
value={projectName}
|
||||||
|
onChange={(e) => {
|
||||||
|
setProjectName(e.target.value);
|
||||||
|
setProjectNameError("");
|
||||||
|
}}
|
||||||
|
onBlur={() =>
|
||||||
|
setProjectNameError(
|
||||||
|
getProjectNameError(projectName) ?? "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="rounded-xl border-border/60 bg-card/30 text-sm focus-visible:bg-card/50"
|
||||||
|
/>
|
||||||
|
{projectNameError && (
|
||||||
|
<p className="text-destructive text-xs">
|
||||||
|
{projectNameError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{/* Purpose — hero element */}
|
{/* Purpose — hero element */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -650,7 +644,7 @@ export function TemplatePreview() {
|
||||||
>
|
>
|
||||||
<PaperclipIcon className="size-3 shrink-0 text-muted-foreground/70" />
|
<PaperclipIcon className="size-3 shrink-0 text-muted-foreground/70" />
|
||||||
<span className="max-w-30 truncate text-foreground/80">
|
<span className="max-w-30 truncate text-foreground/80">
|
||||||
{path.split("/").pop()}
|
{path.split(/[/\\]/).pop()}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleRemoveAttachment(path)}
|
onClick={() => handleRemoveAttachment(path)}
|
||||||
|
|
@ -713,8 +707,8 @@ export function TemplatePreview() {
|
||||||
</div>
|
</div>
|
||||||
{!locationOpen && projectFolder && projectName.trim() && (
|
{!locationOpen && projectFolder && projectName.trim() && (
|
||||||
<span className="min-w-0 max-w-35 truncate rounded-md bg-muted/40 px-2 py-0.5 font-mono text-[11px] text-muted-foreground/60">
|
<span className="min-w-0 max-w-35 truncate rounded-md bg-muted/40 px-2 py-0.5 font-mono text-[11px] text-muted-foreground/60">
|
||||||
.../{projectFolder.split("/").pop()}/
|
.../{projectFolder.split(/[/\\]/).pop()}/
|
||||||
{projectName.trim()}
|
{normalizeProjectName(projectName)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<ChevronDownIcon
|
<ChevronDownIcon
|
||||||
|
|
@ -723,13 +717,12 @@ export function TemplatePreview() {
|
||||||
</button>
|
</button>
|
||||||
{locationOpen && (
|
{locationOpen && (
|
||||||
<div className="space-y-2 px-4 pb-3">
|
<div className="space-y-2 px-4 pb-3">
|
||||||
<div className="flex gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<p className="min-w-0 flex-1 truncate rounded-md bg-muted/30 px-2.5 py-1.5 font-mono text-[11px] text-muted-foreground/60">
|
||||||
placeholder="Project name"
|
{projectFolder
|
||||||
value={projectName}
|
? `${projectFolder}/${normalizeProjectName(projectName) || "..."}`
|
||||||
onChange={(e) => setProjectName(e.target.value)}
|
: "Choose a location"}
|
||||||
className="flex-1 rounded-lg border-border/60 bg-background/50"
|
</p>
|
||||||
/>
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -740,11 +733,6 @@ export function TemplatePreview() {
|
||||||
{projectFolder ? "Change" : "Choose"}
|
{projectFolder ? "Change" : "Choose"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{projectFolder && (
|
|
||||||
<p className="truncate rounded-md bg-muted/30 px-2.5 py-1.5 font-mono text-[11px] text-muted-foreground/60">
|
|
||||||
{projectFolder}/{projectName.trim() || "..."}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,11 @@ function ContextMenuTrigger({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
||||||
return (
|
return (
|
||||||
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
|
<ContextMenuPrimitive.Trigger
|
||||||
|
data-app-context-menu
|
||||||
|
data-slot="context-menu-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -24,6 +28,7 @@ function ContextMenuContent({
|
||||||
return (
|
return (
|
||||||
<ContextMenuPrimitive.Portal>
|
<ContextMenuPrimitive.Portal>
|
||||||
<ContextMenuPrimitive.Content
|
<ContextMenuPrimitive.Content
|
||||||
|
data-app-context-menu
|
||||||
data-slot="context-menu-content"
|
data-slot="context-menu-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in",
|
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ function DialogOverlay({
|
||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
data-slot="dialog-overlay"
|
data-slot="dialog-overlay"
|
||||||
className={cn(
|
className={cn(
|
||||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=open]:animate-in",
|
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/35 backdrop-blur-sm data-[state=closed]:animate-out data-[state=open]:animate-in dark:bg-black/45",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -71,22 +71,22 @@ export function UvSetupDialog({ open, onClose }: UvSetupDialogProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="w-full max-w-[34rem] overflow-hidden sm:max-w-[34rem]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex min-w-0 items-center gap-2 pr-8">
|
||||||
<TerminalIcon className="size-5" />
|
<TerminalIcon className="size-5 shrink-0" />
|
||||||
Python Environment (uv)
|
<span className="min-w-0 truncate">Python Environment (uv)</span>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Manage the Python virtual environment for this project.
|
Manage the Python virtual environment for this project.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 py-2">
|
<div className="min-w-0 space-y-4 py-2">
|
||||||
{/* uv status */}
|
{/* uv status */}
|
||||||
<div className="flex items-center gap-3 rounded-lg border p-3">
|
<div className="flex min-w-0 items-center gap-3 overflow-hidden rounded-lg border p-3">
|
||||||
<StatusIcon status={status} isInstalling={isInstalling} />
|
<StatusIcon status={status} isInstalling={isInstalling} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1 overflow-hidden">
|
||||||
<div className="font-medium text-sm">
|
<div className="font-medium text-sm">
|
||||||
{status === "checking"
|
{status === "checking"
|
||||||
? "Checking uv..."
|
? "Checking uv..."
|
||||||
|
|
@ -102,7 +102,9 @@ export function UvSetupDialog({ open, onClose }: UvSetupDialogProps) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mt-1 text-destructive text-xs">{error}</div>
|
<div className="mt-1 break-words text-destructive text-xs">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{status === "not-installed" && !isInstalling && (
|
{status === "not-installed" && !isInstalling && (
|
||||||
|
|
@ -121,10 +123,10 @@ export function UvSetupDialog({ open, onClose }: UvSetupDialogProps) {
|
||||||
|
|
||||||
{/* venv status — only show when uv is ready */}
|
{/* venv status — only show when uv is ready */}
|
||||||
{status === "ready" && (
|
{status === "ready" && (
|
||||||
<div className="flex items-center gap-3 rounded-lg border p-3">
|
<div className="flex min-w-0 items-center gap-3 overflow-hidden rounded-lg border p-3">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex size-8 items-center justify-center rounded-full",
|
"flex size-8 shrink-0 items-center justify-center rounded-full",
|
||||||
venvReady
|
venvReady
|
||||||
? "bg-accent text-accent-foreground"
|
? "bg-accent text-accent-foreground"
|
||||||
: "bg-muted text-muted-foreground",
|
: "bg-muted text-muted-foreground",
|
||||||
|
|
@ -132,7 +134,7 @@ export function UvSetupDialog({ open, onClose }: UvSetupDialogProps) {
|
||||||
>
|
>
|
||||||
<FolderIcon className="size-4" />
|
<FolderIcon className="size-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1 overflow-hidden">
|
||||||
<div className="font-medium text-sm">
|
<div className="font-medium text-sm">
|
||||||
{venvReady
|
{venvReady
|
||||||
? "Virtual Environment Active"
|
? "Virtual Environment Active"
|
||||||
|
|
@ -140,7 +142,7 @@ export function UvSetupDialog({ open, onClose }: UvSetupDialogProps) {
|
||||||
</div>
|
</div>
|
||||||
{venvPath && (
|
{venvPath && (
|
||||||
<div
|
<div
|
||||||
className="truncate text-muted-foreground text-xs"
|
className="max-w-full break-all text-muted-foreground text-xs leading-snug"
|
||||||
title={venvPath}
|
title={venvPath}
|
||||||
>
|
>
|
||||||
{venvPath}
|
{venvPath}
|
||||||
|
|
@ -151,9 +153,7 @@ export function UvSetupDialog({ open, onClose }: UvSetupDialogProps) {
|
||||||
className="truncate text-muted-foreground text-xs"
|
className="truncate text-muted-foreground text-xs"
|
||||||
title={pythonPath}
|
title={pythonPath}
|
||||||
>
|
>
|
||||||
Python:{" "}
|
Python: {pythonPath.split(/[/\\]/).pop()}
|
||||||
{pythonPath.split("/").pop() ||
|
|
||||||
pythonPath.split("\\").pop()}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -167,9 +167,10 @@ export function UvSetupDialog({ open, onClose }: UvSetupDialogProps) {
|
||||||
|
|
||||||
{/* Info text */}
|
{/* Info text */}
|
||||||
{status === "ready" && venvReady && (
|
{status === "ready" && venvReady && (
|
||||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
<p className="max-w-full break-words text-muted-foreground text-xs leading-relaxed">
|
||||||
Claude Code will automatically use this environment when running
|
Claude Code and ClaudePrism terminal tools use this environment
|
||||||
Python code. Use{" "}
|
when running Python code. OpenAI-compatible providers use it
|
||||||
|
through PowerShell/Bash tool calls. Use{" "}
|
||||||
<code className="text-foreground">uv pip install</code> to add
|
<code className="text-foreground">uv pip install</code> to add
|
||||||
packages.
|
packages.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -189,28 +190,28 @@ function StatusIcon({
|
||||||
}) {
|
}) {
|
||||||
if (isInstalling || status === "checking") {
|
if (isInstalling || status === "checking") {
|
||||||
return (
|
return (
|
||||||
<div className="flex size-8 items-center justify-center rounded-full bg-muted">
|
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted">
|
||||||
<Loader2Icon className="size-4 animate-spin text-muted-foreground" />
|
<Loader2Icon className="size-4 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (status === "ready") {
|
if (status === "ready") {
|
||||||
return (
|
return (
|
||||||
<div className="flex size-8 items-center justify-center rounded-full bg-accent text-accent-foreground">
|
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-accent text-accent-foreground">
|
||||||
<CheckCircle2Icon className="size-4" />
|
<CheckCircle2Icon className="size-4" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (status === "error") {
|
if (status === "error") {
|
||||||
return (
|
return (
|
||||||
<div className="flex size-8 items-center justify-center rounded-full bg-destructive/10 text-destructive">
|
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-destructive/10 text-destructive">
|
||||||
<AlertCircleIcon className="size-4" />
|
<AlertCircleIcon className="size-4" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// not-installed
|
// not-installed
|
||||||
return (
|
return (
|
||||||
<div className="flex size-8 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||||
<TerminalIcon className="size-4" />
|
<TerminalIcon className="size-4" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import vscodeIcon from "@/assets/vscode.svg";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
|
|
@ -33,6 +34,7 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { useDocumentStore } from "@/stores/document-store";
|
import { useDocumentStore } from "@/stores/document-store";
|
||||||
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
|
||||||
interface EditorInfo {
|
interface EditorInfo {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -50,6 +52,28 @@ const ZOOM_OPTIONS = [
|
||||||
{ value: "4", label: "400%" },
|
{ value: "4", label: "400%" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function OpenEditorIcon({ editor }: { editor: EditorInfo }) {
|
||||||
|
if (editor.id === "vscode") {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={vscodeIcon}
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
draggable={false}
|
||||||
|
className="size-5"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ExternalLinkIcon className="size-4" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOpenEditorButtonClassName(editor: EditorInfo) {
|
||||||
|
return editor.id === "vscode"
|
||||||
|
? "h-7 w-7 border border-border/70 bg-muted/30 p-1 hover:bg-muted/50"
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
interface EditorToolbarProps {
|
interface EditorToolbarProps {
|
||||||
editorView: RefObject<EditorView | null>;
|
editorView: RefObject<EditorView | null>;
|
||||||
fileType?: "tex" | "image";
|
fileType?: "tex" | "image";
|
||||||
|
|
@ -67,6 +91,9 @@ export function EditorToolbar({
|
||||||
cropMode,
|
cropMode,
|
||||||
onCropToggle,
|
onCropToggle,
|
||||||
}: EditorToolbarProps) {
|
}: EditorToolbarProps) {
|
||||||
|
const vimMode = useSettingsStore((s) => s.vimMode);
|
||||||
|
const setVimMode = useSettingsStore((s) => s.setVimMode);
|
||||||
|
|
||||||
const fileName = useDocumentStore((s) => {
|
const fileName = useDocumentStore((s) => {
|
||||||
const activeFile = s.files.find((f) => f.id === s.activeFileId);
|
const activeFile = s.files.find((f) => f.id === s.activeFileId);
|
||||||
return activeFile?.name ?? "main.tex";
|
return activeFile?.name ?? "main.tex";
|
||||||
|
|
@ -132,10 +159,13 @@ export function EditorToolbar({
|
||||||
|
|
||||||
if (fileType === "image") {
|
if (fileType === "image") {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(36px+var(--titlebar-height))] items-center justify-between border-border border-b bg-muted/30 px-2 pt-[var(--titlebar-height)]">
|
<div className="flex h-[calc(var(--workspace-topbar-height)+var(--titlebar-height))] min-w-0 items-center justify-between border-border border-b bg-muted/30 px-2">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex min-w-0 max-w-[min(18rem,35vw)] items-center gap-1.5">
|
||||||
<ImageIcon className="size-4 text-muted-foreground" />
|
<ImageIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||||
<span className="font-medium text-muted-foreground text-sm">
|
<span
|
||||||
|
className="min-w-0 truncate font-medium text-muted-foreground text-sm"
|
||||||
|
title={activeFilePath ?? fileName}
|
||||||
|
>
|
||||||
{fileName}
|
{fileName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -192,8 +222,9 @@ export function EditorToolbar({
|
||||||
<TooltipIconButton
|
<TooltipIconButton
|
||||||
tooltip={`Open in ${editors[0].name}`}
|
tooltip={`Open in ${editors[0].name}`}
|
||||||
onClick={() => openInEditor(editors[0].id)}
|
onClick={() => openInEditor(editors[0].id)}
|
||||||
|
className={getOpenEditorButtonClassName(editors[0])}
|
||||||
>
|
>
|
||||||
<ExternalLinkIcon className="size-4" />
|
<OpenEditorIcon editor={editors[0]} />
|
||||||
</TooltipIconButton>
|
</TooltipIconButton>
|
||||||
)}
|
)}
|
||||||
{editors.length > 1 && (
|
{editors.length > 1 && (
|
||||||
|
|
@ -226,12 +257,17 @@ export function EditorToolbar({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(36px+var(--titlebar-height))] items-center gap-1 border-border border-b bg-muted/30 px-2 pt-[var(--titlebar-height)]">
|
<div className="flex h-[calc(var(--workspace-topbar-height)+var(--titlebar-height))] min-w-0 items-center gap-1 border-border border-b bg-muted/30 px-2">
|
||||||
<FileTextIcon className="size-4 text-muted-foreground" />
|
<div className="flex min-w-0 max-w-[min(18rem,35vw)] shrink items-center gap-1.5">
|
||||||
<span className="mr-2 font-medium text-muted-foreground text-sm">
|
<FileTextIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span
|
||||||
|
className="min-w-0 truncate font-medium text-muted-foreground text-sm"
|
||||||
|
title={activeFilePath ?? fileName}
|
||||||
|
>
|
||||||
{fileName}
|
{fileName}
|
||||||
</span>
|
</span>
|
||||||
<div className="mx-2 h-4 w-px bg-border" />
|
</div>
|
||||||
|
<div className="mx-2 h-4 w-px shrink-0 bg-border" />
|
||||||
<TooltipIconButton
|
<TooltipIconButton
|
||||||
tooltip="Bold (\\textbf)"
|
tooltip="Bold (\\textbf)"
|
||||||
onClick={() => insertText("\\textbf{", "}")}
|
onClick={() => insertText("\\textbf{", "}")}
|
||||||
|
|
@ -289,13 +325,24 @@ export function EditorToolbar({
|
||||||
>
|
>
|
||||||
<BookMarkedIcon className="size-4" />
|
<BookMarkedIcon className="size-4" />
|
||||||
</TooltipIconButton>
|
</TooltipIconButton>
|
||||||
|
<div className="mx-2 h-4 w-px bg-border" />
|
||||||
|
<Button
|
||||||
|
variant={vimMode ? "default" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-2 font-mono text-xs"
|
||||||
|
onClick={() => setVimMode(!vimMode)}
|
||||||
|
title="Toggle Vim mode"
|
||||||
|
>
|
||||||
|
VIM
|
||||||
|
</Button>
|
||||||
<div data-tauri-drag-region className="flex-1 self-stretch" />
|
<div data-tauri-drag-region className="flex-1 self-stretch" />
|
||||||
{editors.length === 1 && (
|
{editors.length === 1 && (
|
||||||
<TooltipIconButton
|
<TooltipIconButton
|
||||||
tooltip={`Open in ${editors[0].name}`}
|
tooltip={`Open in ${editors[0].name}`}
|
||||||
onClick={() => openInEditor(editors[0].id)}
|
onClick={() => openInEditor(editors[0].id)}
|
||||||
|
className={getOpenEditorButtonClassName(editors[0])}
|
||||||
>
|
>
|
||||||
<ExternalLinkIcon className="size-4" />
|
<OpenEditorIcon editor={editors[0]} />
|
||||||
</TooltipIconButton>
|
</TooltipIconButton>
|
||||||
)}
|
)}
|
||||||
{editors.length > 1 && (
|
{editors.length > 1 && (
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { CheckIcon, XIcon } from "lucide-react";
|
||||||
import { writeFile } from "@tauri-apps/plugin-fs";
|
import { writeFile } from "@tauri-apps/plugin-fs";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useDocumentStore, type ProjectFile } from "@/stores/document-store";
|
import { useDocumentStore, type ProjectFile } from "@/stores/document-store";
|
||||||
|
import { LOCAL_ZOOM_SHORTCUTS_ATTR } from "@/lib/app-zoom";
|
||||||
import { getAssetUrl } from "@/lib/tauri/fs";
|
import { getAssetUrl } from "@/lib/tauri/fs";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
|
@ -353,12 +354,14 @@ export function ImagePreview({
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
|
{...{ [LOCAL_ZOOM_SHORTCUTS_ATTR]: "true" }}
|
||||||
className="relative h-full overflow-auto bg-muted/50 p-4 outline-none"
|
className="relative h-full overflow-auto bg-muted/50 p-4 outline-none"
|
||||||
style={
|
style={
|
||||||
cropMode
|
cropMode
|
||||||
? { cursor: cropRect && !dragStart ? "default" : "crosshair" }
|
? { cursor: cropRect && !dragStart ? "default" : "crosshair" }
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
onMouseDownCapture={() => containerRef.current?.focus()}
|
||||||
onMouseMove={cropMode ? handleCropMouseMove : undefined}
|
onMouseMove={cropMode ? handleCropMouseMove : undefined}
|
||||||
onMouseUp={cropMode ? handleCropMouseUp : undefined}
|
onMouseUp={cropMode ? handleCropMouseUp : undefined}
|
||||||
onMouseLeave={cropMode ? handleCropMouseUp : undefined}
|
onMouseLeave={cropMode ? handleCropMouseUp : undefined}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState, useMemo } from "react";
|
||||||
import { Compartment, EditorState, Prec, Transaction } from "@codemirror/state";
|
import { Compartment, EditorState, Prec, Transaction } from "@codemirror/state";
|
||||||
import {
|
import {
|
||||||
EditorView,
|
EditorView,
|
||||||
|
drawSelection,
|
||||||
keymap,
|
keymap,
|
||||||
lineNumbers,
|
lineNumbers,
|
||||||
highlightActiveLine,
|
highlightActiveLine,
|
||||||
|
|
@ -47,13 +48,17 @@ import {
|
||||||
useProposedChangesStore,
|
useProposedChangesStore,
|
||||||
type ProposedChange,
|
type ProposedChange,
|
||||||
} from "@/stores/proposed-changes-store";
|
} from "@/stores/proposed-changes-store";
|
||||||
import { useClaudeChatStore } from "@/stores/claude-chat-store";
|
import {
|
||||||
|
useClaudeChatStore,
|
||||||
|
type PromptContextOverride,
|
||||||
|
} from "@/stores/claude-chat-store";
|
||||||
import { useHistoryStore, type FileDiff } from "@/stores/history-store";
|
import { useHistoryStore, type FileDiff } from "@/stores/history-store";
|
||||||
import {
|
import {
|
||||||
compileLatex,
|
compileLatex,
|
||||||
resolveCompileTarget,
|
resolveCompileTarget,
|
||||||
formatCompileError,
|
formatCompileError,
|
||||||
} from "@/lib/latex-compiler";
|
} from "@/lib/latex-compiler";
|
||||||
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import { EditorToolbar } from "./editor-toolbar";
|
import { EditorToolbar } from "./editor-toolbar";
|
||||||
import { SelectionToolbar, type ToolbarAction } from "./selection-toolbar";
|
import { SelectionToolbar, type ToolbarAction } from "./selection-toolbar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -161,11 +166,13 @@ export function LatexEditor() {
|
||||||
const parentRef = useRef<HTMLDivElement>(null);
|
const parentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const { resolvedTheme } = useTheme();
|
const { resolvedTheme } = useTheme();
|
||||||
|
const vimMode = useSettingsStore((s) => s.vimMode);
|
||||||
|
|
||||||
const compileRef = useRef<() => void>(() => {});
|
const compileRef = useRef<() => void>(() => {});
|
||||||
const isSearchOpenRef = useRef(false);
|
const isSearchOpenRef = useRef(false);
|
||||||
const themeCompartmentRef = useRef(new Compartment());
|
const themeCompartmentRef = useRef(new Compartment());
|
||||||
const mergeCompartmentRef = useRef(new Compartment());
|
const mergeCompartmentRef = useRef(new Compartment());
|
||||||
|
const vimCompartmentRef = useRef(new Compartment());
|
||||||
const isMergeActiveRef = useRef(false);
|
const isMergeActiveRef = useRef(false);
|
||||||
const pendingChangeRef = useRef<ProposedChange | null>(null);
|
const pendingChangeRef = useRef<ProposedChange | null>(null);
|
||||||
const handleKeepAllRef = useRef<() => void>(() => {});
|
const handleKeepAllRef = useRef<() => void>(() => {});
|
||||||
|
|
@ -350,18 +357,24 @@ export function LatexEditor() {
|
||||||
if (searchQuery) findNext(view);
|
if (searchQuery) findNext(view);
|
||||||
}, [searchQuery]);
|
}, [searchQuery]);
|
||||||
|
|
||||||
const handleFindNext = () => {
|
const handleFindNext = (options?: { focusEditor?: boolean }) => {
|
||||||
const view = viewRef.current;
|
const view = viewRef.current;
|
||||||
if (view) {
|
if (view) {
|
||||||
findNext(view);
|
findNext(view);
|
||||||
view.focus();
|
if (matchCount > 0) {
|
||||||
|
setCurrentMatch((current) => (current % matchCount) + 1);
|
||||||
|
}
|
||||||
|
if (options?.focusEditor !== false) view.focus();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const handleFindPrevious = () => {
|
const handleFindPrevious = (options?: { focusEditor?: boolean }) => {
|
||||||
const view = viewRef.current;
|
const view = viewRef.current;
|
||||||
if (view) {
|
if (view) {
|
||||||
findPrevious(view);
|
findPrevious(view);
|
||||||
view.focus();
|
if (matchCount > 0) {
|
||||||
|
setCurrentMatch((current) => (current <= 1 ? matchCount : current - 1));
|
||||||
|
}
|
||||||
|
if (options?.focusEditor !== false) view.focus();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -395,7 +408,9 @@ export function LatexEditor() {
|
||||||
.getState()
|
.getState()
|
||||||
.createSnapshot(projectRoot, "[compile] Pre-compile")
|
.createSnapshot(projectRoot, "[compile] Pre-compile")
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
const data = await compileLatex(projectRoot, targetPath);
|
const useTexlive =
|
||||||
|
useSettingsStore.getState().compilerBackend === "texlive";
|
||||||
|
const data = await compileLatex(projectRoot, targetPath, useTexlive);
|
||||||
setPdfData(data, rootId);
|
setPdfData(data, rootId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setCompileError(formatCompileError(error), rootId);
|
setCompileError(formatCompileError(error), rootId);
|
||||||
|
|
@ -615,6 +630,7 @@ export function LatexEditor() {
|
||||||
extensions: [
|
extensions: [
|
||||||
compileKeymap,
|
compileKeymap,
|
||||||
lineNumbers(),
|
lineNumbers(),
|
||||||
|
drawSelection(),
|
||||||
highlightActiveLine(),
|
highlightActiveLine(),
|
||||||
highlightActiveLineGutter(),
|
highlightActiveLineGutter(),
|
||||||
history(),
|
history(),
|
||||||
|
|
@ -667,6 +683,7 @@ export function LatexEditor() {
|
||||||
search(),
|
search(),
|
||||||
highlightSelectionMatches(),
|
highlightSelectionMatches(),
|
||||||
mergeCompartmentRef.current.of([]),
|
mergeCompartmentRef.current.of([]),
|
||||||
|
vimCompartmentRef.current.of([]),
|
||||||
updateListener,
|
updateListener,
|
||||||
EditorView.lineWrapping,
|
EditorView.lineWrapping,
|
||||||
scrollPastEnd(),
|
scrollPastEnd(),
|
||||||
|
|
@ -818,6 +835,23 @@ export function LatexEditor() {
|
||||||
});
|
});
|
||||||
}, [resolvedTheme]);
|
}, [resolvedTheme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const view = viewRef.current;
|
||||||
|
if (!view) return;
|
||||||
|
if (!vimMode) {
|
||||||
|
view.dispatch({
|
||||||
|
effects: vimCompartmentRef.current.reconfigure([]),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
import("@replit/codemirror-vim").then(({ vim }) => {
|
||||||
|
if (viewRef.current !== view) return;
|
||||||
|
view.dispatch({
|
||||||
|
effects: vimCompartmentRef.current.reconfigure(vim()),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [vimMode, activeFileId, isTextFile]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const view = viewRef.current;
|
const view = viewRef.current;
|
||||||
if (!view || !isTextFile || isMergeActiveRef.current) return;
|
if (!view || !isTextFile || isMergeActiveRef.current) return;
|
||||||
|
|
@ -961,14 +995,44 @@ export function LatexEditor() {
|
||||||
return { top: relTop, left: relLeft };
|
return { top: relTop, left: relLeft };
|
||||||
}, [selectionCoords]);
|
}, [selectionCoords]);
|
||||||
|
|
||||||
const handleToolbarSendPrompt = useCallback(
|
const buildSelectionContext =
|
||||||
|
useCallback((): PromptContextOverride | null => {
|
||||||
|
if (!selectionRange || !selectionLabel || !activeFile?.content) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const start = Math.min(selectionRange.start, selectionRange.end);
|
||||||
|
const end = Math.max(selectionRange.start, selectionRange.end);
|
||||||
|
const selectedText = activeFile.content.slice(start, end);
|
||||||
|
if (!selectedText) return null;
|
||||||
|
return {
|
||||||
|
label: selectionLabel,
|
||||||
|
filePath: activeFile.relativePath,
|
||||||
|
selectedText,
|
||||||
|
};
|
||||||
|
}, [selectionRange, selectionLabel, activeFile]);
|
||||||
|
|
||||||
|
const sendToolbarPromptWithSelectionContext = useCallback(
|
||||||
(prompt: string) => {
|
(prompt: string) => {
|
||||||
|
const context = buildSelectionContext();
|
||||||
toolbarStickyRef.current = false;
|
toolbarStickyRef.current = false;
|
||||||
setSelectionCoords(null);
|
setSelectionCoords(null);
|
||||||
setSelectionRange(null);
|
setSelectionRange(null);
|
||||||
useClaudeChatStore.getState().sendPrompt(prompt);
|
const chat = useClaudeChatStore.getState();
|
||||||
|
if (context) {
|
||||||
|
void chat.sendPrompt(prompt, context);
|
||||||
|
chat.requestPinnedContextRemoval([context.label]);
|
||||||
|
} else {
|
||||||
|
void chat.sendPrompt(prompt);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[setSelectionRange],
|
[buildSelectionContext, setSelectionRange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleToolbarSendPrompt = useCallback(
|
||||||
|
(prompt: string) => {
|
||||||
|
sendToolbarPromptWithSelectionContext(prompt);
|
||||||
|
},
|
||||||
|
[sendToolbarPromptWithSelectionContext],
|
||||||
);
|
);
|
||||||
|
|
||||||
const editorToolbarActions: ToolbarAction[] = useMemo(
|
const editorToolbarActions: ToolbarAction[] = useMemo(
|
||||||
|
|
@ -984,16 +1048,13 @@ export function LatexEditor() {
|
||||||
|
|
||||||
const handleToolbarAction = useCallback(
|
const handleToolbarAction = useCallback(
|
||||||
(actionId: string) => {
|
(actionId: string) => {
|
||||||
toolbarStickyRef.current = false;
|
|
||||||
setSelectionCoords(null);
|
|
||||||
setSelectionRange(null);
|
|
||||||
if (actionId === "proofread") {
|
if (actionId === "proofread") {
|
||||||
useClaudeChatStore
|
sendToolbarPromptWithSelectionContext(
|
||||||
.getState()
|
"Proofread and fix any errors in this text",
|
||||||
.sendPrompt("Proofread and fix any errors in this text");
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[setSelectionRange],
|
[sendToolbarPromptWithSelectionContext],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleToolbarDismiss = useCallback(() => {
|
const handleToolbarDismiss = useCallback(() => {
|
||||||
|
|
@ -1039,7 +1100,7 @@ export function LatexEditor() {
|
||||||
const isImage = !isTextFile && !isPdf && !!activeFile;
|
const isImage = !isTextFile && !isPdf && !!activeFile;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col bg-background">
|
<div className="flex h-full min-w-0 flex-col bg-background">
|
||||||
{/* Toolbar — adapts to file type */}
|
{/* Toolbar — adapts to file type */}
|
||||||
<EditorToolbar
|
<EditorToolbar
|
||||||
editorView={viewRef}
|
editorView={viewRef}
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ interface SearchPanelProps {
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
onSearchQueryChange: (query: string) => void;
|
onSearchQueryChange: (query: string) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onFindNext: () => void;
|
onFindNext: (options?: { focusEditor?: boolean }) => void;
|
||||||
onFindPrevious: () => void;
|
onFindPrevious: (options?: { focusEditor?: boolean }) => void;
|
||||||
matchCount: number;
|
matchCount: number;
|
||||||
currentMatch: number;
|
currentMatch: number;
|
||||||
}
|
}
|
||||||
|
|
@ -29,14 +29,22 @@ export function SearchPanel({
|
||||||
inputRef.current?.select();
|
inputRef.current?.select();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const keepInputFocused = () => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
inputRef.current?.select();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
onFindPrevious();
|
onFindPrevious({ focusEditor: false });
|
||||||
} else {
|
} else {
|
||||||
onFindNext();
|
onFindNext({ focusEditor: false });
|
||||||
}
|
}
|
||||||
|
keepInputFocused();
|
||||||
} else if (e.key === "Escape") {
|
} else if (e.key === "Escape") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onClose();
|
onClose();
|
||||||
|
|
@ -44,7 +52,7 @@ export function SearchPanel({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-9 items-center gap-2 border-border border-b bg-[#282c34] px-2">
|
<div className="flex h-9 items-center gap-2 border-border border-b bg-background px-2">
|
||||||
<Input
|
<Input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -52,14 +60,14 @@ export function SearchPanel({
|
||||||
onChange={(e) => onSearchQueryChange(e.target.value)}
|
onChange={(e) => onSearchQueryChange(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="Search..."
|
placeholder="Search..."
|
||||||
className="h-6 w-48 bg-[#1e2127] text-[#abb2bf] text-sm placeholder:text-[#636d83]"
|
className="h-6 w-48 border-border bg-muted/40 text-foreground text-sm placeholder:text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-0.5">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="size-6 text-[#abb2bf] hover:bg-white/10 hover:text-[#abb2bf]"
|
className="size-6 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
onClick={onFindPrevious}
|
onClick={() => onFindPrevious()}
|
||||||
disabled={!searchQuery || matchCount === 0}
|
disabled={!searchQuery || matchCount === 0}
|
||||||
>
|
>
|
||||||
<ChevronUpIcon className="size-4" />
|
<ChevronUpIcon className="size-4" />
|
||||||
|
|
@ -67,15 +75,15 @@ export function SearchPanel({
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="size-6 text-[#abb2bf] hover:bg-white/10 hover:text-[#abb2bf]"
|
className="size-6 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
onClick={onFindNext}
|
onClick={() => onFindNext()}
|
||||||
disabled={!searchQuery || matchCount === 0}
|
disabled={!searchQuery || matchCount === 0}
|
||||||
>
|
>
|
||||||
<ChevronDownIcon className="size-4" />
|
<ChevronDownIcon className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<span className="text-[#636d83] text-xs">
|
<span className="text-muted-foreground text-xs">
|
||||||
{matchCount === 0 ? "No results" : `${currentMatch} of ${matchCount}`}
|
{matchCount === 0 ? "No results" : `${currentMatch} of ${matchCount}`}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -83,7 +91,7 @@ export function SearchPanel({
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="size-6 text-[#abb2bf] hover:bg-white/10 hover:text-[#abb2bf]"
|
className="size-6 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<XIcon className="size-4" />
|
<XIcon className="size-4" />
|
||||||
|
|
|
||||||
|
|
@ -344,7 +344,7 @@ function SnapshotRow({
|
||||||
{hasFiles && (
|
{hasFiles && (
|
||||||
<div className="mt-0.5 truncate text-muted-foreground text-xs">
|
<div className="mt-0.5 truncate text-muted-foreground text-xs">
|
||||||
{snapshot.changed_files
|
{snapshot.changed_files
|
||||||
.map((f) => f.split("/").pop())
|
.map((f) => f.split(/[/\\]/).pop())
|
||||||
.join(", ")}
|
.join(", ")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { APP_VISIBILITY_RESTORED } from "@/lib/debug/log-store";
|
||||||
import type { StructuredTextData, LinkData } from "@/lib/mupdf/types";
|
import type { StructuredTextData, LinkData } from "@/lib/mupdf/types";
|
||||||
|
|
||||||
const log = createLogger("mupdf-page");
|
const log = createLogger("mupdf-page");
|
||||||
|
const RENDER_SCALE_DEBOUNCE_MS = 260;
|
||||||
|
|
||||||
interface MupdfPageProps {
|
interface MupdfPageProps {
|
||||||
docId: number;
|
docId: number;
|
||||||
|
|
@ -43,11 +44,25 @@ export const MupdfPage = memo(function MupdfPage({
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const [textData, setTextData] = useState<StructuredTextData | null>(null);
|
const [textData, setTextData] = useState<StructuredTextData | null>(null);
|
||||||
const [links, setLinks] = useState<LinkData[]>([]);
|
const [links, setLinks] = useState<LinkData[]>([]);
|
||||||
|
const [renderScale, setRenderScale] = useState(scale);
|
||||||
const renderGenRef = useRef(0);
|
const renderGenRef = useRef(0);
|
||||||
|
|
||||||
const cssW = pageWidth * scale;
|
const cssW = pageWidth * scale;
|
||||||
const cssH = pageHeight * scale;
|
const cssH = pageHeight * scale;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRenderScale(scale);
|
||||||
|
}, [docId, pageIndex]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isVisible || docId <= 0) return;
|
||||||
|
const timeout = window.setTimeout(
|
||||||
|
() => setRenderScale(scale),
|
||||||
|
RENDER_SCALE_DEBOUNCE_MS,
|
||||||
|
);
|
||||||
|
return () => window.clearTimeout(timeout);
|
||||||
|
}, [docId, isVisible, scale]);
|
||||||
|
|
||||||
/** Re-render the page onto the canvas via MuPDF worker. */
|
/** Re-render the page onto the canvas via MuPDF worker. */
|
||||||
const renderPage = useCallback(() => {
|
const renderPage = useCallback(() => {
|
||||||
if (!isVisible || docId <= 0) return;
|
if (!isVisible || docId <= 0) return;
|
||||||
|
|
@ -55,7 +70,7 @@ export const MupdfPage = memo(function MupdfPage({
|
||||||
const gen = ++renderGenRef.current;
|
const gen = ++renderGenRef.current;
|
||||||
const client = getMupdfClient();
|
const client = getMupdfClient();
|
||||||
const dpr = window.devicePixelRatio || 1;
|
const dpr = window.devicePixelRatio || 1;
|
||||||
const dpi = scale * 72 * dpr;
|
const dpi = renderScale * 72 * dpr;
|
||||||
|
|
||||||
client
|
client
|
||||||
.drawPage(docId, pageIndex, dpi)
|
.drawPage(docId, pageIndex, dpi)
|
||||||
|
|
@ -78,21 +93,25 @@ export const MupdfPage = memo(function MupdfPage({
|
||||||
if (gen !== renderGenRef.current) return;
|
if (gen !== renderGenRef.current) return;
|
||||||
log.error(`Render error page ${pageIndex}`, { error: String(err) });
|
log.error(`Render error page ${pageIndex}`, { error: String(err) });
|
||||||
});
|
});
|
||||||
}, [docId, pageIndex, scale, isVisible]);
|
}, [docId, pageIndex, renderScale, isVisible]);
|
||||||
|
|
||||||
// Initial render and re-render on dependency changes
|
// Render the canvas immediately on load, then at debounced high-quality zoom.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isVisible || docId <= 0) return;
|
if (!isVisible || docId <= 0) return;
|
||||||
|
|
||||||
renderPage();
|
renderPage();
|
||||||
|
}, [docId, pageIndex, renderScale, isVisible, renderPage]);
|
||||||
|
|
||||||
|
// Text and link layers do not need to be refetched for every zoom change.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isVisible || docId <= 0) return;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
const client = getMupdfClient();
|
const client = getMupdfClient();
|
||||||
const gen = renderGenRef.current;
|
|
||||||
|
|
||||||
client
|
client
|
||||||
.getPageText(docId, pageIndex)
|
.getPageText(docId, pageIndex)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (gen !== renderGenRef.current) return;
|
if (cancelled) return;
|
||||||
setTextData(data);
|
setTextData(data);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
@ -100,11 +119,15 @@ export const MupdfPage = memo(function MupdfPage({
|
||||||
client
|
client
|
||||||
.getPageLinks(docId, pageIndex)
|
.getPageLinks(docId, pageIndex)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (gen !== renderGenRef.current) return;
|
if (cancelled) return;
|
||||||
setLinks(data);
|
setLinks(data);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [docId, pageIndex, scale, isVisible, renderPage]);
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [docId, pageIndex, isVisible]);
|
||||||
|
|
||||||
// Re-render canvas when returning from background if content was lost
|
// Re-render canvas when returning from background if content was lost
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,12 @@ import {
|
||||||
useDocumentStore,
|
useDocumentStore,
|
||||||
getPdfBytes,
|
getPdfBytes,
|
||||||
getCurrentPdfBytes,
|
getCurrentPdfBytes,
|
||||||
|
getCurrentPdfRootId,
|
||||||
hasPdfData,
|
hasPdfData,
|
||||||
} from "@/stores/document-store";
|
} from "@/stores/document-store";
|
||||||
import { useHistoryStore } from "@/stores/history-store";
|
import { useHistoryStore } from "@/stores/history-store";
|
||||||
import { useClaudeChatStore } from "@/stores/claude-chat-store";
|
import { useClaudeChatStore } from "@/stores/claude-chat-store";
|
||||||
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
|
|
@ -63,7 +65,7 @@ const log = createLogger("pdf-preview");
|
||||||
|
|
||||||
type FitMode = "fit-width" | "fit-height" | null;
|
type FitMode = "fit-width" | "fit-height" | null;
|
||||||
|
|
||||||
/** Per-root zoom state cache: rootFileId → { scale, fitMode } */
|
/** Per-root zoom state cache: rootFileId -> { scale, fitMode } */
|
||||||
const zoomCache = new Map<string, { scale: number; fitMode: FitMode }>();
|
const zoomCache = new Map<string, { scale: number; fitMode: FitMode }>();
|
||||||
|
|
||||||
/** Max number of PdfViewer instances kept alive simultaneously. */
|
/** Max number of PdfViewer instances kept alive simultaneously. */
|
||||||
|
|
@ -86,6 +88,8 @@ const ZOOM_OPTIONS = [
|
||||||
];
|
];
|
||||||
|
|
||||||
export function PdfPreview() {
|
export function PdfPreview() {
|
||||||
|
const compilerBackend = useSettingsStore((s) => s.compilerBackend);
|
||||||
|
const setCompilerBackend = useSettingsStore((s) => s.setCompilerBackend);
|
||||||
const pdfRevision = useDocumentStore((s) => s.pdfRevision);
|
const pdfRevision = useDocumentStore((s) => s.pdfRevision);
|
||||||
const compileError = useDocumentStore((s) => s.compileError);
|
const compileError = useDocumentStore((s) => s.compileError);
|
||||||
const isCompiling = useDocumentStore((s) => s.isCompiling);
|
const isCompiling = useDocumentStore((s) => s.isCompiling);
|
||||||
|
|
@ -131,7 +135,10 @@ export function PdfPreview() {
|
||||||
const pdfData = useMemo(() => getCurrentPdfBytes(), [pdfRevision]); // eslint-disable-line react-hooks/exhaustive-deps
|
const pdfData = useMemo(() => getCurrentPdfBytes(), [pdfRevision]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// Keep-alive: track which root files have PdfViewer instances alive (LRU order)
|
// Keep-alive: track which root files have PdfViewer instances alive (LRU order)
|
||||||
const currentRootFileId = resolveTexRoot(activeFile?.id ?? "", files);
|
const currentRootFileId =
|
||||||
|
activeFile?.type === "tex"
|
||||||
|
? resolveTexRoot(activeFile.id, files)
|
||||||
|
: (getCurrentPdfRootId() ?? resolveTexRoot(activeFile?.id ?? "", files));
|
||||||
const [aliveOrder, setAliveOrder] = useState<string[]>([]);
|
const [aliveOrder, setAliveOrder] = useState<string[]>([]);
|
||||||
const prevRootRef = useRef(currentRootFileId);
|
const prevRootRef = useRef(currentRootFileId);
|
||||||
|
|
||||||
|
|
@ -416,7 +423,9 @@ export function PdfPreview() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { rootId, targetPath } = resolved;
|
const { rootId, targetPath } = resolved;
|
||||||
const data = await compileLatex(projectRoot, targetPath);
|
const texlive =
|
||||||
|
useSettingsStore.getState().compilerBackend === "texlive";
|
||||||
|
const data = await compileLatex(projectRoot, targetPath, texlive);
|
||||||
setPdfData(data, rootId);
|
setPdfData(data, rootId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setCompileError(formatCompileError(error));
|
setCompileError(formatCompileError(error));
|
||||||
|
|
@ -553,7 +562,8 @@ export function PdfPreview() {
|
||||||
const compileStart = Date.now();
|
const compileStart = Date.now();
|
||||||
try {
|
try {
|
||||||
await saveAllFiles();
|
await saveAllFiles();
|
||||||
const data = await compileLatex(state.projectRoot, targetFile);
|
const texlive = useSettingsStore.getState().compilerBackend === "texlive";
|
||||||
|
const data = await compileLatex(state.projectRoot, targetFile, texlive);
|
||||||
setPdfData(data, rootId);
|
setPdfData(data, rootId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setCompileError(formatCompileError(error), rootId);
|
setCompileError(formatCompileError(error), rootId);
|
||||||
|
|
@ -683,7 +693,7 @@ export function PdfPreview() {
|
||||||
PDF Preview
|
PDF Preview
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mb-4 text-center text-muted-foreground text-sm">
|
<p className="mb-4 text-center text-muted-foreground text-sm">
|
||||||
Press ⌘+Enter to compile your document
|
Press Cmd+Enter to compile your document
|
||||||
</p>
|
</p>
|
||||||
{isTexActive && (
|
{isTexActive && (
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -794,12 +804,29 @@ export function PdfPreview() {
|
||||||
ref={previewContainerRef}
|
ref={previewContainerRef}
|
||||||
className="@container/pv relative flex h-full flex-col bg-muted/50"
|
className="@container/pv relative flex h-full flex-col bg-muted/50"
|
||||||
>
|
>
|
||||||
<div className="flex h-[calc(40px+var(--titlebar-height))] shrink-0 items-center border-border border-b bg-background px-2 pt-[var(--titlebar-height)]">
|
<div className="flex h-[calc(var(--workspace-topbar-height)+var(--titlebar-height))] shrink-0 flex-nowrap items-center border-border border-b bg-background px-2">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex min-w-0 shrink-0 items-center gap-1">
|
||||||
|
<Select
|
||||||
|
value={compilerBackend}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setCompilerBackend(v as "tectonic" | "texlive")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
size="sm"
|
||||||
|
className="h-7! @[44rem]/pv:w-[8.5rem] w-[6.75rem] text-xs"
|
||||||
|
>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="tectonic">Tectonic</SelectItem>
|
||||||
|
<SelectItem value="texlive">TeXLive</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
{isSaving && (
|
{isSaving && (
|
||||||
<div className="flex items-center gap-1.5 rounded-md bg-muted/50 px-2 py-1">
|
<div className="flex items-center gap-1.5 rounded-md bg-muted/50 px-2 py-1">
|
||||||
<LoaderIcon className="size-3.5 animate-spin text-muted-foreground" />
|
<LoaderIcon className="size-3.5 animate-spin text-muted-foreground" />
|
||||||
<span className="font-medium text-muted-foreground text-xs">
|
<span className="@[38rem]/pv:inline hidden font-medium text-muted-foreground text-xs">
|
||||||
Saving...
|
Saving...
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -807,7 +834,7 @@ export function PdfPreview() {
|
||||||
{!isSaving && isCompiling && (
|
{!isSaving && isCompiling && (
|
||||||
<div className="flex items-center gap-1.5 rounded-md bg-muted/50 px-2 py-1">
|
<div className="flex items-center gap-1.5 rounded-md bg-muted/50 px-2 py-1">
|
||||||
<LoaderIcon className="size-3.5 animate-spin text-muted-foreground" />
|
<LoaderIcon className="size-3.5 animate-spin text-muted-foreground" />
|
||||||
<span className="font-medium text-muted-foreground text-xs">
|
<span className="@[38rem]/pv:inline hidden font-medium text-muted-foreground text-xs">
|
||||||
Compiling...
|
Compiling...
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -816,11 +843,14 @@ export function PdfPreview() {
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-7 gap-1.5 px-2.5 text-xs"
|
className="h-7 gap-1.5 @[42rem]/pv:px-2.5 px-2 text-xs"
|
||||||
onClick={() => handleCompile(true)}
|
onClick={() => handleCompile(true)}
|
||||||
|
title={pdfData ? "Recompile" : "Compile"}
|
||||||
>
|
>
|
||||||
<RefreshCwIcon className="size-3.5" />
|
<RefreshCwIcon className="size-3.5" />
|
||||||
|
<span className="@[42rem]/pv:inline hidden">
|
||||||
{pdfData ? "Recompile" : "Compile"}
|
{pdfData ? "Recompile" : "Compile"}
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{!isSaving && !isCompiling && compileError && (
|
{!isSaving && !isCompiling && compileError && (
|
||||||
|
|
@ -830,14 +860,15 @@ export function PdfPreview() {
|
||||||
className="h-7 gap-1.5 px-2.5 text-destructive text-xs hover:text-destructive"
|
className="h-7 gap-1.5 px-2.5 text-destructive text-xs hover:text-destructive"
|
||||||
onClick={() => handleCompile(true)}
|
onClick={() => handleCompile(true)}
|
||||||
disabled={!isTexActive}
|
disabled={!isTexActive}
|
||||||
|
title="Retry compile"
|
||||||
>
|
>
|
||||||
<RefreshCwIcon className="size-3.5" />
|
<RefreshCwIcon className="size-3.5" />
|
||||||
Retry
|
<span className="@[42rem]/pv:inline hidden">Retry</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div data-tauri-drag-region className="flex-1 self-stretch" />
|
<div data-tauri-drag-region className="min-w-2 flex-1 self-stretch" />
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="ml-auto flex min-w-0 shrink-0 flex-nowrap items-center justify-end gap-1">
|
||||||
{pdfData && (
|
{pdfData && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -854,7 +885,7 @@ export function PdfPreview() {
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
className="h-6 w-8 shrink-0 rounded border border-border bg-background text-center text-foreground text-xs outline-none focus:ring-1 focus:ring-ring"
|
className="h-6 w-7 shrink-0 rounded border border-border bg-background text-center text-foreground text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||||
value={pageInputValue}
|
value={pageInputValue}
|
||||||
onChange={(e) => setPageInputValue(e.target.value)}
|
onChange={(e) => setPageInputValue(e.target.value)}
|
||||||
onBlur={handlePageInputCommit}
|
onBlur={handlePageInputCommit}
|
||||||
|
|
@ -868,7 +899,7 @@ export function PdfPreview() {
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
className="flex h-6 min-w-[2rem] shrink-0 items-center justify-center rounded px-1 text-muted-foreground text-xs tabular-nums hover:bg-muted"
|
className="flex h-6 w-7 shrink-0 items-center justify-center rounded text-muted-foreground text-xs tabular-nums hover:bg-muted"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsEditingPage(true);
|
setIsEditingPage(true);
|
||||||
setPageInputValue(String(currentPage));
|
setPageInputValue(String(currentPage));
|
||||||
|
|
@ -878,20 +909,23 @@ export function PdfPreview() {
|
||||||
{currentPage}
|
{currentPage}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<span className="shrink-0 whitespace-nowrap text-muted-foreground text-xs">
|
<span className="shrink-0 text-muted-foreground/80 text-xs tabular-nums">
|
||||||
/ {numPages}
|
/
|
||||||
|
</span>
|
||||||
|
<span className="flex h-6 w-7 shrink-0 items-center justify-center text-muted-foreground text-xs tabular-nums">
|
||||||
|
{numPages}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="size-7"
|
className="size-7 shrink-0"
|
||||||
onClick={() => goToPage(currentPage + 1)}
|
onClick={() => goToPage(currentPage + 1)}
|
||||||
disabled={currentPage >= numPages}
|
disabled={currentPage >= numPages}
|
||||||
title="Page Down"
|
title="Page Down"
|
||||||
>
|
>
|
||||||
<ChevronDownIcon className="size-3.5" />
|
<ChevronDownIcon className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="mx-1 h-4 w-px bg-border" />
|
<div className="mx-1 @[34rem]/pv:block hidden h-4 w-px bg-border" />
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|
@ -921,7 +955,10 @@ export function PdfPreview() {
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger size="sm" className="h-7! w-auto text-xs">
|
<SelectTrigger
|
||||||
|
size="sm"
|
||||||
|
className="h-7! @[48rem]/pv:w-[7.5rem] w-[5rem] text-xs"
|
||||||
|
>
|
||||||
<SelectValue>
|
<SelectValue>
|
||||||
{fitMode === "fit-width"
|
{fitMode === "fit-width"
|
||||||
? "Fit width"
|
? "Fit width"
|
||||||
|
|
@ -941,26 +978,26 @@ export function PdfPreview() {
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<div className="mx-1 h-4 w-px bg-border" />
|
<div className="mx-1 @[34rem]/pv:block hidden h-4 w-px bg-border" />
|
||||||
{/* Capture mode */}
|
{/* Capture mode */}
|
||||||
<Button
|
<Button
|
||||||
variant={captureMode ? "default" : "secondary"}
|
variant={captureMode ? "default" : "secondary"}
|
||||||
size="sm"
|
size="sm"
|
||||||
className={`h-7 gap-1.5 px-2 text-xs ${
|
className={`h-7 gap-1.5 @[56rem]/pv:px-2.5 px-2 text-xs ${
|
||||||
captureMode
|
captureMode
|
||||||
? "ring-2 ring-primary/30"
|
? "ring-2 ring-primary/30"
|
||||||
: "bg-foreground text-background hover:bg-foreground/90"
|
: "bg-foreground text-background hover:bg-foreground/90"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setCaptureMode(!captureMode)}
|
onClick={() => setCaptureMode(!captureMode)}
|
||||||
title={`Capture & Ask (${navigator.userAgent.includes("Mac") ? "⌘X" : "Ctrl+X"})`}
|
title={`Capture & Ask (${navigator.userAgent.includes("Mac") ? "Cmd+X" : "Ctrl+X"})`}
|
||||||
>
|
>
|
||||||
<CrosshairIcon className="size-3.5 shrink-0" />
|
<CrosshairIcon className="size-3.5 shrink-0" />
|
||||||
<span className="@[36rem]/pv:inline hidden">Capture & Ask</span>
|
<span className="@[56rem]/pv:inline hidden">Capture & Ask</span>
|
||||||
<kbd className="pointer-events-none ml-0.5 @[36rem]/pv:inline hidden rounded border border-background/30 bg-background/20 px-1 py-0.5 font-medium text-[10px] text-background leading-none">
|
<kbd className="pointer-events-none ml-0.5 @[64rem]/pv:inline hidden rounded border border-background/30 bg-background/20 px-1 py-0.5 font-medium text-[10px] text-background leading-none">
|
||||||
{navigator.userAgent.includes("Mac") ? "⌘X" : "Ctrl+X"}
|
{navigator.userAgent.includes("Mac") ? "Cmd+X" : "Ctrl+X"}
|
||||||
</kbd>
|
</kbd>
|
||||||
</Button>
|
</Button>
|
||||||
<div className="mx-1 h-4 w-px bg-border" />
|
<div className="mx-1 @[34rem]/pv:block hidden h-4 w-px bg-border" />
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|
@ -1014,7 +1051,7 @@ export function PdfPreview() {
|
||||||
</kbd>
|
</kbd>
|
||||||
<span className="text-[10px] text-muted-foreground">or</span>
|
<span className="text-[10px] text-muted-foreground">or</span>
|
||||||
<kbd className="rounded border border-border bg-muted px-1.5 py-0.5 font-medium text-[10px] text-muted-foreground">
|
<kbd className="rounded border border-border bg-muted px-1.5 py-0.5 font-medium text-[10px] text-muted-foreground">
|
||||||
{navigator.userAgent.includes("Mac") ? "⌘" : "Ctrl+"}X
|
{navigator.userAgent.includes("Mac") ? "Cmd+" : "Ctrl+"}X
|
||||||
</kbd>
|
</kbd>
|
||||||
<span className="text-[10px] text-muted-foreground">to cancel</span>
|
<span className="text-[10px] text-muted-foreground">to cancel</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,10 @@
|
||||||
import { useCallback, useRef, useEffect, useState } from "react";
|
import {
|
||||||
|
useCallback,
|
||||||
|
useRef,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { LoaderIcon } from "lucide-react";
|
import { LoaderIcon } from "lucide-react";
|
||||||
import { open as shellOpen } from "@tauri-apps/plugin-shell";
|
import { open as shellOpen } from "@tauri-apps/plugin-shell";
|
||||||
import { ask } from "@tauri-apps/plugin-dialog";
|
import { ask } from "@tauri-apps/plugin-dialog";
|
||||||
|
|
@ -6,6 +12,7 @@ import {
|
||||||
getCachedDocument,
|
getCachedDocument,
|
||||||
getOrOpenDocument,
|
getOrOpenDocument,
|
||||||
} from "@/lib/mupdf/pdf-doc-cache";
|
} from "@/lib/mupdf/pdf-doc-cache";
|
||||||
|
import { LOCAL_ZOOM_SHORTCUTS_ATTR } from "@/lib/app-zoom";
|
||||||
import { MupdfPage } from "./mupdf-page";
|
import { MupdfPage } from "./mupdf-page";
|
||||||
import { createLogger } from "@/lib/debug/logger";
|
import { createLogger } from "@/lib/debug/logger";
|
||||||
import { APP_VISIBILITY_RESTORED } from "@/lib/debug/log-store";
|
import { APP_VISIBILITY_RESTORED } from "@/lib/debug/log-store";
|
||||||
|
|
@ -13,6 +20,159 @@ import type { PageSize } from "@/lib/mupdf/types";
|
||||||
|
|
||||||
const log = createLogger("pdf-viewer");
|
const log = createLogger("pdf-viewer");
|
||||||
|
|
||||||
|
const MIN_PDF_SCALE = 0.25;
|
||||||
|
const MAX_PDF_SCALE = 4;
|
||||||
|
const MOUSE_WHEEL_ZOOM_SENSITIVITY = 0.00125;
|
||||||
|
const TRACKPAD_PINCH_ZOOM_SENSITIVITY = 0.012;
|
||||||
|
const MAX_MOUSE_WHEEL_FACTOR_PER_EVENT = 1.22;
|
||||||
|
const MAX_TRACKPAD_PINCH_FACTOR_PER_EVENT = 1.28;
|
||||||
|
|
||||||
|
type WebKitGestureEvent = Event & {
|
||||||
|
scale?: number;
|
||||||
|
clientX?: number;
|
||||||
|
clientY?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function clampPdfScale(value: number): number {
|
||||||
|
return Math.max(MIN_PDF_SCALE, Math.min(MAX_PDF_SCALE, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isModifiedZoomWheel(event: WheelEvent): boolean {
|
||||||
|
return (event.metaKey || event.ctrlKey) && !event.altKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWheelInsidePdfViewer(
|
||||||
|
event: WheelEvent,
|
||||||
|
container: HTMLElement,
|
||||||
|
): boolean {
|
||||||
|
if (event.target instanceof Node && container.contains(event.target)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
event
|
||||||
|
.composedPath()
|
||||||
|
.some((target) => target instanceof Node && container.contains(target))
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
return (
|
||||||
|
event.clientX >= rect.left &&
|
||||||
|
event.clientX <= rect.right &&
|
||||||
|
event.clientY >= rect.top &&
|
||||||
|
event.clientY <= rect.bottom
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTrackpadPinchWheel(
|
||||||
|
event: WheelEvent,
|
||||||
|
isCtrlKeyDown: boolean,
|
||||||
|
isMetaKeyDown: boolean,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
event.ctrlKey &&
|
||||||
|
!event.metaKey &&
|
||||||
|
!isCtrlKeyDown &&
|
||||||
|
!isMetaKeyDown &&
|
||||||
|
event.deltaMode === WheelEvent.DOM_DELTA_PIXEL &&
|
||||||
|
Math.abs(event.deltaX) < 1 &&
|
||||||
|
event.deltaZ === 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampZoomFactor(factor: number, maxFactor: number): number {
|
||||||
|
if (!Number.isFinite(factor) || factor <= 0) return 1;
|
||||||
|
return Math.max(1 / maxFactor, Math.min(maxFactor, factor));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWheelZoomFactor(
|
||||||
|
event: WheelEvent,
|
||||||
|
isTrackpadPinch: boolean,
|
||||||
|
): number {
|
||||||
|
if (event.deltaY === 0) return 1;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!isTrackpadPinch &&
|
||||||
|
(event.deltaMode === WheelEvent.DOM_DELTA_LINE ||
|
||||||
|
event.deltaMode === WheelEvent.DOM_DELTA_PAGE)
|
||||||
|
) {
|
||||||
|
return event.deltaY < 0 ? 1.16 : 1 / 1.16;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sensitivity = isTrackpadPinch
|
||||||
|
? TRACKPAD_PINCH_ZOOM_SENSITIVITY
|
||||||
|
: MOUSE_WHEEL_ZOOM_SENSITIVITY;
|
||||||
|
const maxFactor = isTrackpadPinch
|
||||||
|
? MAX_TRACKPAD_PINCH_FACTOR_PER_EVENT
|
||||||
|
: MAX_MOUSE_WHEEL_FACTOR_PER_EVENT;
|
||||||
|
|
||||||
|
return clampZoomFactor(Math.exp(-event.deltaY * sensitivity), maxFactor);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageZoomAnchor {
|
||||||
|
pageNumber: number;
|
||||||
|
pdfX: number;
|
||||||
|
pdfY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingZoomScroll {
|
||||||
|
anchorClientX: number;
|
||||||
|
anchorClientY: number;
|
||||||
|
pageAnchor: PageZoomAnchor | null;
|
||||||
|
fallbackAnchorX: number;
|
||||||
|
fallbackAnchorY: number;
|
||||||
|
fallbackRatio: number;
|
||||||
|
nextScale: number;
|
||||||
|
containerLeft: number;
|
||||||
|
containerTop: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findPageZoomAnchor(
|
||||||
|
container: HTMLElement,
|
||||||
|
clientX: number,
|
||||||
|
clientY: number,
|
||||||
|
scale: number,
|
||||||
|
): PageZoomAnchor | null {
|
||||||
|
if (scale <= 0) return null;
|
||||||
|
|
||||||
|
const pages = Array.from(container.querySelectorAll(".mupdf-page"));
|
||||||
|
let bestPage: HTMLElement | null = null;
|
||||||
|
let bestDistance = Number.POSITIVE_INFINITY;
|
||||||
|
|
||||||
|
for (const page of pages) {
|
||||||
|
const el = page as HTMLElement;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const distance =
|
||||||
|
clientY >= rect.top && clientY <= rect.bottom
|
||||||
|
? 0
|
||||||
|
: Math.min(
|
||||||
|
Math.abs(clientY - rect.top),
|
||||||
|
Math.abs(clientY - rect.bottom),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (distance < bestDistance) {
|
||||||
|
bestDistance = distance;
|
||||||
|
bestPage = el;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bestPage) return null;
|
||||||
|
|
||||||
|
const pageNumber = parseInt(
|
||||||
|
bestPage.getAttribute("data-page-number") || "0",
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
if (!pageNumber) return null;
|
||||||
|
|
||||||
|
const rect = bestPage.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
pageNumber,
|
||||||
|
pdfX: (clientX - rect.left) / scale,
|
||||||
|
pdfY: (clientY - rect.top) / scale,
|
||||||
|
};
|
||||||
|
}
|
||||||
/** Module-level scroll position cache: rootFileId → page number */
|
/** Module-level scroll position cache: rootFileId → page number */
|
||||||
const scrollPositionCache = new Map<string, number>();
|
const scrollPositionCache = new Map<string, number>();
|
||||||
|
|
||||||
|
|
@ -88,6 +248,17 @@ export function PdfViewer({
|
||||||
|
|
||||||
const scaleRef = useRef(scale);
|
const scaleRef = useRef(scale);
|
||||||
scaleRef.current = scale;
|
scaleRef.current = scale;
|
||||||
|
const renderedScaleRef = useRef(scale);
|
||||||
|
const pendingZoomScrollRef = useRef<PendingZoomScroll | null>(null);
|
||||||
|
const ctrlKeyDownRef = useRef(false);
|
||||||
|
const metaKeyDownRef = useRef(false);
|
||||||
|
const touchPinchRef = useRef<{
|
||||||
|
distance: number;
|
||||||
|
scale: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
} | null>(null);
|
||||||
|
const gesturePinchRef = useRef<{ scale: number } | null>(null);
|
||||||
const synctexClickRef = useRef(onSynctexClick);
|
const synctexClickRef = useRef(onSynctexClick);
|
||||||
synctexClickRef.current = onSynctexClick;
|
synctexClickRef.current = onSynctexClick;
|
||||||
const textSelectRef = useRef(onTextSelect);
|
const textSelectRef = useRef(onTextSelect);
|
||||||
|
|
@ -106,6 +277,42 @@ export function PdfViewer({
|
||||||
window.removeEventListener(APP_VISIBILITY_RESTORED, handleRestore);
|
window.removeEventListener(APP_VISIBILITY_RESTORED, handleRestore);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
renderedScaleRef.current = scale;
|
||||||
|
|
||||||
|
const pending = pendingZoomScrollRef.current;
|
||||||
|
if (!pending || Math.abs(pending.nextScale - scale) > 0.001) return;
|
||||||
|
pendingZoomScrollRef.current = null;
|
||||||
|
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (pending.pageAnchor) {
|
||||||
|
const pageEl = container.querySelector(
|
||||||
|
`.mupdf-page[data-page-number="${pending.pageAnchor.pageNumber}"]`,
|
||||||
|
) as HTMLElement | null;
|
||||||
|
if (pageEl) {
|
||||||
|
const pageRect = pageEl.getBoundingClientRect();
|
||||||
|
container.scrollLeft +=
|
||||||
|
pageRect.left +
|
||||||
|
pending.pageAnchor.pdfX * scale -
|
||||||
|
pending.anchorClientX;
|
||||||
|
container.scrollTop +=
|
||||||
|
pageRect.top +
|
||||||
|
pending.pageAnchor.pdfY * scale -
|
||||||
|
pending.anchorClientY;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
container.scrollLeft =
|
||||||
|
pending.fallbackAnchorX * pending.fallbackRatio -
|
||||||
|
(pending.anchorClientX - pending.containerLeft);
|
||||||
|
container.scrollTop =
|
||||||
|
pending.fallbackAnchorY * pending.fallbackRatio -
|
||||||
|
(pending.anchorClientY - pending.containerTop);
|
||||||
|
}, [scale]);
|
||||||
|
|
||||||
// Keep-alive scroll save/restore
|
// Keep-alive scroll save/restore
|
||||||
const savedScrollTop = useRef(0);
|
const savedScrollTop = useRef(0);
|
||||||
const prevIsActive = useRef(isActive);
|
const prevIsActive = useRef(isActive);
|
||||||
|
|
@ -139,6 +346,30 @@ export function PdfViewer({
|
||||||
|
|
||||||
const numPages = pageSizes.length;
|
const numPages = pageSizes.length;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Control") ctrlKeyDownRef.current = true;
|
||||||
|
if (event.key === "Meta") metaKeyDownRef.current = true;
|
||||||
|
};
|
||||||
|
const handleKeyUp = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Control") ctrlKeyDownRef.current = false;
|
||||||
|
if (event.key === "Meta") metaKeyDownRef.current = false;
|
||||||
|
};
|
||||||
|
const resetModifierState = () => {
|
||||||
|
ctrlKeyDownRef.current = false;
|
||||||
|
metaKeyDownRef.current = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown, true);
|
||||||
|
window.addEventListener("keyup", handleKeyUp, true);
|
||||||
|
window.addEventListener("blur", resetModifierState);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKeyDown, true);
|
||||||
|
window.removeEventListener("keyup", handleKeyUp, true);
|
||||||
|
window.removeEventListener("blur", resetModifierState);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
function getVisiblePage(): number {
|
function getVisiblePage(): number {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container) return 1;
|
if (!container) return 1;
|
||||||
|
|
@ -338,7 +569,7 @@ export function PdfViewer({
|
||||||
pages.forEach((p) => observer.observe(p));
|
pages.forEach((p) => observer.observe(p));
|
||||||
|
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [pageSizes, scale, isActive, focusGen]);
|
}, [pageSizes, isActive, focusGen]);
|
||||||
|
|
||||||
// Report container dimensions to parent for fit-to-width/height
|
// Report container dimensions to parent for fit-to-width/height
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -515,24 +746,199 @@ export function PdfViewer({
|
||||||
return () => container.removeEventListener("scroll", handleScroll);
|
return () => container.removeEventListener("scroll", handleScroll);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Pinch-to-zoom
|
const zoomAtPoint = useCallback(
|
||||||
|
(nextScaleValue: number, clientX?: number, clientY?: number) => {
|
||||||
|
if (!onScaleChange) return;
|
||||||
|
|
||||||
|
const container = containerRef.current;
|
||||||
|
const previousScale = scaleRef.current;
|
||||||
|
const renderedScale = renderedScaleRef.current;
|
||||||
|
const nextScale = clampPdfScale(nextScaleValue);
|
||||||
|
if (Math.abs(nextScale - previousScale) < 0.001) return;
|
||||||
|
|
||||||
|
if (!container || previousScale <= 0 || renderedScale <= 0) {
|
||||||
|
scaleRef.current = nextScale;
|
||||||
|
onScaleChange(nextScale);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
const anchorClientX =
|
||||||
|
clientX ?? containerRect.left + containerRect.width / 2;
|
||||||
|
const anchorClientY =
|
||||||
|
clientY ?? containerRect.top + containerRect.height / 2;
|
||||||
|
const pageAnchor = findPageZoomAnchor(
|
||||||
|
container,
|
||||||
|
anchorClientX,
|
||||||
|
anchorClientY,
|
||||||
|
renderedScale,
|
||||||
|
);
|
||||||
|
const fallbackAnchorX =
|
||||||
|
anchorClientX - containerRect.left + container.scrollLeft;
|
||||||
|
const fallbackAnchorY =
|
||||||
|
anchorClientY - containerRect.top + container.scrollTop;
|
||||||
|
const fallbackRatio = nextScale / renderedScale;
|
||||||
|
|
||||||
|
pendingZoomScrollRef.current = {
|
||||||
|
anchorClientX,
|
||||||
|
anchorClientY,
|
||||||
|
pageAnchor,
|
||||||
|
fallbackAnchorX,
|
||||||
|
fallbackAnchorY,
|
||||||
|
fallbackRatio,
|
||||||
|
nextScale,
|
||||||
|
containerLeft: containerRect.left,
|
||||||
|
containerTop: containerRect.top,
|
||||||
|
};
|
||||||
|
|
||||||
|
scaleRef.current = nextScale;
|
||||||
|
onScaleChange(nextScale);
|
||||||
|
},
|
||||||
|
[onScaleChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ctrl/Cmd + wheel zoom. Windows precision touchpad pinch arrives here too.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container || !onScaleChange) return;
|
if (!container || !onScaleChange) return;
|
||||||
|
|
||||||
const handleWheel = (e: WheelEvent) => {
|
const handleWheel = (e: WheelEvent) => {
|
||||||
if (e.metaKey || e.ctrlKey) {
|
if (!isModifiedZoomWheel(e) || !isWheelInsidePdfViewer(e, container)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const delta = -e.deltaY * 0.005;
|
e.stopPropagation();
|
||||||
onScaleChange(Math.max(0.25, Math.min(4, scale + delta)));
|
|
||||||
|
const isTrackpadPinch = isTrackpadPinchWheel(
|
||||||
|
e,
|
||||||
|
ctrlKeyDownRef.current,
|
||||||
|
metaKeyDownRef.current,
|
||||||
|
);
|
||||||
|
const factor = getWheelZoomFactor(e, isTrackpadPinch);
|
||||||
|
if (Math.abs(factor - 1) < 0.0001) return;
|
||||||
|
zoomAtPoint(scaleRef.current * factor, e.clientX, e.clientY);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("wheel", handleWheel, {
|
||||||
|
passive: false,
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("wheel", handleWheel);
|
||||||
|
pendingZoomScrollRef.current = null;
|
||||||
|
};
|
||||||
|
}, [onScaleChange, zoomAtPoint]);
|
||||||
|
|
||||||
|
// Some WebViews report trackpad pinch through non-standard WebKit gesture
|
||||||
|
// events instead of Ctrl/Cmd + wheel.
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container || !onScaleChange) return;
|
||||||
|
|
||||||
|
const readGestureScale = (event: WebKitGestureEvent) =>
|
||||||
|
typeof event.scale === "number" && Number.isFinite(event.scale)
|
||||||
|
? event.scale
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
const handleGestureStart: EventListener = (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
gesturePinchRef.current = { scale: scaleRef.current };
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGestureChange: EventListener = (event) => {
|
||||||
|
const gesture = event as WebKitGestureEvent;
|
||||||
|
const start = gesturePinchRef.current;
|
||||||
|
if (!start) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
const factor =
|
||||||
|
(start.scale * readGestureScale(gesture)) / scaleRef.current;
|
||||||
|
zoomAtPoint(scaleRef.current * factor, gesture.clientX, gesture.clientY);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGestureEnd: EventListener = () => {
|
||||||
|
gesturePinchRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
container.addEventListener("gesturestart", handleGestureStart, {
|
||||||
|
passive: false,
|
||||||
|
});
|
||||||
|
container.addEventListener("gesturechange", handleGestureChange, {
|
||||||
|
passive: false,
|
||||||
|
});
|
||||||
|
container.addEventListener("gestureend", handleGestureEnd);
|
||||||
|
return () => {
|
||||||
|
container.removeEventListener("gesturestart", handleGestureStart);
|
||||||
|
container.removeEventListener("gesturechange", handleGestureChange);
|
||||||
|
container.removeEventListener("gestureend", handleGestureEnd);
|
||||||
|
gesturePinchRef.current = null;
|
||||||
|
};
|
||||||
|
}, [onScaleChange, zoomAtPoint]);
|
||||||
|
|
||||||
|
// Two-finger touch pinch for touch-capable screens. Trackpads usually arrive
|
||||||
|
// through the wheel path above.
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container || !onScaleChange) return;
|
||||||
|
|
||||||
|
const readTouchPinch = (touches: TouchList) => {
|
||||||
|
if (touches.length < 2) return null;
|
||||||
|
const first = touches.item(0);
|
||||||
|
const second = touches.item(1);
|
||||||
|
if (!first || !second) return null;
|
||||||
|
const dx = second.clientX - first.clientX;
|
||||||
|
const dy = second.clientY - first.clientY;
|
||||||
|
return {
|
||||||
|
distance: Math.hypot(dx, dy),
|
||||||
|
x: (first.clientX + second.clientX) / 2,
|
||||||
|
y: (first.clientY + second.clientY) / 2,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchStart = (event: TouchEvent) => {
|
||||||
|
const pinch = readTouchPinch(event.touches);
|
||||||
|
if (!pinch) return;
|
||||||
|
event.preventDefault();
|
||||||
|
touchPinchRef.current = {
|
||||||
|
...pinch,
|
||||||
|
scale: scaleRef.current,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchMove = (event: TouchEvent) => {
|
||||||
|
const start = touchPinchRef.current;
|
||||||
|
const pinch = readTouchPinch(event.touches);
|
||||||
|
if (!start || !pinch || start.distance <= 0) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const factor =
|
||||||
|
(start.scale * (pinch.distance / start.distance)) / scaleRef.current;
|
||||||
|
zoomAtPoint(scaleRef.current * factor, pinch.x, pinch.y);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchEnd = () => {
|
||||||
|
if (containerRef.current && touchPinchRef.current) {
|
||||||
|
touchPinchRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
container.addEventListener("wheel", handleWheel, { passive: false });
|
container.addEventListener("touchstart", handleTouchStart, {
|
||||||
return () => container.removeEventListener("wheel", handleWheel);
|
passive: false,
|
||||||
}, [scale, onScaleChange]);
|
});
|
||||||
|
container.addEventListener("touchmove", handleTouchMove, {
|
||||||
|
passive: false,
|
||||||
|
});
|
||||||
|
container.addEventListener("touchend", handleTouchEnd);
|
||||||
|
container.addEventListener("touchcancel", handleTouchEnd);
|
||||||
|
return () => {
|
||||||
|
container.removeEventListener("touchstart", handleTouchStart);
|
||||||
|
container.removeEventListener("touchmove", handleTouchMove);
|
||||||
|
container.removeEventListener("touchend", handleTouchEnd);
|
||||||
|
container.removeEventListener("touchcancel", handleTouchEnd);
|
||||||
|
touchPinchRef.current = null;
|
||||||
|
};
|
||||||
|
}, [onScaleChange, zoomAtPoint]);
|
||||||
|
|
||||||
// Keyboard zoom (Cmd/Ctrl +/-) — scoped to container to avoid affecting other panels
|
// Keyboard zoom (Cmd/Ctrl +/-), scoped to the PDF viewer.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container || !onScaleChange) return;
|
if (!container || !onScaleChange) return;
|
||||||
|
|
@ -543,19 +949,19 @@ export function PdfViewer({
|
||||||
|
|
||||||
if (e.key === "=" || e.key === "+") {
|
if (e.key === "=" || e.key === "+") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onScaleChange(Math.min(4, scale + 0.25));
|
zoomAtPoint(scaleRef.current + 0.25);
|
||||||
} else if (e.key === "-") {
|
} else if (e.key === "-") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onScaleChange(Math.max(0.25, scale - 0.25));
|
zoomAtPoint(scaleRef.current - 0.25);
|
||||||
} else if (e.key === "0") {
|
} else if (e.key === "0") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onScaleChange(1);
|
zoomAtPoint(1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
container.addEventListener("keydown", handleKeyDown);
|
container.addEventListener("keydown", handleKeyDown);
|
||||||
return () => container.removeEventListener("keydown", handleKeyDown);
|
return () => container.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [scale, onScaleChange]);
|
}, [onScaleChange, zoomAtPoint]);
|
||||||
|
|
||||||
// Intercept link clicks
|
// Intercept link clicks
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -751,8 +1157,13 @@ export function PdfViewer({
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
|
{...{ [LOCAL_ZOOM_SHORTCUTS_ATTR]: "true" }}
|
||||||
className="min-h-0 flex-1 overflow-auto outline-none"
|
className="min-h-0 flex-1 overflow-auto outline-none"
|
||||||
style={{ cursor: captureMode ? "crosshair" : undefined }}
|
style={{
|
||||||
|
cursor: captureMode ? "crosshair" : undefined,
|
||||||
|
touchAction: captureMode ? "none" : "pan-x pan-y",
|
||||||
|
}}
|
||||||
|
onMouseDownCapture={() => containerRef.current?.focus()}
|
||||||
onMouseDown={handleCaptureMouseDown}
|
onMouseDown={handleCaptureMouseDown}
|
||||||
onMouseMove={handleCaptureMouseMove}
|
onMouseMove={handleCaptureMouseMove}
|
||||||
onMouseUp={handleCaptureMouseUp}
|
onMouseUp={handleCaptureMouseUp}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,175 @@
|
||||||
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
Panel,
|
||||||
|
PanelGroup,
|
||||||
|
PanelResizeHandle,
|
||||||
|
type ImperativePanelHandle,
|
||||||
|
} from "react-resizable-panels";
|
||||||
import { Sidebar } from "./sidebar";
|
import { Sidebar } from "./sidebar";
|
||||||
import { LatexEditor } from "./editor/latex-editor";
|
import { LatexEditor } from "./editor/latex-editor";
|
||||||
import { PdfPreview } from "./preview/pdf-preview";
|
import { PdfPreview } from "./preview/pdf-preview";
|
||||||
import { useDocumentStore } from "@/stores/document-store";
|
import { useDocumentStore } from "@/stores/document-store";
|
||||||
|
import { usePreviewStore } from "@/stores/preview-store";
|
||||||
|
|
||||||
|
const SIDEBAR_DEFAULT_SIZE = 15;
|
||||||
|
const SIDEBAR_MIN_SIZE = 10;
|
||||||
|
const SIDEBAR_COLLAPSED_WIDTH_PX = 48;
|
||||||
|
const SIDEBAR_COLLAPSED_SIZE_FALLBACK = 8;
|
||||||
|
const SIDEBAR_ANIMATION_MS = 280;
|
||||||
|
|
||||||
|
function easeInOutSmooth(progress: number) {
|
||||||
|
return progress * progress * (3 - 2 * progress);
|
||||||
|
}
|
||||||
|
|
||||||
export function WorkspaceLayout() {
|
export function WorkspaceLayout() {
|
||||||
const initialized = useDocumentStore((s) => s.initialized);
|
const initialized = useDocumentStore((s) => s.initialized);
|
||||||
|
const previewVisible = usePreviewStore((s) => s.visible);
|
||||||
|
const setPreviewVisible = usePreviewStore((s) => s.setVisible);
|
||||||
|
const workspaceRef = useRef<HTMLDivElement>(null);
|
||||||
|
const sidebarPanelRef = useRef<ImperativePanelHandle>(null);
|
||||||
|
const sidebarAnimationFrameRef = useRef<number | null>(null);
|
||||||
|
const sidebarAnimatingRef = useRef(false);
|
||||||
|
const expandedSidebarSizeRef = useRef(SIDEBAR_DEFAULT_SIZE);
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
|
const [sidebarCollapsedSize, setSidebarCollapsedSize] = useState(
|
||||||
|
SIDEBAR_COLLAPSED_SIZE_FALLBACK,
|
||||||
|
);
|
||||||
|
const [codeVisible, setCodeVisible] = useState(true);
|
||||||
|
|
||||||
|
const getCollapsedSidebarSize = useCallback(() => {
|
||||||
|
const workspaceWidth =
|
||||||
|
workspaceRef.current?.clientWidth ?? window.innerWidth;
|
||||||
|
if (!workspaceWidth) return SIDEBAR_COLLAPSED_SIZE_FALLBACK;
|
||||||
|
return Math.min(
|
||||||
|
18,
|
||||||
|
Math.max(2.5, (SIDEBAR_COLLAPSED_WIDTH_PX / workspaceWidth) * 100),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const animateSidebarToSize = useCallback((targetSize: number) => {
|
||||||
|
const sidebarPanel = sidebarPanelRef.current;
|
||||||
|
if (!sidebarPanel) return;
|
||||||
|
|
||||||
|
if (sidebarAnimationFrameRef.current !== null) {
|
||||||
|
window.cancelAnimationFrame(sidebarAnimationFrameRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startSize = sidebarPanel.getSize();
|
||||||
|
const sizeDelta = targetSize - startSize;
|
||||||
|
const startedAt = performance.now();
|
||||||
|
sidebarAnimatingRef.current = true;
|
||||||
|
|
||||||
|
const step = (now: number) => {
|
||||||
|
const progress = Math.min((now - startedAt) / SIDEBAR_ANIMATION_MS, 1);
|
||||||
|
const nextSize = startSize + sizeDelta * easeInOutSmooth(progress);
|
||||||
|
|
||||||
|
sidebarPanel.resize(nextSize);
|
||||||
|
|
||||||
|
if (progress < 1) {
|
||||||
|
sidebarAnimationFrameRef.current = window.requestAnimationFrame(step);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sidebarPanel.resize(targetSize);
|
||||||
|
sidebarAnimationFrameRef.current = null;
|
||||||
|
sidebarAnimatingRef.current = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
sidebarAnimationFrameRef.current = window.requestAnimationFrame(step);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setSidebarPaneCollapsed = useCallback(
|
||||||
|
(nextCollapsed: boolean) => {
|
||||||
|
const sidebarPanel = sidebarPanelRef.current;
|
||||||
|
if (!sidebarPanel) return;
|
||||||
|
|
||||||
|
if (!nextCollapsed) {
|
||||||
|
setSidebarCollapsed(false);
|
||||||
|
animateSidebarToSize(expandedSidebarSizeRef.current);
|
||||||
|
} else {
|
||||||
|
const collapsedSize = getCollapsedSidebarSize();
|
||||||
|
const currentSize = sidebarPanel.getSize();
|
||||||
|
if (currentSize >= SIDEBAR_MIN_SIZE) {
|
||||||
|
expandedSidebarSizeRef.current = currentSize;
|
||||||
|
}
|
||||||
|
setSidebarCollapsedSize(collapsedSize);
|
||||||
|
setSidebarCollapsed(true);
|
||||||
|
animateSidebarToSize(collapsedSize);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[animateSidebarToSize, getCollapsedSidebarSize],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleSidebarCollapsed = useCallback(() => {
|
||||||
|
setSidebarPaneCollapsed(!sidebarCollapsed);
|
||||||
|
}, [setSidebarPaneCollapsed, sidebarCollapsed]);
|
||||||
|
|
||||||
|
const setCodePaneVisible = useCallback(
|
||||||
|
(visible: boolean) => {
|
||||||
|
if (!visible && !previewVisible) {
|
||||||
|
setPreviewVisible(true);
|
||||||
|
}
|
||||||
|
setCodeVisible(visible);
|
||||||
|
},
|
||||||
|
[previewVisible, setPreviewVisible],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setPdfPaneVisible = useCallback(
|
||||||
|
(visible: boolean) => {
|
||||||
|
if (!visible && !codeVisible) {
|
||||||
|
setCodeVisible(true);
|
||||||
|
}
|
||||||
|
setPreviewVisible(visible);
|
||||||
|
},
|
||||||
|
[codeVisible, setPreviewVisible],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cmd+\ / Ctrl+\ toggles the PDF preview pane.
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === "\\") {
|
||||||
|
e.preventDefault();
|
||||||
|
setPdfPaneVisible(!previewVisible);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handler);
|
||||||
|
return () => window.removeEventListener("keydown", handler);
|
||||||
|
}, [previewVisible, setPdfPaneVisible]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (sidebarAnimationFrameRef.current !== null) {
|
||||||
|
window.cancelAnimationFrame(sidebarAnimationFrameRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const updateCollapsedSize = () => {
|
||||||
|
const nextSize = getCollapsedSidebarSize();
|
||||||
|
setSidebarCollapsedSize(nextSize);
|
||||||
|
|
||||||
|
if (sidebarCollapsed && !sidebarAnimatingRef.current) {
|
||||||
|
sidebarPanelRef.current?.resize(nextSize);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateCollapsedSize();
|
||||||
|
|
||||||
|
const workspaceElement = workspaceRef.current;
|
||||||
|
if (!workspaceElement) return;
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(updateCollapsedSize);
|
||||||
|
resizeObserver.observe(workspaceElement);
|
||||||
|
|
||||||
|
return () => resizeObserver.disconnect();
|
||||||
|
}, [getCollapsedSidebarSize, sidebarCollapsed]);
|
||||||
|
|
||||||
if (!initialized) {
|
if (!initialized) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -16,22 +180,64 @@ export function WorkspaceLayout() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div ref={workspaceRef} className="h-full">
|
||||||
<PanelGroup direction="horizontal" className="h-full">
|
<PanelGroup direction="horizontal" className="h-full">
|
||||||
<Panel defaultSize={15} minSize={10} maxSize={25}>
|
<Panel
|
||||||
<Sidebar />
|
ref={sidebarPanelRef}
|
||||||
|
defaultSize={SIDEBAR_DEFAULT_SIZE}
|
||||||
|
minSize={SIDEBAR_MIN_SIZE}
|
||||||
|
maxSize={25}
|
||||||
|
collapsible
|
||||||
|
collapsedSize={sidebarCollapsedSize}
|
||||||
|
onCollapse={() => setSidebarCollapsed(true)}
|
||||||
|
onExpand={() => setSidebarCollapsed(false)}
|
||||||
|
onResize={(size) => {
|
||||||
|
if (!sidebarAnimatingRef.current && size >= SIDEBAR_MIN_SIZE) {
|
||||||
|
expandedSidebarSizeRef.current = size;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="min-w-0 overflow-hidden"
|
||||||
|
>
|
||||||
|
<Sidebar
|
||||||
|
collapsed={sidebarCollapsed}
|
||||||
|
onToggleCollapsed={toggleSidebarCollapsed}
|
||||||
|
layoutControls={{
|
||||||
|
codeVisible,
|
||||||
|
pdfVisible: previewVisible,
|
||||||
|
sidebarVisible: !sidebarCollapsed,
|
||||||
|
setCodeVisible: setCodePaneVisible,
|
||||||
|
setPdfVisible: setPdfPaneVisible,
|
||||||
|
setSidebarVisible: (visible) => setSidebarPaneCollapsed(!visible),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<PanelResizeHandle className="w-px bg-border transition-colors hover:bg-ring" />
|
<PanelResizeHandle className="w-px bg-border transition-colors hover:bg-ring" />
|
||||||
|
|
||||||
<Panel defaultSize={42.5} minSize={25}>
|
{codeVisible && (
|
||||||
|
<Panel
|
||||||
|
defaultSize={previewVisible ? 42.5 : 85}
|
||||||
|
minSize={25}
|
||||||
|
className="min-w-0"
|
||||||
|
>
|
||||||
<LatexEditor />
|
<LatexEditor />
|
||||||
</Panel>
|
</Panel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{codeVisible && previewVisible && (
|
||||||
<PanelResizeHandle className="w-px bg-border transition-colors hover:bg-ring" />
|
<PanelResizeHandle className="w-px bg-border transition-colors hover:bg-ring" />
|
||||||
|
)}
|
||||||
|
|
||||||
<Panel defaultSize={42.5} minSize={25}>
|
{previewVisible && (
|
||||||
|
<Panel
|
||||||
|
defaultSize={codeVisible ? 42.5 : 85}
|
||||||
|
minSize={25}
|
||||||
|
className="min-w-0"
|
||||||
|
>
|
||||||
<PdfPreview />
|
<PdfPreview />
|
||||||
</Panel>
|
</Panel>
|
||||||
|
)}
|
||||||
</PanelGroup>
|
</PanelGroup>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { remove } from "@tauri-apps/plugin-fs";
|
||||||
import {
|
import {
|
||||||
|
CLAUDE_CODE_PROVIDER_ID,
|
||||||
useClaudeChatStore,
|
useClaudeChatStore,
|
||||||
type ClaudeStreamMessage,
|
type ClaudeStreamMessage,
|
||||||
} from "@/stores/claude-chat-store";
|
} from "@/stores/claude-chat-store";
|
||||||
import { useDocumentStore } from "@/stores/document-store";
|
import { useDocumentStore } from "@/stores/document-store";
|
||||||
import { useHistoryStore } from "@/stores/history-store";
|
import { useHistoryStore } from "@/stores/history-store";
|
||||||
import { useProposedChangesStore } from "@/stores/proposed-changes-store";
|
import { useProposedChangesStore } from "@/stores/proposed-changes-store";
|
||||||
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import { readTexFileContent } from "@/lib/tauri/fs";
|
import { readTexFileContent } from "@/lib/tauri/fs";
|
||||||
import {
|
import {
|
||||||
compileLatex,
|
compileLatex,
|
||||||
|
|
@ -18,6 +21,21 @@ import { createLogger } from "@/lib/debug/logger";
|
||||||
|
|
||||||
const log = createLogger("claude-event");
|
const log = createLogger("claude-event");
|
||||||
|
|
||||||
|
async function cleanupTemporaryFiles(paths: string[]) {
|
||||||
|
await Promise.all(
|
||||||
|
paths.map(async (path) => {
|
||||||
|
try {
|
||||||
|
await remove(path);
|
||||||
|
} catch (err) {
|
||||||
|
log.warn("failed to remove temporary chat file", {
|
||||||
|
path,
|
||||||
|
error: String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Backend event payload shapes (include tab_id for routing) */
|
/** Backend event payload shapes (include tab_id for routing) */
|
||||||
interface ClaudeOutputPayload {
|
interface ClaudeOutputPayload {
|
||||||
tab_id: string;
|
tab_id: string;
|
||||||
|
|
@ -49,6 +67,8 @@ export function useClaudeEvents() {
|
||||||
);
|
);
|
||||||
const hasTexChangesRef = useRef(new Map<string, boolean>());
|
const hasTexChangesRef = useRef(new Map<string, boolean>());
|
||||||
const cancelledForAskRef = useRef(new Map<string, boolean>());
|
const cancelledForAskRef = useRef(new Map<string, boolean>());
|
||||||
|
const lastErrorRef = useRef(new Map<string, string>());
|
||||||
|
const directProviderTabRef = useRef(new Map<string, boolean>());
|
||||||
const listenersRef = useRef<UnlistenFn[]>([]);
|
const listenersRef = useRef<UnlistenFn[]>([]);
|
||||||
const msgCountRef = useRef(new Map<string, number>());
|
const msgCountRef = useRef(new Map<string, number>());
|
||||||
const streamStartTimeRef = useRef(new Map<string, number>());
|
const streamStartTimeRef = useRef(new Map<string, number>());
|
||||||
|
|
@ -63,6 +83,12 @@ export function useClaudeEvents() {
|
||||||
pendingToolUsesRef.current.set(tab.id, new Map());
|
pendingToolUsesRef.current.set(tab.id, new Map());
|
||||||
hasTexChangesRef.current.set(tab.id, false);
|
hasTexChangesRef.current.set(tab.id, false);
|
||||||
cancelledForAskRef.current.set(tab.id, false);
|
cancelledForAskRef.current.set(tab.id, false);
|
||||||
|
lastErrorRef.current.delete(tab.id);
|
||||||
|
const providerKey = tab.sessionProviderKey ?? tab.providerKey;
|
||||||
|
directProviderTabRef.current.set(
|
||||||
|
tab.id,
|
||||||
|
!!providerKey && providerKey !== CLAUDE_CODE_PROVIDER_ID,
|
||||||
|
);
|
||||||
msgCountRef.current.set(tab.id, 0);
|
msgCountRef.current.set(tab.id, 0);
|
||||||
streamStartTimeRef.current.delete(tab.id);
|
streamStartTimeRef.current.delete(tab.id);
|
||||||
lastMsgTimeRef.current.delete(tab.id);
|
lastMsgTimeRef.current.delete(tab.id);
|
||||||
|
|
@ -77,6 +103,36 @@ export function useClaudeEvents() {
|
||||||
|
|
||||||
// ── One-time listener setup (mount only) ──
|
// ── One-time listener setup (mount only) ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
function setUserVisibleError(tabId: string, message: string) {
|
||||||
|
lastErrorRef.current.set(tabId, message);
|
||||||
|
useClaudeChatStore.getState()._setError(tabId, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function providerErrorMessage(payload: string): string | null {
|
||||||
|
const trimmed = payload.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
const lower = trimmed.toLowerCase();
|
||||||
|
const looksProviderRelated =
|
||||||
|
lower.includes("provider") ||
|
||||||
|
lower.includes("openai") ||
|
||||||
|
lower.includes("api key") ||
|
||||||
|
lower.includes("unauthorized") ||
|
||||||
|
lower.includes("401") ||
|
||||||
|
lower.includes("403") ||
|
||||||
|
lower.includes("404") ||
|
||||||
|
lower.includes("429") ||
|
||||||
|
lower.includes("too many requests") ||
|
||||||
|
lower.includes("rate limit") ||
|
||||||
|
lower.includes("invalid model") ||
|
||||||
|
lower.includes("model access") ||
|
||||||
|
lower.includes("tool_calls") ||
|
||||||
|
lower.includes("unsupported parameter") ||
|
||||||
|
lower.includes("does not support") ||
|
||||||
|
lower.includes("base url");
|
||||||
|
if (!looksProviderRelated) return null;
|
||||||
|
return trimmed.length > 800 ? `${trimmed.slice(0, 800)}...` : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
async function registerProposedChange(
|
async function registerProposedChange(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
toolUseId: string,
|
toolUseId: string,
|
||||||
|
|
@ -192,6 +248,14 @@ export function useClaudeEvents() {
|
||||||
log.info(
|
log.info(
|
||||||
`[${tabId}] ${elapsed(tabId)} result cost=$${msg.cost_usd} api=${msg.duration_api_ms}ms total=${msg.duration_ms}ms`,
|
`[${tabId}] ${elapsed(tabId)} result cost=$${msg.cost_usd} api=${msg.duration_api_ms}ms total=${msg.duration_ms}ms`,
|
||||||
);
|
);
|
||||||
|
if (
|
||||||
|
msg.is_error &&
|
||||||
|
msg.subtype !== "cancelled" &&
|
||||||
|
typeof msg.result === "string" &&
|
||||||
|
msg.result.trim()
|
||||||
|
) {
|
||||||
|
setUserVisibleError(tabId, msg.result.trim());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract session_id from system:init
|
// Extract session_id from system:init
|
||||||
|
|
@ -266,15 +330,17 @@ export function useClaudeEvents() {
|
||||||
|
|
||||||
chatStore._appendMessage(tabId, msg);
|
chatStore._appendMessage(tabId, msg);
|
||||||
|
|
||||||
// When AskUserQuestion is detected, cancel the process so the user
|
// When a UI-pause tool is detected, cancel the process so the user
|
||||||
// can interact with the widget before Claude continues.
|
// can interact with the widget before Claude continues.
|
||||||
if (msg.type === "assistant" && msg.message?.content) {
|
if (msg.type === "assistant" && msg.message?.content) {
|
||||||
const hasAskUser = msg.message.content.some(
|
const hasUiPauseTool = msg.message.content.some(
|
||||||
(b: any) => b.type === "tool_use" && b.name === "AskUserQuestion",
|
(b: any) =>
|
||||||
|
b.type === "tool_use" &&
|
||||||
|
(b.name === "AskUserQuestion" || b.name === "ExitPlanMode"),
|
||||||
);
|
);
|
||||||
if (hasAskUser) {
|
if (hasUiPauseTool) {
|
||||||
log.info(
|
log.info(
|
||||||
`[${tabId}] ${elapsed(tabId)} AskUserQuestion detected — cancelling process for user input`,
|
`[${tabId}] ${elapsed(tabId)} UI-pause tool detected - cancelling process for user input`,
|
||||||
);
|
);
|
||||||
cancelledForAskRef.current.set(tabId, true);
|
cancelledForAskRef.current.set(tabId, true);
|
||||||
invoke("cancel_claude_execution", { tabId }).catch(() => {});
|
invoke("cancel_claude_execution", { tabId }).catch(() => {});
|
||||||
|
|
@ -302,26 +368,85 @@ export function useClaudeEvents() {
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!success &&
|
!success &&
|
||||||
count > 0 &&
|
|
||||||
!tab.error &&
|
!tab.error &&
|
||||||
|
!lastErrorRef.current.get(tabId) &&
|
||||||
!cancelledForAskRef.current.get(tabId) &&
|
!cancelledForAskRef.current.get(tabId) &&
|
||||||
!chatStore._cancelledByUser
|
!chatStore._cancelledByUser
|
||||||
) {
|
) {
|
||||||
|
const isDirectProvider = directProviderTabRef.current.get(tabId);
|
||||||
|
if (count === 0) {
|
||||||
|
const isWindows = navigator.userAgent.includes("Windows");
|
||||||
chatStore._setError(
|
chatStore._setError(
|
||||||
tabId,
|
tabId,
|
||||||
"Claude process exited unexpectedly. This may be due to rate limiting or an API error.",
|
isDirectProvider
|
||||||
|
? "AI provider request failed to start. Check the provider API key, Base URL, model name, and model access."
|
||||||
|
: isWindows
|
||||||
|
? "Claude process failed to start. Check that Claude Code CLI is installed and git-bash is available."
|
||||||
|
: "Claude process failed to start. Check that Claude Code CLI is installed.",
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
chatStore._setError(
|
||||||
|
tabId,
|
||||||
|
isDirectProvider
|
||||||
|
? "AI provider request stopped unexpectedly. Check the provider API key, model access, Base URL, tool-call support, or rate limits."
|
||||||
|
: "Claude process exited unexpectedly. This may be due to rate limiting or an API error.",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up per-tab state
|
// Clean up per-tab state
|
||||||
pendingToolUsesRef.current.delete(tabId);
|
pendingToolUsesRef.current.delete(tabId);
|
||||||
hasTexChangesRef.current.delete(tabId);
|
hasTexChangesRef.current.delete(tabId);
|
||||||
cancelledForAskRef.current.delete(tabId);
|
cancelledForAskRef.current.delete(tabId);
|
||||||
|
lastErrorRef.current.delete(tabId);
|
||||||
|
directProviderTabRef.current.delete(tabId);
|
||||||
|
|
||||||
|
const completedSessionId = tab.sessionId;
|
||||||
chatStore._setStreaming(tabId, false);
|
chatStore._setStreaming(tabId, false);
|
||||||
|
void cleanupTemporaryFiles(chatStore.consumeTemporaryFilePaths(tabId));
|
||||||
|
|
||||||
|
const forceQueuedGuidance = tab.forceQueuedGuidanceOnComplete === true;
|
||||||
|
if (forceQueuedGuidance) {
|
||||||
|
const queuedGuidance = useClaudeChatStore
|
||||||
|
.getState()
|
||||||
|
.consumeQueuedGuidance(tabId, tab.forcedQueuedGuidanceId);
|
||||||
|
if (queuedGuidance) {
|
||||||
|
log.info(`[${tabId}] interrupting current run with queued guidance`);
|
||||||
|
void useClaudeChatStore
|
||||||
|
.getState()
|
||||||
|
.sendPrompt(queuedGuidance.prompt, queuedGuidance.contextOverride, {
|
||||||
|
tabId,
|
||||||
|
preserveTabProvider: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Snapshot after Claude edit
|
// Snapshot after Claude edit
|
||||||
const projectPath = useDocumentStore.getState().projectRoot;
|
const projectPath = useDocumentStore.getState().projectRoot;
|
||||||
|
if (projectPath && completedSessionId) {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const title = await invoke<string | null>(
|
||||||
|
"generate_claude_session_title",
|
||||||
|
{
|
||||||
|
projectPath,
|
||||||
|
sessionId: completedSessionId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (title) {
|
||||||
|
useClaudeChatStore
|
||||||
|
.getState()
|
||||||
|
._setSessionTitle(completedSessionId, title);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.warn("failed to refresh completed session title", {
|
||||||
|
error: String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
if (projectPath) {
|
if (projectPath) {
|
||||||
try {
|
try {
|
||||||
await useHistoryStore
|
await useHistoryStore
|
||||||
|
|
@ -335,6 +460,20 @@ export function useClaudeEvents() {
|
||||||
const docStore = useDocumentStore.getState();
|
const docStore = useDocumentStore.getState();
|
||||||
await docStore.refreshFiles();
|
await docStore.refreshFiles();
|
||||||
|
|
||||||
|
const queuedGuidance = success
|
||||||
|
? useClaudeChatStore.getState().consumeQueuedGuidance(tabId)
|
||||||
|
: null;
|
||||||
|
if (queuedGuidance) {
|
||||||
|
log.info(`[${tabId}] continuing with queued guidance`);
|
||||||
|
void useClaudeChatStore
|
||||||
|
.getState()
|
||||||
|
.sendPrompt(queuedGuidance.prompt, queuedGuidance.contextOverride, {
|
||||||
|
tabId,
|
||||||
|
preserveTabProvider: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-recompile after Claude finishes
|
// Auto-recompile after Claude finishes
|
||||||
const {
|
const {
|
||||||
projectRoot,
|
projectRoot,
|
||||||
|
|
@ -350,7 +489,13 @@ export function useClaudeEvents() {
|
||||||
useDocumentStore.getState().setPendingRecompile(false);
|
useDocumentStore.getState().setPendingRecompile(false);
|
||||||
try {
|
try {
|
||||||
await useDocumentStore.getState().saveAllFiles();
|
await useDocumentStore.getState().saveAllFiles();
|
||||||
const pdfData = await compileLatex(projectRoot, targetPath);
|
const texlive =
|
||||||
|
useSettingsStore.getState().compilerBackend === "texlive";
|
||||||
|
const pdfData = await compileLatex(
|
||||||
|
projectRoot,
|
||||||
|
targetPath,
|
||||||
|
texlive,
|
||||||
|
);
|
||||||
useDocumentStore.getState().setPdfData(pdfData, rootId);
|
useDocumentStore.getState().setPdfData(pdfData, rootId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
useDocumentStore
|
useDocumentStore
|
||||||
|
|
@ -410,6 +555,29 @@ export function useClaudeEvents() {
|
||||||
) {
|
) {
|
||||||
log.error(`[${tabId}] CRITICAL: ${payload}`);
|
log.error(`[${tabId}] CRITICAL: ${payload}`);
|
||||||
}
|
}
|
||||||
|
const isDirectProvider = directProviderTabRef.current.get(tabId);
|
||||||
|
const providerMessage =
|
||||||
|
isDirectProvider || providerErrorMessage(payload)
|
||||||
|
? providerErrorMessage(payload) || payload.trim()
|
||||||
|
: null;
|
||||||
|
if (providerMessage) {
|
||||||
|
setUserVisibleError(tabId, providerMessage);
|
||||||
|
}
|
||||||
|
// Surface critical stderr messages to the user UI (only if no error is already set)
|
||||||
|
if (
|
||||||
|
(payload.includes("git-bash") ||
|
||||||
|
payload.includes("git bash") ||
|
||||||
|
payload.includes("bash.exe")) &&
|
||||||
|
!useClaudeChatStore.getState().tabs.find((t) => t.id === tabId)
|
||||||
|
?.error
|
||||||
|
) {
|
||||||
|
useClaudeChatStore
|
||||||
|
.getState()
|
||||||
|
._setError(
|
||||||
|
tabId,
|
||||||
|
"Claude Code requires git-bash on Windows. Please install Git for Windows or set the CLAUDE_CODE_GIT_BASH_PATH environment variable.",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,20 @@
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { getAppZoomAction, shouldHandleAppZoomShortcut } from "@/lib/app-zoom";
|
||||||
import { useDocumentStore } from "@/stores/document-store";
|
import { useDocumentStore } from "@/stores/document-store";
|
||||||
|
|
||||||
export function useKeyboardShortcuts() {
|
export function useKeyboardShortcuts() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const handleZoomKeyDown = (e: KeyboardEvent) => {
|
||||||
|
const zoomAction = getAppZoomAction(e);
|
||||||
|
if (!zoomAction || !shouldHandleAppZoomShortcut(e.target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -34,18 +45,22 @@ export function useKeyboardShortcuts() {
|
||||||
window.dispatchEvent(new CustomEvent("toggle-capture-mode"));
|
window.dispatchEvent(new CustomEvent("toggle-capture-mode"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cmd+Shift+D (macOS) / Ctrl+Shift+D (others): Open debug window
|
// Cmd+Shift+D (macOS) / Ctrl+Shift+D (others): Toggle debug panel
|
||||||
if (
|
if (
|
||||||
(e.metaKey || e.ctrlKey) &&
|
(e.metaKey || e.ctrlKey) &&
|
||||||
e.shiftKey &&
|
e.shiftKey &&
|
||||||
e.key.toLowerCase() === "d"
|
e.key.toLowerCase() === "d"
|
||||||
) {
|
) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
invoke("open_debug_window").catch(console.error);
|
window.dispatchEvent(new CustomEvent("toggle-debug-panel"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
document.addEventListener("keydown", handleZoomKeyDown, true);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
document.removeEventListener("keydown", handleZoomKeyDown, true);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
149
apps/desktop/src/lib/app-zoom.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
||||||
|
|
||||||
|
export const APP_ZOOM_STORAGE_KEY = "claude-prism-app-zoom";
|
||||||
|
export const LOCAL_ZOOM_SHORTCUTS_ATTR = "data-local-zoom-shortcuts";
|
||||||
|
export const DEFAULT_APP_ZOOM = 1;
|
||||||
|
export const MIN_APP_ZOOM = 0.5;
|
||||||
|
export const MAX_APP_ZOOM = 3;
|
||||||
|
export const APP_ZOOM_STEP = 0.1;
|
||||||
|
|
||||||
|
let nativeWheelZoomGuardInstalled = false;
|
||||||
|
|
||||||
|
export type AppZoomAction = "in" | "out" | "reset";
|
||||||
|
|
||||||
|
type ZoomShortcutEvent = Pick<
|
||||||
|
KeyboardEvent,
|
||||||
|
"altKey" | "code" | "ctrlKey" | "key" | "metaKey"
|
||||||
|
>;
|
||||||
|
|
||||||
|
function roundZoom(value: number): number {
|
||||||
|
return Math.round(value * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampAppZoom(value: number): number {
|
||||||
|
return roundZoom(Math.min(MAX_APP_ZOOM, Math.max(MIN_APP_ZOOM, value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readStoredAppZoom(): number {
|
||||||
|
const raw = window.localStorage.getItem(APP_ZOOM_STORAGE_KEY);
|
||||||
|
if (raw === null) return DEFAULT_APP_ZOOM;
|
||||||
|
|
||||||
|
const parsed = Number(raw);
|
||||||
|
if (!Number.isFinite(parsed)) return DEFAULT_APP_ZOOM;
|
||||||
|
|
||||||
|
return clampAppZoom(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyAppZoom(value: number): Promise<number> {
|
||||||
|
const zoom = clampAppZoom(value);
|
||||||
|
await getCurrentWebview().setZoom(zoom);
|
||||||
|
return zoom;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function persistAppZoom(value: number): Promise<number> {
|
||||||
|
const zoom = await applyAppZoom(value);
|
||||||
|
window.localStorage.setItem(APP_ZOOM_STORAGE_KEY, zoom.toString());
|
||||||
|
return zoom;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initializeAppZoom(): Promise<number> {
|
||||||
|
installNativeWheelZoomGuard();
|
||||||
|
window.localStorage.removeItem(APP_ZOOM_STORAGE_KEY);
|
||||||
|
return applyAppZoom(DEFAULT_APP_ZOOM);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function zoomInApp(): Promise<number> {
|
||||||
|
return persistAppZoom(readStoredAppZoom() + APP_ZOOM_STEP);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function zoomOutApp(): Promise<number> {
|
||||||
|
return persistAppZoom(readStoredAppZoom() - APP_ZOOM_STEP);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetAppZoom(): Promise<number> {
|
||||||
|
return persistAppZoom(DEFAULT_APP_ZOOM);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAppZoomAction(
|
||||||
|
event: ZoomShortcutEvent,
|
||||||
|
): AppZoomAction | null {
|
||||||
|
if (!(event.metaKey || event.ctrlKey) || event.altKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
event.key === "+" ||
|
||||||
|
event.key === "=" ||
|
||||||
|
event.code === "Equal" ||
|
||||||
|
event.code === "NumpadAdd"
|
||||||
|
) {
|
||||||
|
return "in";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
event.key === "-" ||
|
||||||
|
event.key === "_" ||
|
||||||
|
event.code === "Minus" ||
|
||||||
|
event.code === "NumpadSubtract"
|
||||||
|
) {
|
||||||
|
return "out";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
event.key === "0" ||
|
||||||
|
event.code === "Digit0" ||
|
||||||
|
event.code === "Numpad0"
|
||||||
|
) {
|
||||||
|
return "reset";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldHandleAppZoomShortcut(
|
||||||
|
target: EventTarget | null,
|
||||||
|
): boolean {
|
||||||
|
if (!(target instanceof Element)) return true;
|
||||||
|
return !target.closest(`[${LOCAL_ZOOM_SHORTCUTS_ATTR}]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasLocalZoomSurfaceAtPoint(event: WheelEvent): boolean {
|
||||||
|
if (typeof document.elementsFromPoint !== "function") return false;
|
||||||
|
|
||||||
|
return document
|
||||||
|
.elementsFromPoint(event.clientX, event.clientY)
|
||||||
|
.some((element) => element.closest(`[${LOCAL_ZOOM_SHORTCUTS_ATTR}]`));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasLocalZoomSurfaceInPath(event: WheelEvent): boolean {
|
||||||
|
return event
|
||||||
|
.composedPath()
|
||||||
|
.some(
|
||||||
|
(target) =>
|
||||||
|
target instanceof Element &&
|
||||||
|
!!target.closest(`[${LOCAL_ZOOM_SHORTCUTS_ATTR}]`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldHandleNativeWheelZoom(event: WheelEvent): boolean {
|
||||||
|
return (
|
||||||
|
shouldHandleAppZoomShortcut(event.target) &&
|
||||||
|
!hasLocalZoomSurfaceInPath(event) &&
|
||||||
|
!hasLocalZoomSurfaceAtPoint(event)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function installNativeWheelZoomGuard(): void {
|
||||||
|
if (nativeWheelZoomGuardInstalled || typeof document === "undefined") return;
|
||||||
|
nativeWheelZoomGuardInstalled = true;
|
||||||
|
|
||||||
|
document.addEventListener(
|
||||||
|
"wheel",
|
||||||
|
(event) => {
|
||||||
|
if (!(event.metaKey || event.ctrlKey) || event.altKey) return;
|
||||||
|
if (!shouldHandleNativeWheelZoom(event)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
},
|
||||||
|
{ passive: false },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -143,7 +143,7 @@ if (_isDebugWindow) {
|
||||||
useLogStore.setState({ version: ++_version });
|
useLogStore.setState({ version: ++_version });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Request bulk sync on open
|
// Request bulk sync on open — send to main window
|
||||||
emit("debug-log-sync-request").catch(() => {});
|
emit("debug-log-sync-request").catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import { createLogger } from "@/lib/debug/logger";
|
||||||
const log = createLogger("latex");
|
const log = createLogger("latex");
|
||||||
|
|
||||||
/** Resolve which file to compile and the root ID for caching.
|
/** Resolve which file to compile and the root ID for caching.
|
||||||
|
* resolveTexRoot now handles \documentclass detection and main.tex fallback,
|
||||||
|
* so the only remaining fallback here is for projects with no .tex files.
|
||||||
* Returns `null` when the project has no compilable .tex file. */
|
* Returns `null` when the project has no compilable .tex file. */
|
||||||
export function resolveCompileTarget(
|
export function resolveCompileTarget(
|
||||||
activeFileId: string,
|
activeFileId: string,
|
||||||
|
|
@ -15,19 +17,11 @@ export function resolveCompileTarget(
|
||||||
if (rootEntry?.type === "tex") {
|
if (rootEntry?.type === "tex") {
|
||||||
return { rootId, targetPath: rootEntry.relativePath };
|
return { rootId, targetPath: rootEntry.relativePath };
|
||||||
}
|
}
|
||||||
// Fallback: look for any well-known root tex file
|
// No .tex file exists — cannot compile
|
||||||
const fallback = files.find(
|
|
||||||
(f) => f.name === "main.tex" || f.name === "document.tex",
|
|
||||||
);
|
|
||||||
if (fallback) {
|
|
||||||
return { rootId: fallback.id, targetPath: fallback.relativePath };
|
|
||||||
}
|
|
||||||
// Final fallback: use the first available .tex file in the project
|
|
||||||
const anyTex = files.find((f) => f.type === "tex");
|
const anyTex = files.find((f) => f.type === "tex");
|
||||||
if (anyTex) {
|
if (anyTex) {
|
||||||
return { rootId: anyTex.id, targetPath: anyTex.relativePath };
|
return { rootId: anyTex.id, targetPath: anyTex.relativePath };
|
||||||
}
|
}
|
||||||
// No .tex file exists — cannot compile
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -43,13 +37,17 @@ export function formatCompileError(error: unknown): string {
|
||||||
export async function compileLatex(
|
export async function compileLatex(
|
||||||
projectDir: string,
|
projectDir: string,
|
||||||
mainFile: string = "main.tex",
|
mainFile: string = "main.tex",
|
||||||
|
useTexlive: boolean = false,
|
||||||
): Promise<Uint8Array> {
|
): Promise<Uint8Array> {
|
||||||
log.info(`Compiling ${mainFile}`);
|
log.info(
|
||||||
|
`Compiling ${mainFile} (backend: ${useTexlive ? "texlive" : "tectonic"})`,
|
||||||
|
);
|
||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
// compile_latex returns raw PDF bytes via Tauri IPC Response
|
// compile_latex returns raw PDF bytes via Tauri IPC Response
|
||||||
const buffer = await invoke<ArrayBuffer>("compile_latex", {
|
const buffer = await invoke<ArrayBuffer>("compile_latex", {
|
||||||
projectDir,
|
projectDir,
|
||||||
mainFile,
|
mainFile,
|
||||||
|
useTexlive,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = new Uint8Array(buffer);
|
const result = new Uint8Array(buffer);
|
||||||
|
|
@ -59,6 +57,16 @@ export async function compileLatex(
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TexliveStatus {
|
||||||
|
available: boolean;
|
||||||
|
engines: string[];
|
||||||
|
version: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function detectTexlive(): Promise<TexliveStatus> {
|
||||||
|
return invoke<TexliveStatus>("detect_texlive");
|
||||||
|
}
|
||||||
|
|
||||||
export interface SynctexResult {
|
export interface SynctexResult {
|
||||||
file: string;
|
file: string;
|
||||||
line: number;
|
line: number;
|
||||||
|
|
|
||||||
379
apps/desktop/src/lib/model-capabilities.ts
Normal file
|
|
@ -0,0 +1,379 @@
|
||||||
|
export interface OpenAiCompatibleModelInfo {
|
||||||
|
id: string;
|
||||||
|
metadata?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModelCapabilityInput {
|
||||||
|
label?: string | null;
|
||||||
|
baseUrl?: string | null;
|
||||||
|
model?: string | null;
|
||||||
|
metadata?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelCapabilities {
|
||||||
|
chat: boolean;
|
||||||
|
vision: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelMetadataCache = new Map<string, unknown>();
|
||||||
|
|
||||||
|
function normalizeText(value?: unknown) {
|
||||||
|
return String(value ?? "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function metadataKey(baseUrl?: string | null, model?: string | null) {
|
||||||
|
return `${normalizeText(baseUrl)}::${normalizeText(model)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rememberModelCapabilityMetadata(
|
||||||
|
baseUrl: string | null | undefined,
|
||||||
|
model: string | null | undefined,
|
||||||
|
metadata: unknown,
|
||||||
|
) {
|
||||||
|
if (!model?.trim()) return;
|
||||||
|
modelMetadataCache.set(metadataKey(baseUrl, model), metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rememberModelListCapabilityMetadata(
|
||||||
|
baseUrl: string | null | undefined,
|
||||||
|
models: Array<string | OpenAiCompatibleModelInfo>,
|
||||||
|
) {
|
||||||
|
for (const model of models) {
|
||||||
|
if (typeof model === "string") {
|
||||||
|
rememberModelCapabilityMetadata(baseUrl, model, { id: model });
|
||||||
|
} else {
|
||||||
|
rememberModelCapabilityMetadata(
|
||||||
|
baseUrl,
|
||||||
|
model.id,
|
||||||
|
model.metadata ?? model,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function modelInfoId(model: unknown) {
|
||||||
|
if (typeof model === "string") return model;
|
||||||
|
if (isPlainObject(model) && typeof model.id === "string") return model.id;
|
||||||
|
return String(model ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function haystack(input: ModelCapabilityInput) {
|
||||||
|
return [input.label, input.baseUrl, input.model]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalModelId(value?: string | null) {
|
||||||
|
return normalizeText(value)
|
||||||
|
.replace(/[._/]+/g, "-")
|
||||||
|
.replace(/[^a-z0-9-]+/g, "-")
|
||||||
|
.replace(/-+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeToken(value: unknown) {
|
||||||
|
return String(value)
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[_\s]+/g, "-");
|
||||||
|
}
|
||||||
|
|
||||||
|
function booleanValue(value: unknown): boolean | null {
|
||||||
|
if (typeof value === "boolean") return value;
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const normalized = normalizeToken(value);
|
||||||
|
if (["1", "true", "yes", "supported", "enabled"].includes(normalized)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (["0", "false", "no", "unsupported", "disabled"].includes(normalized)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueTokens(value: unknown): string[] {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.flatMap(valueTokens);
|
||||||
|
}
|
||||||
|
if (isPlainObject(value)) {
|
||||||
|
return Object.entries(value).flatMap(([key, child]) => [
|
||||||
|
normalizeToken(key),
|
||||||
|
...valueTokens(child),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if (typeof value === "string" || typeof value === "number") {
|
||||||
|
return normalizeToken(value)
|
||||||
|
.split(/[^a-z0-9]+/)
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadataVisionBooleanKeys = new Set([
|
||||||
|
"supports-vision",
|
||||||
|
"support-vision",
|
||||||
|
"vision",
|
||||||
|
"supports-image-input",
|
||||||
|
"support-image-input",
|
||||||
|
"image-input",
|
||||||
|
"images-input",
|
||||||
|
"supports-images",
|
||||||
|
"multimodal",
|
||||||
|
"multi-modal",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const metadataFeatureKeys = new Set([
|
||||||
|
"features",
|
||||||
|
"capabilities",
|
||||||
|
"modalities",
|
||||||
|
"input-modalities",
|
||||||
|
"supported-modalities",
|
||||||
|
"supported-input-modalities",
|
||||||
|
"model-features",
|
||||||
|
"model-capabilities",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const visionFeatureTokens = new Set([
|
||||||
|
"vision",
|
||||||
|
"visual",
|
||||||
|
"image",
|
||||||
|
"images",
|
||||||
|
"image-input",
|
||||||
|
"input-image",
|
||||||
|
"multimodal",
|
||||||
|
"multi-modal",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const chatModeTokens = new Set([
|
||||||
|
"chat",
|
||||||
|
"completion",
|
||||||
|
"completions",
|
||||||
|
"messages",
|
||||||
|
"text-generation",
|
||||||
|
"text",
|
||||||
|
"llm",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const nonChatModeTokens = new Set([
|
||||||
|
"embedding",
|
||||||
|
"embeddings",
|
||||||
|
"rerank",
|
||||||
|
"reranking",
|
||||||
|
"moderation",
|
||||||
|
"whisper",
|
||||||
|
"tts",
|
||||||
|
"text-to-speech",
|
||||||
|
"speech-to-text",
|
||||||
|
"speech",
|
||||||
|
"audio",
|
||||||
|
"asr",
|
||||||
|
"stt",
|
||||||
|
"image-generation",
|
||||||
|
"image-to-video",
|
||||||
|
"video-generation",
|
||||||
|
"text-embedding",
|
||||||
|
"realtime",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function metadataVisionCapability(
|
||||||
|
value: unknown,
|
||||||
|
depth = 0,
|
||||||
|
): boolean | undefined {
|
||||||
|
if (depth > 5) return undefined;
|
||||||
|
if (!isPlainObject(value)) return undefined;
|
||||||
|
|
||||||
|
for (const [rawKey, child] of Object.entries(value)) {
|
||||||
|
const key = normalizeToken(rawKey);
|
||||||
|
if (metadataVisionBooleanKeys.has(key)) {
|
||||||
|
const parsed = booleanValue(child);
|
||||||
|
if (parsed !== null) return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadataFeatureKeys.has(key)) {
|
||||||
|
const tokens = valueTokens(child);
|
||||||
|
if (tokens.some((token) => visionFeatureTokens.has(token))) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nested = metadataVisionCapability(child, depth + 1);
|
||||||
|
if (nested !== undefined) return nested;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function metadataChatCapability(
|
||||||
|
value: unknown,
|
||||||
|
depth = 0,
|
||||||
|
): boolean | undefined {
|
||||||
|
if (depth > 5) return undefined;
|
||||||
|
if (!isPlainObject(value)) return undefined;
|
||||||
|
|
||||||
|
for (const [rawKey, child] of Object.entries(value)) {
|
||||||
|
const key = normalizeToken(rawKey);
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
"mode",
|
||||||
|
"type",
|
||||||
|
"task",
|
||||||
|
"model-type",
|
||||||
|
"endpoint-type",
|
||||||
|
"category",
|
||||||
|
].includes(key)
|
||||||
|
) {
|
||||||
|
const tokens = valueTokens(child);
|
||||||
|
if (tokens.some((token) => nonChatModeTokens.has(token))) return false;
|
||||||
|
if (tokens.some((token) => chatModeTokens.has(token))) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadataFeatureKeys.has(key)) {
|
||||||
|
const tokens = valueTokens(child);
|
||||||
|
if (tokens.some((token) => chatModeTokens.has(token))) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nested = metadataChatCapability(child, depth + 1);
|
||||||
|
if (nested !== undefined) return nested;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNonChatModel(value: string) {
|
||||||
|
return Array.from(nonChatModeTokens).some((marker) => value.includes(marker));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isQwenProvider(value: string) {
|
||||||
|
return [
|
||||||
|
"qwen",
|
||||||
|
"dashscope",
|
||||||
|
"aliyuncs.com",
|
||||||
|
"alibabacloud",
|
||||||
|
"alibaba-cloud",
|
||||||
|
"modelstudio",
|
||||||
|
"bailian",
|
||||||
|
].some((marker) => value.includes(marker));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isQwenVisionModel(model: string) {
|
||||||
|
const id = canonicalModelId(model);
|
||||||
|
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
"qwen-vl",
|
||||||
|
"qwen2-vl",
|
||||||
|
"qwen2-5-vl",
|
||||||
|
"qwen3-vl",
|
||||||
|
"qwen-omni",
|
||||||
|
"qwen2-5-omni",
|
||||||
|
"qwen3-omni",
|
||||||
|
"qvq",
|
||||||
|
].some((prefix) => id === prefix || id.startsWith(`${prefix}-`))
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!id.startsWith("qwen")) return false;
|
||||||
|
if (
|
||||||
|
["audio", "coder", "code", "embedding", "math", "rerank", "tts"].some(
|
||||||
|
(token) => id.includes(token),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = id.match(/^qwen-?(\d+)-(\d+)/);
|
||||||
|
if (!version) return false;
|
||||||
|
|
||||||
|
const major = Number(version[1]);
|
||||||
|
const minor = Number(version[2]);
|
||||||
|
return major > 3 || (major === 3 && minor >= 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
function curatedVisionCapability(input: ModelCapabilityInput, value: string) {
|
||||||
|
const model = input.model ?? "";
|
||||||
|
|
||||||
|
if (isQwenProvider(value) && isQwenVisionModel(model)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasExplicitVisionFamily(value: string) {
|
||||||
|
const normalized = value.toLowerCase();
|
||||||
|
const tokens = normalized.split(/[^a-z0-9]+/).filter(Boolean);
|
||||||
|
|
||||||
|
if (tokens.includes("vl")) return true;
|
||||||
|
|
||||||
|
return [
|
||||||
|
"vision",
|
||||||
|
"multimodal",
|
||||||
|
"multi-modal",
|
||||||
|
"omni",
|
||||||
|
"qvq",
|
||||||
|
"llava",
|
||||||
|
"bakllava",
|
||||||
|
"moondream",
|
||||||
|
"minicpm-v",
|
||||||
|
"glm-4v",
|
||||||
|
"glm-4.5v",
|
||||||
|
"internvl",
|
||||||
|
"pixtral",
|
||||||
|
"llama3.2-vision",
|
||||||
|
"granite3.2-vision",
|
||||||
|
"gpt-4o",
|
||||||
|
"gpt-4.1",
|
||||||
|
"gpt-4.5",
|
||||||
|
"gpt-4-turbo",
|
||||||
|
"gpt-4-vision",
|
||||||
|
"o3",
|
||||||
|
"o4",
|
||||||
|
"gemini",
|
||||||
|
].some((marker) => normalized.includes(marker));
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelMetadata(input: ModelCapabilityInput) {
|
||||||
|
return (
|
||||||
|
input.metadata ??
|
||||||
|
modelMetadataCache.get(metadataKey(input.baseUrl, input.model)) ??
|
||||||
|
modelMetadataCache.get(metadataKey(null, input.model))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getModelCapabilities(
|
||||||
|
input: ModelCapabilityInput,
|
||||||
|
): ModelCapabilities {
|
||||||
|
const value = haystack(input);
|
||||||
|
const model = normalizeText(input.model);
|
||||||
|
|
||||||
|
if (!model) {
|
||||||
|
return { chat: true, vision: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = modelMetadata(input);
|
||||||
|
const metadataChat = metadataChatCapability(metadata);
|
||||||
|
const metadataVision = metadataVisionCapability(metadata);
|
||||||
|
|
||||||
|
const chat = metadataChat ?? !isNonChatModel(value);
|
||||||
|
if (!chat) {
|
||||||
|
return { chat: false, vision: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
chat,
|
||||||
|
vision:
|
||||||
|
metadataVision ??
|
||||||
|
curatedVisionCapability(input, value) ??
|
||||||
|
hasExplicitVisionFamily(value),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isChatModelOption(input: ModelCapabilityInput) {
|
||||||
|
return getModelCapabilities(input).chat;
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,43 @@
|
||||||
import * as mupdf from "mupdf";
|
import type { PDFDocument } from "mupdf";
|
||||||
|
|
||||||
const documentMap = new Map<number, mupdf.PDFDocument>();
|
type MupdfModule = typeof import("mupdf");
|
||||||
|
|
||||||
|
type MupdfWasmModuleConfig = {
|
||||||
|
locateFile?: (path: string) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const wasmModuleConfig = ((
|
||||||
|
globalThis as typeof globalThis & {
|
||||||
|
$libmupdf_wasm_Module?: MupdfWasmModuleConfig;
|
||||||
|
}
|
||||||
|
).$libmupdf_wasm_Module ??= {});
|
||||||
|
|
||||||
|
// In Vite dev, requests for /node_modules/.../mupdf-wasm.wasm can fall back to
|
||||||
|
// index.html. Pointing MuPDF at Vite's @fs URL keeps worker startup on the
|
||||||
|
// actual binary during local development without changing packaged builds.
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
const devWasmUrl = `/@fs/${__MUPDF_WASM_FS_PATH__}`;
|
||||||
|
wasmModuleConfig.locateFile = (path: string) => {
|
||||||
|
if (path.endsWith("mupdf-wasm.wasm")) {
|
||||||
|
return devWasmUrl;
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const mupdf: MupdfModule = await import("mupdf");
|
||||||
|
|
||||||
|
const documentMap = new Map<number, PDFDocument>();
|
||||||
let nextDocId = 1;
|
let nextDocId = 1;
|
||||||
|
|
||||||
const methods: Record<string, (...args: any[]) => any> = {};
|
const methods: Record<string, (...args: any[]) => any> = {};
|
||||||
|
|
||||||
methods.openDocument = (buffer: ArrayBuffer, magic: string): number => {
|
methods.openDocument = (buffer: ArrayBuffer, magic: string): number => {
|
||||||
const docId = nextDocId++;
|
const docId = nextDocId++;
|
||||||
const doc = mupdf.Document.openDocument(buffer, magic) as mupdf.PDFDocument;
|
const doc = mupdf.Document.openDocument(
|
||||||
|
buffer,
|
||||||
|
magic,
|
||||||
|
) as unknown as PDFDocument;
|
||||||
documentMap.set(docId, doc);
|
documentMap.set(docId, doc);
|
||||||
return docId;
|
return docId;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -123,12 +123,18 @@ export function invalidateDoc(docId: number): void {
|
||||||
|
|
||||||
/** Close all cached documents (e.g., on project close). */
|
/** Close all cached documents (e.g., on project close). */
|
||||||
export async function clearDocCache(): Promise<void> {
|
export async function clearDocCache(): Promise<void> {
|
||||||
const count = cache.size;
|
const entries = [...cache.values()];
|
||||||
|
const count = entries.length;
|
||||||
|
cache.clear();
|
||||||
|
if (count === 0) {
|
||||||
|
log.info("Cleared doc cache (0 documents closed)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const client = getMupdfClient();
|
const client = getMupdfClient();
|
||||||
const closePromises = [...cache.values()].map((entry) =>
|
const closePromises = entries.map((entry) =>
|
||||||
client.closeDocument(entry.docId).catch(() => {}),
|
client.closeDocument(entry.docId).catch(() => {}),
|
||||||
);
|
);
|
||||||
cache.clear();
|
|
||||||
await Promise.all(closePromises);
|
await Promise.all(closePromises);
|
||||||
log.info(`Cleared doc cache (${count} documents closed)`);
|
log.info(`Cleared doc cache (${count} documents closed)`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
56
apps/desktop/src/lib/project-attachments.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { copyFileToProject } from "@/lib/tauri/fs";
|
||||||
|
|
||||||
|
export interface ImportedReferenceFile {
|
||||||
|
relativePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseName(path: string): string {
|
||||||
|
return path.split(/[/\\]/).pop() || path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPdfPath(path: string): boolean {
|
||||||
|
return path.toLowerCase().endsWith(".pdf");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importReferenceFiles(
|
||||||
|
projectRoot: string,
|
||||||
|
sourcePaths: string[],
|
||||||
|
targetFolder = "attachments",
|
||||||
|
): Promise<ImportedReferenceFile[]> {
|
||||||
|
const imported: ImportedReferenceFile[] = [];
|
||||||
|
|
||||||
|
for (const sourcePath of sourcePaths) {
|
||||||
|
const targetName = `${targetFolder}/${baseName(sourcePath)}`;
|
||||||
|
const relativePath = await copyFileToProject(
|
||||||
|
projectRoot,
|
||||||
|
sourcePath,
|
||||||
|
targetName,
|
||||||
|
);
|
||||||
|
const reference: ImportedReferenceFile = { relativePath };
|
||||||
|
imported.push(reference);
|
||||||
|
}
|
||||||
|
|
||||||
|
return imported;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildReferenceFilesSection(
|
||||||
|
references: ImportedReferenceFile[],
|
||||||
|
): string {
|
||||||
|
if (references.length === 0) return "";
|
||||||
|
|
||||||
|
const lines = references.map((reference) => {
|
||||||
|
if (isPdfPath(reference.relativePath)) {
|
||||||
|
return `- \`${reference.relativePath}\` (PDF)`;
|
||||||
|
}
|
||||||
|
return `- \`${reference.relativePath}\``;
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
"",
|
||||||
|
"### Reference Files",
|
||||||
|
lines.join("\n"),
|
||||||
|
"",
|
||||||
|
"Please review them and incorporate relevant information.",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
22
apps/desktop/src/lib/project-name.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
export function normalizeProjectName(name: string): string {
|
||||||
|
return name.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProjectNameError(name: string): string | null {
|
||||||
|
const trimmed = normalizeProjectName(name);
|
||||||
|
if (!trimmed) return "Enter a project name";
|
||||||
|
if (trimmed === "." || trimmed === "..") {
|
||||||
|
return "Project name cannot be . or ..";
|
||||||
|
}
|
||||||
|
const hasControlCharacter = Array.from(trimmed).some(
|
||||||
|
(char) => char.charCodeAt(0) < 32,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
hasControlCharacter ||
|
||||||
|
/[\\/<>:"|?*]/.test(trimmed) ||
|
||||||
|
/[\s.]$/.test(trimmed)
|
||||||
|
) {
|
||||||
|
return "Project name contains characters Windows cannot use";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
161
apps/desktop/src/lib/provider-icons.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
import anthropicIcon from "@/assets/providers/anthropic.svg";
|
||||||
|
import deepseekIcon from "@/assets/providers/deepseek.svg";
|
||||||
|
import geminiIcon from "@/assets/providers/gemini-color.svg";
|
||||||
|
import moonshotIcon from "@/assets/providers/moonshot.svg";
|
||||||
|
import ollamaIcon from "@/assets/providers/ollama.svg";
|
||||||
|
import openaiIcon from "@/assets/providers/openai.svg";
|
||||||
|
import qwenIcon from "@/assets/providers/qwen.svg";
|
||||||
|
import zhipuIcon from "@/assets/providers/zhipu-color.svg";
|
||||||
|
|
||||||
|
interface ProviderIconInput {
|
||||||
|
label?: string | null;
|
||||||
|
baseUrl?: string | null;
|
||||||
|
model?: string | null;
|
||||||
|
id?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function providerHaystack(input: ProviderIconInput) {
|
||||||
|
return [input.id, input.label, input.baseUrl, input.model]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGenericOpenAiLabel(label?: string | null) {
|
||||||
|
const normalized = label?.trim().toLowerCase();
|
||||||
|
return (
|
||||||
|
!normalized ||
|
||||||
|
normalized === "custom openai api" ||
|
||||||
|
normalized === "openai-compatible provider"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProviderDisplayName(input: ProviderIconInput): string {
|
||||||
|
const haystack = providerHaystack(input);
|
||||||
|
|
||||||
|
if (
|
||||||
|
haystack.includes("ollama") ||
|
||||||
|
haystack.includes("localhost:11434") ||
|
||||||
|
haystack.includes("127.0.0.1:11434")
|
||||||
|
) {
|
||||||
|
return "Ollama";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
haystack.includes("qwen") ||
|
||||||
|
haystack.includes("dashscope") ||
|
||||||
|
haystack.includes("aliyuncs")
|
||||||
|
) {
|
||||||
|
return "Qwen";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (haystack.includes("deepseek")) {
|
||||||
|
return "DeepSeek";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
haystack.includes("glm") ||
|
||||||
|
haystack.includes("zhipu") ||
|
||||||
|
haystack.includes("bigmodel") ||
|
||||||
|
haystack.includes("open.bigmodel.cn")
|
||||||
|
) {
|
||||||
|
return "GLM";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
haystack.includes("gemini") ||
|
||||||
|
haystack.includes("googleapis") ||
|
||||||
|
haystack.includes("generativelanguage")
|
||||||
|
) {
|
||||||
|
return "Gemini";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
haystack.includes("moonshot") ||
|
||||||
|
haystack.includes("kimi") ||
|
||||||
|
haystack.includes("api.moonshot.cn")
|
||||||
|
) {
|
||||||
|
return "Moonshot / Kimi";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
haystack.includes("anthropic") ||
|
||||||
|
haystack.includes("claude") ||
|
||||||
|
haystack.includes("sk-ant")
|
||||||
|
) {
|
||||||
|
return "Anthropic";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (haystack.includes("openai") || haystack.includes("api.openai.com")) {
|
||||||
|
return "OpenAI";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isGenericOpenAiLabel(input.label)) {
|
||||||
|
return input.label!.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Provider";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProviderIconSrc(input: ProviderIconInput): string | null {
|
||||||
|
const haystack = providerHaystack(input);
|
||||||
|
|
||||||
|
if (
|
||||||
|
haystack.includes("ollama") ||
|
||||||
|
haystack.includes("localhost:11434") ||
|
||||||
|
haystack.includes("127.0.0.1:11434")
|
||||||
|
) {
|
||||||
|
return ollamaIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
haystack.includes("qwen") ||
|
||||||
|
haystack.includes("dashscope") ||
|
||||||
|
haystack.includes("aliyuncs")
|
||||||
|
) {
|
||||||
|
return qwenIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (haystack.includes("deepseek")) {
|
||||||
|
return deepseekIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
haystack.includes("glm") ||
|
||||||
|
haystack.includes("zhipu") ||
|
||||||
|
haystack.includes("bigmodel") ||
|
||||||
|
haystack.includes("open.bigmodel.cn")
|
||||||
|
) {
|
||||||
|
return zhipuIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
haystack.includes("gemini") ||
|
||||||
|
haystack.includes("googleapis") ||
|
||||||
|
haystack.includes("generativelanguage")
|
||||||
|
) {
|
||||||
|
return geminiIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
haystack.includes("moonshot") ||
|
||||||
|
haystack.includes("kimi") ||
|
||||||
|
haystack.includes("api.moonshot.cn")
|
||||||
|
) {
|
||||||
|
return moonshotIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
haystack.includes("anthropic") ||
|
||||||
|
haystack.includes("claude") ||
|
||||||
|
haystack.includes("sk-ant")
|
||||||
|
) {
|
||||||
|
return anthropicIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (haystack.includes("openai") || haystack.includes("api.openai.com")) {
|
||||||
|
return openaiIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
@ -55,8 +55,15 @@ const STYLE_EXTENSIONS = new Set([
|
||||||
".ins",
|
".ins",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const IGNORED_DIRECTORY_NAMES = new Set([
|
||||||
|
"node_modules",
|
||||||
|
"__pycache__",
|
||||||
|
"venv",
|
||||||
|
"env",
|
||||||
|
]);
|
||||||
|
|
||||||
const IGNORED_EXTENSIONS = new Set([
|
const IGNORED_EXTENSIONS = new Set([
|
||||||
// Ignored file extensions: LaTeX build artifacts and other non-editable/binary files
|
// Ignore LaTeX build artifacts, but keep user-imported reference files visible.
|
||||||
".aux",
|
".aux",
|
||||||
".log",
|
".log",
|
||||||
".out",
|
".out",
|
||||||
|
|
@ -74,50 +81,24 @@ const IGNORED_EXTENSIONS = new Set([
|
||||||
".vrb",
|
".vrb",
|
||||||
".run.xml",
|
".run.xml",
|
||||||
".bcf",
|
".bcf",
|
||||||
// Binary / non-text files (cannot be meaningfully edited)
|
// Compiled build artifacts (never user reference material)
|
||||||
".hwp",
|
".pyc",
|
||||||
".hwpx",
|
".pyo",
|
||||||
".doc",
|
".pyd",
|
||||||
".docx",
|
|
||||||
".xls",
|
|
||||||
".xlsx",
|
|
||||||
".xlsm",
|
|
||||||
".ppt",
|
|
||||||
".pptx",
|
|
||||||
".accdb",
|
|
||||||
".mdb",
|
|
||||||
".zip",
|
|
||||||
".rar",
|
|
||||||
".7z",
|
|
||||||
".tar",
|
|
||||||
".gz",
|
|
||||||
".exe",
|
|
||||||
".dll",
|
|
||||||
".so",
|
|
||||||
".dylib",
|
|
||||||
".o",
|
".o",
|
||||||
".obj",
|
".obj",
|
||||||
".bin",
|
".so",
|
||||||
".dat",
|
".dylib",
|
||||||
".iso",
|
".dll",
|
||||||
".dmg",
|
|
||||||
".msi",
|
|
||||||
".mp3",
|
|
||||||
".mp4",
|
|
||||||
".avi",
|
|
||||||
".mov",
|
|
||||||
".mkv",
|
|
||||||
".wav",
|
|
||||||
".flac",
|
|
||||||
".psd",
|
|
||||||
".ai",
|
|
||||||
".sketch",
|
|
||||||
".fig",
|
|
||||||
".sqlite",
|
|
||||||
".db",
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function getFileType(name: string): ProjectFileType | null {
|
export function shouldSkipProjectDirectory(name: string): boolean {
|
||||||
|
return (
|
||||||
|
name.startsWith(".") || IGNORED_DIRECTORY_NAMES.has(name.toLowerCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProjectFileType(name: string): ProjectFileType | null {
|
||||||
const lower = name.toLowerCase();
|
const lower = name.toLowerCase();
|
||||||
// Skip ignored file extensions (build artifacts, binary/non-text files)
|
// Skip ignored file extensions (build artifacts, binary/non-text files)
|
||||||
for (const ext of IGNORED_EXTENSIONS) {
|
for (const ext of IGNORED_EXTENSIONS) {
|
||||||
|
|
@ -153,13 +134,13 @@ export async function scanProjectFolder(rootPath: string): Promise<ScanResult> {
|
||||||
|
|
||||||
if (entry.isDirectory) {
|
if (entry.isDirectory) {
|
||||||
// Skip hidden directories and common non-project dirs
|
// Skip hidden directories and common non-project dirs
|
||||||
if (entry.name.startsWith(".") || entry.name === "node_modules") {
|
if (shouldSkipProjectDirectory(entry.name)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
folders.push(relativePath);
|
folders.push(relativePath);
|
||||||
await walk(entryPath, relativePath);
|
await walk(entryPath, relativePath);
|
||||||
} else {
|
} else {
|
||||||
const type = getFileType(entry.name);
|
const type = getProjectFileType(entry.name);
|
||||||
if (type) {
|
if (type) {
|
||||||
// Only stat files that may be skipped by the large-file threshold
|
// Only stat files that may be skipped by the large-file threshold
|
||||||
// (image and other). tex/bib/style are always loaded, pdf is always lazy.
|
// (image and other). tex/bib/style are always loaded, pdf is always lazy.
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import { initializeAppZoom } from "./lib/app-zoom";
|
||||||
import { createLogger } from "./lib/debug/logger";
|
import { createLogger } from "./lib/debug/logger";
|
||||||
import { APP_VISIBILITY_RESTORED } from "./lib/debug/log-store";
|
import { APP_VISIBILITY_RESTORED } from "./lib/debug/log-store";
|
||||||
import "./styles/globals.css";
|
import "./styles/globals.css";
|
||||||
|
|
@ -51,6 +52,7 @@ if (navigator.userAgent.includes("Windows")) {
|
||||||
|
|
||||||
const rootEl = document.getElementById("root");
|
const rootEl = document.getElementById("root");
|
||||||
if (!rootEl) throw new Error("Root element #root not found");
|
if (!rootEl) throw new Error("Root element #root not found");
|
||||||
|
const rootContainer: HTMLElement = rootEl;
|
||||||
|
|
||||||
function hideLoadingScreen() {
|
function hideLoadingScreen() {
|
||||||
const loading = document.getElementById("loading-screen");
|
const loading = document.getElementById("loading-screen");
|
||||||
|
|
@ -61,22 +63,32 @@ function hideLoadingScreen() {
|
||||||
getCurrentWindow().show();
|
getCurrentWindow().show();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
try {
|
||||||
|
await initializeAppZoom();
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to initialize app zoom", { error: String(error) });
|
||||||
|
}
|
||||||
|
|
||||||
if (isDebugWindow) {
|
if (isDebugWindow) {
|
||||||
// Debug window — render standalone debug page
|
// Debug window — render standalone debug page
|
||||||
import("./components/debug/debug-page").then(({ DebugPage }) => {
|
const { DebugPage } = await import("./components/debug/debug-page");
|
||||||
ReactDOM.createRoot(rootEl).render(
|
ReactDOM.createRoot(rootContainer).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<DebugPage />
|
<DebugPage />
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
hideLoadingScreen();
|
hideLoadingScreen();
|
||||||
});
|
return;
|
||||||
} else {
|
}
|
||||||
|
|
||||||
// Main app window
|
// Main app window
|
||||||
const { App } = await import("./App");
|
const { App } = await import("./App");
|
||||||
ReactDOM.createRoot(rootEl).render(
|
ReactDOM.createRoot(rootContainer).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App onReady={hideLoadingScreen} />
|
<App onReady={hideLoadingScreen} />
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void bootstrap();
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,13 @@
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import {
|
||||||
|
isChatModelOption,
|
||||||
|
modelInfoId,
|
||||||
|
type OpenAiCompatibleModelInfo,
|
||||||
|
rememberModelListCapabilityMetadata,
|
||||||
|
} from "@/lib/model-capabilities";
|
||||||
|
|
||||||
|
const MOONSHOT_OFFICIAL_ORIGIN = "https://api.moonshot.ai";
|
||||||
|
|
||||||
// ─── Types ───
|
// ─── Types ───
|
||||||
|
|
||||||
|
|
@ -8,10 +16,21 @@ interface ClaudeStatus {
|
||||||
authenticated: boolean;
|
authenticated: boolean;
|
||||||
binary_path: string | null;
|
binary_path: string | null;
|
||||||
version: string | null;
|
version: string | null;
|
||||||
|
provider_kind: "claude-code" | "openai-compatible" | null;
|
||||||
account_email: string | null;
|
account_email: string | null;
|
||||||
|
provider_model: string | null;
|
||||||
|
provider_base_url: string | null;
|
||||||
|
claude_provider_configured: boolean;
|
||||||
missing_git: boolean;
|
missing_git: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OpenAiCompatibleCredentialInfo {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
base_url: string;
|
||||||
|
model: string;
|
||||||
|
}
|
||||||
|
|
||||||
type SetupStatus =
|
type SetupStatus =
|
||||||
| "checking"
|
| "checking"
|
||||||
| "missing-git"
|
| "missing-git"
|
||||||
|
|
@ -32,9 +51,17 @@ interface ClaudeSetupState {
|
||||||
status: SetupStatus;
|
status: SetupStatus;
|
||||||
isInstalling: boolean;
|
isInstalling: boolean;
|
||||||
isLoggingIn: boolean;
|
isLoggingIn: boolean;
|
||||||
|
isSavingApiKey: boolean;
|
||||||
|
isClearingApiKey: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
version: string | null;
|
version: string | null;
|
||||||
|
providerKind: "claude-code" | "openai-compatible" | null;
|
||||||
accountEmail: string | null;
|
accountEmail: string | null;
|
||||||
|
providerModel: string | null;
|
||||||
|
providerBaseUrl: string | null;
|
||||||
|
claudeProviderConfigured: boolean;
|
||||||
|
openAiCredentials: OpenAiCompatibleCredentialInfo[];
|
||||||
|
activeOpenAiCredentialId: string | null;
|
||||||
|
|
||||||
// Install progress
|
// Install progress
|
||||||
installSteps: StepInfo[];
|
installSteps: StepInfo[];
|
||||||
|
|
@ -48,6 +75,18 @@ interface ClaudeSetupState {
|
||||||
checkStatus: () => Promise<void>;
|
checkStatus: () => Promise<void>;
|
||||||
install: () => Promise<void>;
|
install: () => Promise<void>;
|
||||||
login: () => Promise<void>;
|
login: () => Promise<void>;
|
||||||
|
saveApiKey: (
|
||||||
|
apiKey: string,
|
||||||
|
baseUrl?: string,
|
||||||
|
provider?: string,
|
||||||
|
model?: string,
|
||||||
|
credentialLabel?: string,
|
||||||
|
) => Promise<boolean>;
|
||||||
|
clearApiKey: () => Promise<boolean>;
|
||||||
|
listApiCredentials: () => Promise<void>;
|
||||||
|
deleteApiCredential: (credentialId: string) => Promise<boolean>;
|
||||||
|
setActiveApiCredential: (credentialId: string) => Promise<boolean>;
|
||||||
|
fetchProviderModels: (apiKey: string, baseUrl: string) => Promise<string[]>;
|
||||||
toggleInstallLogs: () => void;
|
toggleInstallLogs: () => void;
|
||||||
|
|
||||||
// Internal helpers
|
// Internal helpers
|
||||||
|
|
@ -83,6 +122,59 @@ const STEP_ORDER_INSTALL = [
|
||||||
];
|
];
|
||||||
const STEP_ORDER_LOGIN = ["opening-browser", "waiting-auth", "complete"];
|
const STEP_ORDER_LOGIN = ["opening-browser", "waiting-auth", "complete"];
|
||||||
|
|
||||||
|
function canonicalOpenAiCompatibleBaseUrl(url: string) {
|
||||||
|
const trimmed = url.trim();
|
||||||
|
const lower = trimmed.toLowerCase();
|
||||||
|
const deepseekMatch = trimmed.match(
|
||||||
|
/^(https?:\/\/api\.deepseek\.com)(?:\/|$)/i,
|
||||||
|
);
|
||||||
|
const deepseekOrigin = deepseekMatch?.[1];
|
||||||
|
if (deepseekOrigin && !lower.includes("/anthropic")) {
|
||||||
|
return `${deepseekOrigin}/anthropic`;
|
||||||
|
}
|
||||||
|
if (deepseekOrigin) {
|
||||||
|
const anthropicIndex = lower.indexOf("/anthropic");
|
||||||
|
return `${trimmed.slice(0, anthropicIndex)}/anthropic`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const qwenMatch = trimmed.match(
|
||||||
|
/^(https?:\/\/dashscope(?:-intl)?\.aliyuncs\.com)(?:\/|$)/i,
|
||||||
|
);
|
||||||
|
const qwenOrigin = qwenMatch?.[1];
|
||||||
|
if (
|
||||||
|
qwenOrigin &&
|
||||||
|
(lower.includes("/apps/anthropic") ||
|
||||||
|
lower.includes("/compatible-mode/") ||
|
||||||
|
trimmed.replace(/\/+$/, "").toLowerCase() === qwenOrigin.toLowerCase())
|
||||||
|
) {
|
||||||
|
const anthropicIndex = lower.indexOf("/apps/anthropic");
|
||||||
|
if (anthropicIndex >= 0) {
|
||||||
|
return `${trimmed.slice(0, anthropicIndex)}/apps/anthropic`;
|
||||||
|
}
|
||||||
|
return `${qwenOrigin}/apps/anthropic`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const moonshotMatch = trimmed.match(
|
||||||
|
/^(https?:\/\/api\.moonshot\.(?:cn|ai))(?:\/|$)/i,
|
||||||
|
);
|
||||||
|
const moonshotOrigin = moonshotMatch?.[1];
|
||||||
|
if (
|
||||||
|
moonshotOrigin &&
|
||||||
|
(lower.includes("/anthropic") ||
|
||||||
|
lower.includes("/v1") ||
|
||||||
|
trimmed.replace(/\/+$/, "").toLowerCase() ===
|
||||||
|
moonshotOrigin.toLowerCase())
|
||||||
|
) {
|
||||||
|
const anthropicIndex = lower.indexOf("/anthropic");
|
||||||
|
if (anthropicIndex >= 0) {
|
||||||
|
return `${MOONSHOT_OFFICIAL_ORIGIN}/anthropic`;
|
||||||
|
}
|
||||||
|
return `${MOONSHOT_OFFICIAL_ORIGIN}/anthropic`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
function advanceSteps(
|
function advanceSteps(
|
||||||
steps: StepInfo[],
|
steps: StepInfo[],
|
||||||
targetId: string,
|
targetId: string,
|
||||||
|
|
@ -107,9 +199,17 @@ export const useClaudeSetupStore = create<ClaudeSetupState>((set, get) => ({
|
||||||
status: "checking",
|
status: "checking",
|
||||||
isInstalling: false,
|
isInstalling: false,
|
||||||
isLoggingIn: false,
|
isLoggingIn: false,
|
||||||
|
isSavingApiKey: false,
|
||||||
|
isClearingApiKey: false,
|
||||||
error: null,
|
error: null,
|
||||||
version: null,
|
version: null,
|
||||||
|
providerKind: null,
|
||||||
accountEmail: null,
|
accountEmail: null,
|
||||||
|
providerModel: null,
|
||||||
|
providerBaseUrl: null,
|
||||||
|
claudeProviderConfigured: false,
|
||||||
|
openAiCredentials: [],
|
||||||
|
activeOpenAiCredentialId: null,
|
||||||
|
|
||||||
installSteps: [],
|
installSteps: [],
|
||||||
installLogs: [],
|
installLogs: [],
|
||||||
|
|
@ -121,15 +221,51 @@ export const useClaudeSetupStore = create<ClaudeSetupState>((set, get) => ({
|
||||||
set({ status: "checking", error: null });
|
set({ status: "checking", error: null });
|
||||||
try {
|
try {
|
||||||
const result = await invoke<ClaudeStatus>("check_claude_status");
|
const result = await invoke<ClaudeStatus>("check_claude_status");
|
||||||
|
let openAiCredentials: OpenAiCompatibleCredentialInfo[] = [];
|
||||||
|
try {
|
||||||
|
openAiCredentials = await invoke<OpenAiCompatibleCredentialInfo[]>(
|
||||||
|
"list_openai_compatible_credentials",
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
openAiCredentials = [];
|
||||||
|
}
|
||||||
|
const activeOpenAiCredentialId =
|
||||||
|
openAiCredentials.find(
|
||||||
|
(credential) =>
|
||||||
|
credential.model === result.provider_model &&
|
||||||
|
credential.base_url === result.provider_base_url,
|
||||||
|
)?.id ??
|
||||||
|
openAiCredentials[0]?.id ??
|
||||||
|
null;
|
||||||
|
|
||||||
// On Windows, Git for Windows is required before anything else
|
// On Windows, Git for Windows is required before anything else
|
||||||
if (result.missing_git) {
|
if (result.missing_git) {
|
||||||
set({ status: "missing-git", version: null, accountEmail: null });
|
set({
|
||||||
|
status: "missing-git",
|
||||||
|
version: null,
|
||||||
|
providerKind: result.provider_kind ?? "claude-code",
|
||||||
|
accountEmail: null,
|
||||||
|
providerModel: null,
|
||||||
|
providerBaseUrl: null,
|
||||||
|
claudeProviderConfigured: result.claude_provider_configured,
|
||||||
|
openAiCredentials,
|
||||||
|
activeOpenAiCredentialId,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!result.installed) {
|
if (!result.installed) {
|
||||||
set({ status: "not-installed", version: null, accountEmail: null });
|
set({
|
||||||
|
status: "not-installed",
|
||||||
|
version: null,
|
||||||
|
providerKind: result.provider_kind ?? "claude-code",
|
||||||
|
accountEmail: null,
|
||||||
|
providerModel: null,
|
||||||
|
providerBaseUrl: null,
|
||||||
|
claudeProviderConfigured: result.claude_provider_configured,
|
||||||
|
openAiCredentials,
|
||||||
|
activeOpenAiCredentialId,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -137,7 +273,13 @@ export const useClaudeSetupStore = create<ClaudeSetupState>((set, get) => ({
|
||||||
set({
|
set({
|
||||||
status: "not-authenticated",
|
status: "not-authenticated",
|
||||||
version: result.version,
|
version: result.version,
|
||||||
|
providerKind: result.provider_kind ?? "claude-code",
|
||||||
accountEmail: null,
|
accountEmail: null,
|
||||||
|
providerModel: null,
|
||||||
|
providerBaseUrl: null,
|
||||||
|
claudeProviderConfigured: result.claude_provider_configured,
|
||||||
|
openAiCredentials,
|
||||||
|
activeOpenAiCredentialId,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -145,7 +287,13 @@ export const useClaudeSetupStore = create<ClaudeSetupState>((set, get) => ({
|
||||||
set({
|
set({
|
||||||
status: "ready",
|
status: "ready",
|
||||||
version: result.version,
|
version: result.version,
|
||||||
|
providerKind: result.provider_kind ?? "claude-code",
|
||||||
accountEmail: result.account_email,
|
accountEmail: result.account_email,
|
||||||
|
providerModel: result.provider_model,
|
||||||
|
providerBaseUrl: result.provider_base_url,
|
||||||
|
claudeProviderConfigured: result.claude_provider_configured,
|
||||||
|
openAiCredentials,
|
||||||
|
activeOpenAiCredentialId,
|
||||||
});
|
});
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
set({
|
set({
|
||||||
|
|
@ -171,7 +319,10 @@ export const useClaudeSetupStore = create<ClaudeSetupState>((set, get) => ({
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fire-and-forget — events drive the rest
|
// Fire-and-forget — events drive the rest
|
||||||
await invoke("install_claude_cli");
|
const success = await invoke<boolean>("install_claude_cli");
|
||||||
|
if (get().isInstalling) {
|
||||||
|
get()._finishInstall(success);
|
||||||
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
set({
|
set({
|
||||||
isInstalling: false,
|
isInstalling: false,
|
||||||
|
|
@ -204,6 +355,174 @@ export const useClaudeSetupStore = create<ClaudeSetupState>((set, get) => ({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
saveApiKey: async (
|
||||||
|
apiKey: string,
|
||||||
|
baseUrl?: string,
|
||||||
|
provider = "claude-code",
|
||||||
|
model?: string,
|
||||||
|
credentialLabel?: string,
|
||||||
|
) => {
|
||||||
|
const status = get().status;
|
||||||
|
if (status === "missing-git" || status === "not-installed") {
|
||||||
|
set({
|
||||||
|
error: "Install Claude Code before configuring an AI provider.",
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = apiKey.trim();
|
||||||
|
const rawUrl = baseUrl?.trim() ?? "";
|
||||||
|
const url =
|
||||||
|
provider === "openai-compatible"
|
||||||
|
? canonicalOpenAiCompatibleBaseUrl(rawUrl)
|
||||||
|
: rawUrl;
|
||||||
|
const modelName = model?.trim() ?? "";
|
||||||
|
if (provider !== "openai-compatible" && !key) {
|
||||||
|
set({ error: "API key is empty" });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key && /\s/.test(key)) {
|
||||||
|
set({ error: "API key cannot contain spaces or line breaks" });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url && !/^https?:\/\//.test(url)) {
|
||||||
|
set({ error: "Base URL must start with http:// or https://" });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === "openai-compatible" && !url) {
|
||||||
|
set({ error: "OpenAI-compatible provider requires a Base URL." });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === "openai-compatible" && !modelName) {
|
||||||
|
set({ error: "OpenAI-compatible provider requires a model." });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
provider !== "openai-compatible" &&
|
||||||
|
!url &&
|
||||||
|
!key.startsWith("sk-ant-")
|
||||||
|
) {
|
||||||
|
set({
|
||||||
|
error:
|
||||||
|
"This looks like an external provider key. Set the provider Base URL, or use an Anthropic key that starts with sk-ant-.",
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ isSavingApiKey: true, error: null });
|
||||||
|
try {
|
||||||
|
if (provider === "openai-compatible") {
|
||||||
|
await invoke("verify_openai_compatible_api_key", {
|
||||||
|
apiKey: key,
|
||||||
|
baseUrl: url,
|
||||||
|
model: modelName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await invoke("save_anthropic_api_key", {
|
||||||
|
apiKey: key,
|
||||||
|
baseUrl: url || null,
|
||||||
|
provider,
|
||||||
|
model: modelName || null,
|
||||||
|
credentialLabel: credentialLabel || null,
|
||||||
|
});
|
||||||
|
set({ isSavingApiKey: false });
|
||||||
|
await get().checkStatus();
|
||||||
|
return true;
|
||||||
|
} catch (err: any) {
|
||||||
|
set({
|
||||||
|
isSavingApiKey: false,
|
||||||
|
error: err?.message || String(err),
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearApiKey: async () => {
|
||||||
|
set({ isClearingApiKey: true, error: null });
|
||||||
|
try {
|
||||||
|
await invoke("clear_anthropic_api_key");
|
||||||
|
set({ isClearingApiKey: false });
|
||||||
|
await get().checkStatus();
|
||||||
|
return true;
|
||||||
|
} catch (err: any) {
|
||||||
|
set({
|
||||||
|
isClearingApiKey: false,
|
||||||
|
error: err?.message || String(err),
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
listApiCredentials: async () => {
|
||||||
|
const credentials = await invoke<OpenAiCompatibleCredentialInfo[]>(
|
||||||
|
"list_openai_compatible_credentials",
|
||||||
|
);
|
||||||
|
set((state) => ({
|
||||||
|
openAiCredentials: credentials,
|
||||||
|
activeOpenAiCredentialId:
|
||||||
|
credentials.find(
|
||||||
|
(credential) =>
|
||||||
|
credential.model === state.providerModel &&
|
||||||
|
credential.base_url === state.providerBaseUrl,
|
||||||
|
)?.id ??
|
||||||
|
credentials[0]?.id ??
|
||||||
|
null,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteApiCredential: async (credentialId: string) => {
|
||||||
|
try {
|
||||||
|
await invoke("delete_openai_compatible_credential", {
|
||||||
|
credentialId,
|
||||||
|
});
|
||||||
|
await get().checkStatus();
|
||||||
|
return true;
|
||||||
|
} catch (err: any) {
|
||||||
|
set({ error: err?.message || String(err) });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setActiveApiCredential: async (credentialId: string) => {
|
||||||
|
try {
|
||||||
|
await invoke("set_active_openai_compatible_credential", {
|
||||||
|
credentialId,
|
||||||
|
});
|
||||||
|
await get().checkStatus();
|
||||||
|
return true;
|
||||||
|
} catch (err: any) {
|
||||||
|
set({ error: err?.message || String(err) });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchProviderModels: async (apiKey: string, baseUrl: string) => {
|
||||||
|
const trimmedBaseUrl = baseUrl.trim();
|
||||||
|
const models = await invoke<Array<string | OpenAiCompatibleModelInfo>>(
|
||||||
|
"list_openai_compatible_models",
|
||||||
|
{
|
||||||
|
apiKey: apiKey.trim(),
|
||||||
|
baseUrl: trimmedBaseUrl,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
rememberModelListCapabilityMetadata(trimmedBaseUrl, models);
|
||||||
|
return models
|
||||||
|
.filter((model) =>
|
||||||
|
isChatModelOption({
|
||||||
|
baseUrl: trimmedBaseUrl,
|
||||||
|
model: modelInfoId(model),
|
||||||
|
metadata: typeof model === "string" ? undefined : model.metadata,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.map(modelInfoId);
|
||||||
|
},
|
||||||
|
|
||||||
toggleInstallLogs: () => {
|
toggleInstallLogs: () => {
|
||||||
set((state) => ({ installLogsVisible: !state.installLogsVisible }));
|
set((state) => ({ installLogsVisible: !state.installLogsVisible }));
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import {
|
import {
|
||||||
scanProjectFolder,
|
scanProjectFolder,
|
||||||
readTexFileContent,
|
readTexFileContent,
|
||||||
|
|
@ -21,9 +22,11 @@ import { clearDocCache } from "@/lib/mupdf/pdf-doc-cache";
|
||||||
import { clearScrollPositionCache } from "@/components/workspace/preview/pdf-viewer";
|
import { clearScrollPositionCache } from "@/components/workspace/preview/pdf-viewer";
|
||||||
import { clearZoomCache } from "@/components/workspace/preview/pdf-preview";
|
import { clearZoomCache } from "@/components/workspace/preview/pdf-preview";
|
||||||
import { clearEditorStateCache } from "@/components/workspace/editor/latex-editor";
|
import { clearEditorStateCache } from "@/components/workspace/editor/latex-editor";
|
||||||
|
import { useProjectStore } from "@/stores/project-store";
|
||||||
import { createLogger } from "@/lib/debug/logger";
|
import { createLogger } from "@/lib/debug/logger";
|
||||||
|
|
||||||
const log = createLogger("document");
|
const log = createLogger("document");
|
||||||
|
const PROJECT_RENAME_LOCK_RETRY_DELAYS_MS = [150, 300, 600, 1000];
|
||||||
|
|
||||||
export interface ProjectFile {
|
export interface ProjectFile {
|
||||||
id: string; // relativePath is the id
|
id: string; // relativePath is the id
|
||||||
|
|
@ -56,6 +59,11 @@ export function getCurrentPdfBytes(): Uint8Array | null {
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Get the root file id for the currently displayed PDF, if any. */
|
||||||
|
export function getCurrentPdfRootId(): string | null {
|
||||||
|
return _currentPdfRootId;
|
||||||
|
}
|
||||||
|
|
||||||
/** Check if any PDF data exists for the current root. */
|
/** Check if any PDF data exists for the current root. */
|
||||||
export function hasPdfData(): boolean {
|
export function hasPdfData(): boolean {
|
||||||
return _currentPdfRootId != null && _pdfBytesCache.has(_currentPdfRootId);
|
return _currentPdfRootId != null && _pdfBytesCache.has(_currentPdfRootId);
|
||||||
|
|
@ -91,6 +99,7 @@ interface DocumentState {
|
||||||
lastCompiledGenerations: Map<string, number>;
|
lastCompiledGenerations: Map<string, number>;
|
||||||
|
|
||||||
openProject: (rootPath: string) => Promise<void>;
|
openProject: (rootPath: string) => Promise<void>;
|
||||||
|
renameProject: (newName: string) => Promise<void>;
|
||||||
closeProject: () => void;
|
closeProject: () => void;
|
||||||
setActiveFile: (id: string) => void;
|
setActiveFile: (id: string) => void;
|
||||||
addFile: (file: Omit<ProjectFile, "id" | "isDirty">) => string;
|
addFile: (file: Omit<ProjectFile, "id" | "isDirty">) => string;
|
||||||
|
|
@ -148,25 +157,57 @@ function getActiveFile(state: { files: ProjectFile[]; activeFileId: string }) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the root .tex file for compilation.
|
* Resolve the root .tex file for compilation.
|
||||||
* Parses `% !TEX root = <file>` magic comment from the first 20 lines.
|
*
|
||||||
* Returns the root file's ID (relativePath), or the input fileId if no root is specified.
|
* Priority order:
|
||||||
|
* 1. `% !TEX root = <file>` magic comment in the first 20 lines of the active file
|
||||||
|
* 2. The file itself, if it contains `\documentclass`
|
||||||
|
* 3. `main.tex` or `document.tex` that contains `\documentclass`
|
||||||
|
* 4. Any other .tex file in the project that contains `\documentclass`
|
||||||
|
* 5. Fallback: the active file itself
|
||||||
*/
|
*/
|
||||||
export function resolveTexRoot(fileId: string, files: ProjectFile[]): string {
|
export function resolveTexRoot(fileId: string, files: ProjectFile[]): string {
|
||||||
const file = files.find((f) => f.id === fileId);
|
const file = files.find((f) => f.id === fileId);
|
||||||
if (!file || file.type !== "tex" || !file.content) return fileId;
|
if (!file || file.type !== "tex" || !file.content) return fileId;
|
||||||
|
|
||||||
|
// 1. Check for % !TEX root magic comment
|
||||||
const lines = file.content.split("\n").slice(0, 20);
|
const lines = file.content.split("\n").slice(0, 20);
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const match = line.match(/^%\s*!TEX\s+root\s*=\s*(.+)/i);
|
const match = line.match(/^%\s*!TEX\s+root\s*=\s*(.+)/i);
|
||||||
if (match) {
|
if (match) {
|
||||||
const rootPath = match[1].trim();
|
const rootPath = match[1].trim();
|
||||||
// Try matching by relative path first, then by filename
|
|
||||||
const target =
|
const target =
|
||||||
files.find((f) => f.relativePath === rootPath) ??
|
files.find((f) => f.relativePath === rootPath) ??
|
||||||
files.find((f) => f.name === rootPath);
|
files.find((f) => f.name === rootPath);
|
||||||
if (target) return target.id;
|
if (target) return target.id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. If the current file contains \documentclass, it is a root file
|
||||||
|
if (/\\documentclass[\s{[]/.test(file.content)) {
|
||||||
|
return fileId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Look for main.tex or document.tex with \documentclass
|
||||||
|
const wellKnown = files.find(
|
||||||
|
(f) =>
|
||||||
|
(f.name === "main.tex" || f.name === "document.tex") &&
|
||||||
|
f.type === "tex" &&
|
||||||
|
f.content &&
|
||||||
|
/\\documentclass[\s{[]/.test(f.content),
|
||||||
|
);
|
||||||
|
if (wellKnown) return wellKnown.id;
|
||||||
|
|
||||||
|
// 4. Any .tex file with \documentclass
|
||||||
|
const anyRoot = files.find(
|
||||||
|
(f) =>
|
||||||
|
f.type === "tex" &&
|
||||||
|
f.id !== fileId &&
|
||||||
|
f.content &&
|
||||||
|
/\\documentclass[\s{[]/.test(f.content),
|
||||||
|
);
|
||||||
|
if (anyRoot) return anyRoot.id;
|
||||||
|
|
||||||
|
// 5. Fallback: the active file itself
|
||||||
return fileId;
|
return fileId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -193,6 +234,104 @@ function migrateCacheKey<V>(
|
||||||
return copy;
|
return copy;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeProjectRoot(rootPath: string): string {
|
||||||
|
return rootPath.replace(/[\\/]+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitProjectRoot(rootPath: string): {
|
||||||
|
parentPath: string;
|
||||||
|
folderName: string;
|
||||||
|
separator: string;
|
||||||
|
} {
|
||||||
|
const normalized = normalizeProjectRoot(rootPath);
|
||||||
|
const lastSep = Math.max(
|
||||||
|
normalized.lastIndexOf("/"),
|
||||||
|
normalized.lastIndexOf("\\"),
|
||||||
|
);
|
||||||
|
if (lastSep < 0) {
|
||||||
|
throw new Error("Project path has no parent folder");
|
||||||
|
}
|
||||||
|
|
||||||
|
const separator = normalized[lastSep];
|
||||||
|
return {
|
||||||
|
parentPath: lastSep === 0 ? separator : normalized.slice(0, lastSep),
|
||||||
|
folderName: normalized.slice(lastSep + 1),
|
||||||
|
separator,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRenamedProjectRoot(rootPath: string, newName: string): string {
|
||||||
|
const name = newName.trim();
|
||||||
|
if (!name) throw new Error("Project name cannot be empty");
|
||||||
|
if (name === "." || name === "..") {
|
||||||
|
throw new Error("Project name cannot be . or ..");
|
||||||
|
}
|
||||||
|
if (/[\\/<>:"|?*]/.test(name) || /[\s.]$/.test(name)) {
|
||||||
|
throw new Error("Project name contains characters Windows cannot use");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { parentPath, folderName, separator } = splitProjectRoot(rootPath);
|
||||||
|
if (name === folderName) return normalizeProjectRoot(rootPath);
|
||||||
|
return `${parentPath}${parentPath.endsWith(separator) ? "" : separator}${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWindowsFolderLockError(error: unknown): boolean {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return (
|
||||||
|
message.includes("os error 32") ||
|
||||||
|
message.includes("being used by another process") ||
|
||||||
|
message.includes("another program is using") ||
|
||||||
|
message.includes("进程无法访问") ||
|
||||||
|
message.includes("另一个程序正在使用")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatProjectRenameError(error: unknown): string {
|
||||||
|
if (isWindowsFolderLockError(error)) {
|
||||||
|
return [
|
||||||
|
"Project folder is still in use.",
|
||||||
|
"Close any external PDF viewer, terminal, Python process, or file explorer preview using this project, then try again.",
|
||||||
|
].join(" ");
|
||||||
|
}
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renameProjectRootWithRetry(
|
||||||
|
oldRoot: string,
|
||||||
|
newRoot: string,
|
||||||
|
): Promise<void> {
|
||||||
|
for (let attempt = 0; ; attempt++) {
|
||||||
|
try {
|
||||||
|
await renameFileOnDisk(oldRoot, newRoot);
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
const delay = PROJECT_RENAME_LOCK_RETRY_DELAYS_MS[attempt];
|
||||||
|
if (!isWindowsFolderLockError(error) || delay == null) {
|
||||||
|
throw new Error(formatProjectRenameError(error));
|
||||||
|
}
|
||||||
|
await sleep(delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForCompileToFinish(
|
||||||
|
getState: () => DocumentState,
|
||||||
|
): Promise<void> {
|
||||||
|
const started = Date.now();
|
||||||
|
while (getState().isCompiling) {
|
||||||
|
if (Date.now() - started > 10_000) {
|
||||||
|
throw new Error(
|
||||||
|
"Compilation is still running. Wait for it to finish before renaming the project.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await sleep(200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-save: debounced save 2 seconds after last content change
|
// Auto-save: debounced save 2 seconds after last content change
|
||||||
let autoSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
let autoSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
// Store reference set after creation to avoid TDZ issues
|
// Store reference set after creation to avoid TDZ issues
|
||||||
|
|
@ -234,6 +373,7 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
|
||||||
|
|
||||||
openProject: async (rootPath: string) => {
|
openProject: async (rootPath: string) => {
|
||||||
log.info(`Opening project: ${rootPath}`);
|
log.info(`Opening project: ${rootPath}`);
|
||||||
|
await invoke("allow_project_directory", { rootPath });
|
||||||
const { files: fsFiles, folders: fsFolders } =
|
const { files: fsFiles, folders: fsFolders } =
|
||||||
await scanProjectFolder(rootPath);
|
await scanProjectFolder(rootPath);
|
||||||
const projectFiles: ProjectFile[] = [];
|
const projectFiles: ProjectFile[] = [];
|
||||||
|
|
@ -241,7 +381,7 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
|
||||||
for (const f of fsFiles) {
|
for (const f of fsFiles) {
|
||||||
const pf: ProjectFile = {
|
const pf: ProjectFile = {
|
||||||
id: f.relativePath,
|
id: f.relativePath,
|
||||||
name: f.relativePath.split("/").pop() || f.relativePath,
|
name: f.relativePath.split(/[/\\]/).pop() || f.relativePath,
|
||||||
relativePath: f.relativePath,
|
relativePath: f.relativePath,
|
||||||
absolutePath: f.absolutePath,
|
absolutePath: f.absolutePath,
|
||||||
type: f.type,
|
type: f.type,
|
||||||
|
|
@ -315,13 +455,83 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
renameProject: async (newName: string) => {
|
||||||
|
const state = get();
|
||||||
|
if (!state.projectRoot) throw new Error("No project open");
|
||||||
|
|
||||||
|
const oldRoot = state.projectRoot;
|
||||||
|
const newRoot = buildRenamedProjectRoot(oldRoot, newName);
|
||||||
|
if (newRoot === normalizeProjectRoot(oldRoot)) return;
|
||||||
|
|
||||||
|
if (autoSaveTimer) {
|
||||||
|
clearTimeout(autoSaveTimer);
|
||||||
|
autoSaveTimer = null;
|
||||||
|
}
|
||||||
|
await waitForCompileToFinish(get);
|
||||||
|
|
||||||
|
const chatState = useClaudeChatStore.getState();
|
||||||
|
const streamingTabs =
|
||||||
|
"tabs" in chatState && Array.isArray(chatState.tabs)
|
||||||
|
? chatState.tabs.filter((tab) => tab.isStreaming)
|
||||||
|
: [];
|
||||||
|
if (streamingTabs.length > 0) {
|
||||||
|
await Promise.all(
|
||||||
|
streamingTabs.map((tab) =>
|
||||||
|
invoke("cancel_claude_execution", { tabId: tab.id }).catch(() => {}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await sleep(250);
|
||||||
|
}
|
||||||
|
|
||||||
|
await state.saveAllFiles();
|
||||||
|
const dirtyFiles = get().files.filter(
|
||||||
|
(f) => f.isDirty && f.content != null,
|
||||||
|
);
|
||||||
|
if (dirtyFiles.length > 0) {
|
||||||
|
throw new Error("Save failed. Please save changes before renaming.");
|
||||||
|
}
|
||||||
|
|
||||||
|
clearPdfBytesCache();
|
||||||
|
clearScrollPositionCache();
|
||||||
|
clearZoomCache();
|
||||||
|
clearEditorStateCache();
|
||||||
|
useHistoryStore.getState().reset();
|
||||||
|
set((s) => ({
|
||||||
|
pdfRevision: s.pdfRevision + 1,
|
||||||
|
compileError: null,
|
||||||
|
compileErrorCache: new Map(),
|
||||||
|
lastCompiledGenerations: new Map(),
|
||||||
|
}));
|
||||||
|
await clearDocCache();
|
||||||
|
await sleep(150);
|
||||||
|
|
||||||
|
await renameProjectRootWithRetry(oldRoot, newRoot);
|
||||||
|
try {
|
||||||
|
await invoke("migrate_project_sessions", {
|
||||||
|
oldProjectPath: oldRoot,
|
||||||
|
newProjectPath: newRoot,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
log.warn("Failed to migrate project sessions after rename", {
|
||||||
|
oldRoot,
|
||||||
|
newRoot,
|
||||||
|
error: String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const projectStore = useProjectStore.getState();
|
||||||
|
projectStore.renameRecentProject(oldRoot, newRoot);
|
||||||
|
projectStore.setLastProjectFolder(splitProjectRoot(newRoot).parentPath);
|
||||||
|
|
||||||
|
await get().openProject(newRoot);
|
||||||
|
},
|
||||||
|
|
||||||
closeProject: () => {
|
closeProject: () => {
|
||||||
log.info("Closing project");
|
log.info("Closing project");
|
||||||
if (autoSaveTimer) {
|
if (autoSaveTimer) {
|
||||||
clearTimeout(autoSaveTimer);
|
clearTimeout(autoSaveTimer);
|
||||||
autoSaveTimer = null;
|
autoSaveTimer = null;
|
||||||
}
|
}
|
||||||
clearDocCache();
|
void clearDocCache();
|
||||||
clearScrollPositionCache();
|
clearScrollPositionCache();
|
||||||
clearZoomCache();
|
clearZoomCache();
|
||||||
clearEditorStateCache();
|
clearEditorStateCache();
|
||||||
|
|
@ -343,6 +553,15 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
|
||||||
|
|
||||||
setActiveFile: (id) => {
|
setActiveFile: (id) => {
|
||||||
const state = get();
|
const state = get();
|
||||||
|
const file = state.files.find((f) => f.id === id);
|
||||||
|
if (!file || file.type !== "tex") {
|
||||||
|
set({
|
||||||
|
activeFileId: id,
|
||||||
|
selectionRange: null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const rootId = resolveTexRoot(id, state.files);
|
const rootId = resolveTexRoot(id, state.files);
|
||||||
const newPdfRootId = _pdfBytesCache.has(rootId) ? rootId : null;
|
const newPdfRootId = _pdfBytesCache.has(rootId) ? rootId : null;
|
||||||
const pdfRootChanged = newPdfRootId !== _currentPdfRootId;
|
const pdfRootChanged = newPdfRootId !== _currentPdfRootId;
|
||||||
|
|
@ -796,7 +1015,7 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
|
||||||
const newAbsPath = await join(state.projectRoot, newRelativePath);
|
const newAbsPath = await join(state.projectRoot, newRelativePath);
|
||||||
await renameFileOnDisk(file.absolutePath, newAbsPath);
|
await renameFileOnDisk(file.absolutePath, newAbsPath);
|
||||||
|
|
||||||
const newName = newRelativePath.split("/").pop() || file.name;
|
const newName = newRelativePath.split(/[/\\]/).pop() || file.name;
|
||||||
migratePdfBytesKey(fileId, newRelativePath);
|
migratePdfBytesKey(fileId, newRelativePath);
|
||||||
set((s) => {
|
set((s) => {
|
||||||
const compileErrorCache = migrateCacheKey(
|
const compileErrorCache = migrateCacheKey(
|
||||||
|
|
@ -833,7 +1052,7 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
|
||||||
const state = get();
|
const state = get();
|
||||||
if (!state.projectRoot) return;
|
if (!state.projectRoot) return;
|
||||||
|
|
||||||
const folderName = folderPath.split("/").pop()!;
|
const folderName = folderPath.split(/[/\\]/).pop()!;
|
||||||
const newFolderPath = targetFolder
|
const newFolderPath = targetFolder
|
||||||
? `${targetFolder}/${folderName}`
|
? `${targetFolder}/${folderName}`
|
||||||
: folderName;
|
: folderName;
|
||||||
|
|
@ -911,7 +1130,7 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
|
||||||
// New file on disk
|
// New file on disk
|
||||||
const pf: ProjectFile = {
|
const pf: ProjectFile = {
|
||||||
id: fsFile.relativePath,
|
id: fsFile.relativePath,
|
||||||
name: fsFile.relativePath.split("/").pop() || fsFile.relativePath,
|
name: fsFile.relativePath.split(/[/\\]/).pop() || fsFile.relativePath,
|
||||||
relativePath: fsFile.relativePath,
|
relativePath: fsFile.relativePath,
|
||||||
absolutePath: fsFile.absolutePath,
|
absolutePath: fsFile.absolutePath,
|
||||||
type: fsFile.type,
|
type: fsFile.type,
|
||||||
|
|
|
||||||
13
apps/desktop/src/stores/preview-store.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
interface PreviewState {
|
||||||
|
visible: boolean;
|
||||||
|
toggle: () => void;
|
||||||
|
setVisible: (v: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePreviewStore = create<PreviewState>((set) => ({
|
||||||
|
visible: true,
|
||||||
|
toggle: () => set((s) => ({ visible: !s.visible })),
|
||||||
|
setVisible: (v) => set({ visible: v }),
|
||||||
|
}));
|
||||||
|
|
@ -12,11 +12,28 @@ interface ProjectState {
|
||||||
lastProjectFolder: string | null;
|
lastProjectFolder: string | null;
|
||||||
addRecentProject: (path: string) => void;
|
addRecentProject: (path: string) => void;
|
||||||
removeRecentProject: (path: string) => void;
|
removeRecentProject: (path: string) => void;
|
||||||
|
renameRecentProject: (oldPath: string, newPath: string) => void;
|
||||||
setLastProjectFolder: (path: string) => void;
|
setLastProjectFolder: (path: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_RECENT = 10;
|
const MAX_RECENT = 10;
|
||||||
|
|
||||||
|
function normalizeRecentPath(path: string): string {
|
||||||
|
return path.replace(/[\\/]+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function recentProjectName(path: string): string {
|
||||||
|
const normalized = normalizeRecentPath(path);
|
||||||
|
return normalized.split(/[/\\]/).pop() || normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameProjectPath(a: string, b: string): boolean {
|
||||||
|
return (
|
||||||
|
normalizeRecentPath(a).toLowerCase() ===
|
||||||
|
normalizeRecentPath(b).toLowerCase()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const useProjectStore = create<ProjectState>()(
|
export const useProjectStore = create<ProjectState>()(
|
||||||
persist(
|
persist(
|
||||||
(set) => ({
|
(set) => ({
|
||||||
|
|
@ -26,12 +43,15 @@ export const useProjectStore = create<ProjectState>()(
|
||||||
setLastProjectFolder: (path) => set({ lastProjectFolder: path }),
|
setLastProjectFolder: (path) => set({ lastProjectFolder: path }),
|
||||||
|
|
||||||
addRecentProject: (path) => {
|
addRecentProject: (path) => {
|
||||||
const name = path.split("/").pop() || path;
|
const normalizedPath = normalizeRecentPath(path);
|
||||||
|
const name = recentProjectName(normalizedPath);
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const filtered = state.recentProjects.filter((p) => p.path !== path);
|
const filtered = state.recentProjects.filter(
|
||||||
|
(p) => !isSameProjectPath(p.path, normalizedPath),
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
recentProjects: [
|
recentProjects: [
|
||||||
{ path, name, lastOpened: Date.now() },
|
{ path: normalizedPath, name, lastOpened: Date.now() },
|
||||||
...filtered,
|
...filtered,
|
||||||
].slice(0, MAX_RECENT),
|
].slice(0, MAX_RECENT),
|
||||||
};
|
};
|
||||||
|
|
@ -40,12 +60,33 @@ export const useProjectStore = create<ProjectState>()(
|
||||||
|
|
||||||
removeRecentProject: (path) => {
|
removeRecentProject: (path) => {
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
recentProjects: state.recentProjects.filter((p) => p.path !== path),
|
recentProjects: state.recentProjects.filter(
|
||||||
|
(p) => !isSameProjectPath(p.path, path),
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
renameRecentProject: (oldPath, newPath) => {
|
||||||
|
const normalizedNewPath = normalizeRecentPath(newPath);
|
||||||
|
const name = recentProjectName(normalizedNewPath);
|
||||||
|
set((state) => ({
|
||||||
|
recentProjects: [
|
||||||
|
{ path: normalizedNewPath, name, lastOpened: Date.now() },
|
||||||
|
...state.recentProjects.filter(
|
||||||
|
(p) =>
|
||||||
|
!isSameProjectPath(p.path, oldPath) &&
|
||||||
|
!isSameProjectPath(p.path, normalizedNewPath),
|
||||||
|
),
|
||||||
|
].slice(0, MAX_RECENT),
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "claude-prism-projects",
|
name: "claude-prism-projects",
|
||||||
|
partialize: (state) => ({
|
||||||
|
recentProjects: state.recentProjects,
|
||||||
|
lastProjectFolder: state.lastProjectFolder,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
25
apps/desktop/src/stores/settings-store.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { persist } from "zustand/middleware";
|
||||||
|
|
||||||
|
type CompilerBackend = "tectonic" | "texlive";
|
||||||
|
|
||||||
|
interface SettingsState {
|
||||||
|
compilerBackend: CompilerBackend;
|
||||||
|
setCompilerBackend: (backend: CompilerBackend) => void;
|
||||||
|
vimMode: boolean;
|
||||||
|
setVimMode: (enabled: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSettingsStore = create<SettingsState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
compilerBackend: "tectonic",
|
||||||
|
setCompilerBackend: (backend) => set({ compilerBackend: backend }),
|
||||||
|
vimMode: false,
|
||||||
|
setVimMode: (enabled) => set({ vimMode: enabled }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "claude-prism-settings",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
@ -124,6 +124,7 @@
|
||||||
/* macOS overlay titlebar inset — traffic lights safe area */
|
/* macOS overlay titlebar inset — traffic lights safe area */
|
||||||
--titlebar-height: 18px;
|
--titlebar-height: 18px;
|
||||||
--traffic-light-width: 70px;
|
--traffic-light-width: 70px;
|
||||||
|
--workspace-topbar-height: 40px;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
|
|
|
||||||
2
apps/desktop/src/vite-env.d.ts
vendored
|
|
@ -1 +1,3 @@
|
||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare const __MUPDF_WASM_FS_PATH__: string;
|
||||||
|
|
|
||||||