Initial commit
@@ -0,0 +1,3 @@
|
|||||||
|
[target.aarch64-unknown-linux-gnu]
|
||||||
|
linker = "aarch64-linux-gnu-gcc"
|
||||||
|
ar = "aarch64-linux-gnu-gcc-ar"
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"mcp__context7__resolve-library-id",
|
||||||
|
"mcp__context7__query-docs",
|
||||||
|
"Bash(pnpm install:*)",
|
||||||
|
"Bash(pnpm build:*)",
|
||||||
|
"WebSearch",
|
||||||
|
"Bash(wc:*)",
|
||||||
|
"Bash(head:*)",
|
||||||
|
"Bash(file:*)",
|
||||||
|
"Bash(python3:*)",
|
||||||
|
"WebFetch(domain:agentclientprotocol.com)",
|
||||||
|
"WebFetch(domain:docs.rs)",
|
||||||
|
"WebFetch(domain:github.com)",
|
||||||
|
"WebFetch(domain:agentclientprotocol.github.io)",
|
||||||
|
"Bash(cargo build:*)",
|
||||||
|
"Bash(pnpm add:*)",
|
||||||
|
"Bash(cd:*)",
|
||||||
|
"Bash(ls:*)",
|
||||||
|
"Bash(pnpm next build:*)",
|
||||||
|
"Bash(timeout 15 pnpm dev:*)",
|
||||||
|
"Bash(cat:*)",
|
||||||
|
"Bash(pnpm tauri build:*)",
|
||||||
|
"mcp__ide__getDiagnostics",
|
||||||
|
"Bash(cargo check:*)",
|
||||||
|
"Bash(cargo clippy:*)",
|
||||||
|
"Bash(npx tsc:*)",
|
||||||
|
"Bash(pnpm eslint:*)",
|
||||||
|
"WebFetch(domain:cdn.agentclientprotocol.com)",
|
||||||
|
"Bash(cargo doc:*)",
|
||||||
|
"Bash(grep:*)",
|
||||||
|
"Bash(\"/Users/xggz/Library/Caches/app.codeg/acp-binaries/codex/v0.9.2/codex-acp\" --help)",
|
||||||
|
"Bash(\"/Users/xggz/Library/Caches/app.codeg/acp-binaries/codex/v0.9.2/codex-acp\" --version)",
|
||||||
|
"Bash(gh api:*)",
|
||||||
|
"Bash(cargo search:*)",
|
||||||
|
"WebFetch(domain:www.npmjs.com)",
|
||||||
|
"WebFetch(domain:registry.npmjs.org)",
|
||||||
|
"Bash(npx @zed-industries/codex-acp:*)",
|
||||||
|
"Bash(codex:*)",
|
||||||
|
"Bash(npm view:*)",
|
||||||
|
"Bash(pnpm exec tsc:*)",
|
||||||
|
"Bash(pnpm tauri dev:*)",
|
||||||
|
"WebFetch(domain:elements.ai-sdk.dev)",
|
||||||
|
"WebFetch(domain:ai-sdk.dev)",
|
||||||
|
"Bash(npx ai-elements@latest add:*)",
|
||||||
|
"Bash(pnpm dlx:*)",
|
||||||
|
"Bash(pnpm ls:*)",
|
||||||
|
"Bash(npm ls:*)",
|
||||||
|
"Bash(pnpm info:*)",
|
||||||
|
"Bash(pnpm prettier:*)",
|
||||||
|
"Bash(npx prettier:*)",
|
||||||
|
"Bash(lsof:*)",
|
||||||
|
"Bash(npx shadcn@latest add dialog:*)",
|
||||||
|
"Bash(kill:*)",
|
||||||
|
"Bash(node -e:*)",
|
||||||
|
"Bash(cargo add:*)",
|
||||||
|
"WebFetch(domain:crates.io)",
|
||||||
|
"Bash(cargo info:*)",
|
||||||
|
"Bash(git mv:*)",
|
||||||
|
"Bash(npx next build)",
|
||||||
|
"mcp__plugin_context7_context7__resolve-library-id",
|
||||||
|
"mcp__plugin_context7_context7__query-docs",
|
||||||
|
"WebFetch(domain:lobehub.com)",
|
||||||
|
"WebFetch(domain:raw.githubusercontent.com)",
|
||||||
|
"WebFetch(domain:icons.lobehub.com)",
|
||||||
|
"Bash(npm info:*)",
|
||||||
|
"Bash(git stash:*)",
|
||||||
|
"Bash(sqlite3:*)",
|
||||||
|
"Bash(npx:*)",
|
||||||
|
"Bash(# Check how many of the 179 unmatched have cross-turn matches sqlite3 \"\"/Users/xggz/Library/Application Support/app.codeg/codeg.db\"\" \"\"SELECT m.id, m.conversation_id, m.role_type, m.content FROM message m WHERE m.deleted_at IS NULL\"\")",
|
||||||
|
"WebFetch(domain:developers.openai.com)",
|
||||||
|
"WebFetch(domain:jpcaparas.medium.com)",
|
||||||
|
"Bash(find:*)",
|
||||||
|
"WebFetch(domain:)",
|
||||||
|
"Bash(xargs basename:*)",
|
||||||
|
"WebFetch(domain:opencode.ai)",
|
||||||
|
"mcp__acp__Edit",
|
||||||
|
"mcp__acp__Write",
|
||||||
|
"mcp__acp__Bash",
|
||||||
|
"Bash(pnpm lint:*)",
|
||||||
|
"WebFetch(domain:geminicli.com)",
|
||||||
|
"WebFetch(domain:addyosmani.com)",
|
||||||
|
"WebFetch(domain:docs.openclaw.ai)",
|
||||||
|
"WebFetch(domain:openclaw.dog)",
|
||||||
|
"Bash(curl:*)",
|
||||||
|
"WebFetch(domain:deepwiki.com)",
|
||||||
|
"WebFetch(domain:openclaw-ai.com)",
|
||||||
|
"WebFetch(domain:www.getopenclaw.ai)",
|
||||||
|
"WebFetch(domain:docs.z.ai)",
|
||||||
|
"WebFetch(domain:blog.laozhang.ai)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# http://editorconfig.org
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
# 适用于所有文件的通用设置
|
||||||
|
charset = utf-8
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
insert_final_newline = true
|
||||||
|
end_of_line = lf
|
||||||
|
|
||||||
|
[*.html]
|
||||||
|
# Html 文件
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
tab_width = 2
|
||||||
|
|
||||||
|
[*.js]
|
||||||
|
# JavaScript/React 文件
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
tab_width = 2
|
||||||
|
|
||||||
|
[*.mjs]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
tab_width = 2
|
||||||
|
|
||||||
|
[*.jsx]
|
||||||
|
# React JSX 文件
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
tab_width = 2
|
||||||
|
|
||||||
|
[*.ts]
|
||||||
|
# TypeScript 文件
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
tab_width = 2
|
||||||
|
|
||||||
|
[*.tsx]
|
||||||
|
# TypeScript React 文件
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
tab_width = 2
|
||||||
|
|
||||||
|
[*.json]
|
||||||
|
# JSON 文件通常使用 2 个空格
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
tab_width = 2
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*.*.*"
|
||||||
|
|
||||||
|
env:
|
||||||
|
RUST_BACKTRACE: short
|
||||||
|
HUSKY: 0
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: release-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
create-draft-release:
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
outputs:
|
||||||
|
release_id: ${{ steps.release.outputs.release_id }}
|
||||||
|
release_url: ${{ steps.release.outputs.release_url }}
|
||||||
|
prerelease: ${{ steps.meta.outputs.prerelease }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- id: meta
|
||||||
|
name: Resolve release channel
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
if [[ "${GITHUB_REF_NAME}" == *"-rc"* ]]; then
|
||||||
|
echo "prerelease=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "prerelease=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Check tag commit belongs to default branch
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
DEFAULT_BRANCH="${{ github.event.repository.default_branch }}"
|
||||||
|
git fetch origin "${DEFAULT_BRANCH}"
|
||||||
|
|
||||||
|
TAG_COMMIT=$(git rev-list -n 1 "${GITHUB_REF_NAME}")
|
||||||
|
if git merge-base --is-ancestor "${TAG_COMMIT}" "origin/${DEFAULT_BRANCH}"; then
|
||||||
|
echo "Tag ${GITHUB_REF_NAME} is based on ${DEFAULT_BRANCH}"
|
||||||
|
else
|
||||||
|
echo "Tag ${GITHUB_REF_NAME} is not based on ${DEFAULT_BRANCH}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Verify tag matches app version
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
APP_VERSION=$(node -p "require('./src-tauri/tauri.conf.json').version")
|
||||||
|
if [[ "v$APP_VERSION" != "${GITHUB_REF_NAME}" ]]; then
|
||||||
|
echo "Tag ${GITHUB_REF_NAME} does not match app version v$APP_VERSION"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- id: release
|
||||||
|
name: Create or reuse draft release
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
env:
|
||||||
|
PRERELEASE: ${{ steps.meta.outputs.prerelease }}
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const tag = context.ref.replace("refs/tags/", "");
|
||||||
|
const { owner, repo } = context.repo;
|
||||||
|
const prerelease = process.env.PRERELEASE === "true";
|
||||||
|
let release;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = await github.rest.repos.getReleaseByTag({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
tag,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing.data.draft) {
|
||||||
|
core.setFailed(
|
||||||
|
`Release for tag ${tag} already exists and is not a draft.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
release = existing.data;
|
||||||
|
if (release.prerelease !== prerelease) {
|
||||||
|
const updated = await github.rest.repos.updateRelease({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
release_id: release.id,
|
||||||
|
prerelease,
|
||||||
|
});
|
||||||
|
release = updated.data;
|
||||||
|
}
|
||||||
|
core.info(`Reusing existing draft release #${release.id}`);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.status !== 404) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await github.rest.repos.createRelease({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
tag_name: tag,
|
||||||
|
name: `codeg ${tag}`,
|
||||||
|
body: [
|
||||||
|
`Release channel: ${prerelease ? "Pre-release" : "Stable"}`,
|
||||||
|
"",
|
||||||
|
"Assets will be attached automatically by this workflow.",
|
||||||
|
`Workflow run: https://github.com/${owner}/${repo}/actions/runs/${context.runId}`,
|
||||||
|
].join("\n"),
|
||||||
|
draft: true,
|
||||||
|
prerelease,
|
||||||
|
});
|
||||||
|
|
||||||
|
release = created.data;
|
||||||
|
core.info(`Created draft release #${release.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
core.setOutput("release_id", String(release.id));
|
||||||
|
core.setOutput("release_url", release.html_url);
|
||||||
|
|
||||||
|
build-tauri:
|
||||||
|
needs: create-draft-release
|
||||||
|
name: Build ${{ matrix.name }}
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- name: "macOS x64"
|
||||||
|
runner: "macos-latest"
|
||||||
|
target: "x86_64-apple-darwin"
|
||||||
|
- name: "macOS arm64"
|
||||||
|
runner: "macos-latest"
|
||||||
|
target: "aarch64-apple-darwin"
|
||||||
|
- name: "Linux x64"
|
||||||
|
runner: "ubuntu-22.04"
|
||||||
|
target: "x86_64-unknown-linux-gnu"
|
||||||
|
- name: "Linux arm64"
|
||||||
|
runner: "ubuntu-22.04"
|
||||||
|
target: "aarch64-unknown-linux-gnu"
|
||||||
|
- name: "Windows x64"
|
||||||
|
runner: "windows-2022"
|
||||||
|
target: "x86_64-pc-windows-msvc"
|
||||||
|
- name: "Windows arm64"
|
||||||
|
runner: "windows-latest"
|
||||||
|
target: "aarch64-pc-windows-msvc"
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
run_install: false
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: "pnpm"
|
||||||
|
|
||||||
|
- name: Install Rust stable
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: ${{ matrix.target }}
|
||||||
|
|
||||||
|
- name: Rust cache
|
||||||
|
uses: swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: "./src-tauri -> target"
|
||||||
|
|
||||||
|
- name: Install Linux x64 dependencies
|
||||||
|
if: matrix.target == 'x86_64-unknown-linux-gnu'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y \
|
||||||
|
libwebkit2gtk-4.1-dev \
|
||||||
|
libayatana-appindicator3-dev \
|
||||||
|
librsvg2-dev \
|
||||||
|
patchelf
|
||||||
|
|
||||||
|
- name: Install Linux arm64 cross dependencies
|
||||||
|
if: matrix.target == 'aarch64-unknown-linux-gnu'
|
||||||
|
run: |
|
||||||
|
cat > /tmp/sources.list << 'EOF'
|
||||||
|
deb [arch=amd64] http://archive.ubuntu.com/ubuntu jammy main restricted universe multiverse
|
||||||
|
deb [arch=amd64] http://archive.ubuntu.com/ubuntu jammy-updates main restricted universe multiverse
|
||||||
|
deb [arch=amd64] http://archive.ubuntu.com/ubuntu jammy-backports main restricted universe multiverse
|
||||||
|
deb [arch=amd64] http://security.ubuntu.com/ubuntu jammy-security main restricted universe multiverse
|
||||||
|
|
||||||
|
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports jammy main restricted universe multiverse
|
||||||
|
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports jammy-updates main restricted universe multiverse
|
||||||
|
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports jammy-backports main restricted universe multiverse
|
||||||
|
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports jammy-security main restricted universe multiverse
|
||||||
|
EOF
|
||||||
|
sudo mv /etc/apt/sources.list /etc/apt/sources.list.default
|
||||||
|
sudo mv /tmp/sources.list /etc/apt/sources.list
|
||||||
|
|
||||||
|
sudo dpkg --add-architecture arm64
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y \
|
||||||
|
gcc-aarch64-linux-gnu \
|
||||||
|
g++-aarch64-linux-gnu \
|
||||||
|
libwebkit2gtk-4.1-dev:arm64 \
|
||||||
|
libayatana-appindicator3-dev:arm64 \
|
||||||
|
librsvg2-dev:arm64 \
|
||||||
|
libssl-dev:arm64 \
|
||||||
|
patchelf
|
||||||
|
|
||||||
|
- name: Configure Linux arm64 cross env
|
||||||
|
if: matrix.target == 'aarch64-unknown-linux-gnu'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "PKG_CONFIG_ALLOW_CROSS=1" >> "$GITHUB_ENV"
|
||||||
|
echo "PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig:/usr/share/pkgconfig" >> "$GITHUB_ENV"
|
||||||
|
echo "PKG_CONFIG_LIBDIR=/usr/lib/aarch64-linux-gnu/pkgconfig:/usr/share/pkgconfig" >> "$GITHUB_ENV"
|
||||||
|
echo "PKG_CONFIG_SYSROOT_DIR=/usr/aarch64-linux-gnu" >> "$GITHUB_ENV"
|
||||||
|
echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> "$GITHUB_ENV"
|
||||||
|
echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_AR=aarch64-linux-gnu-gcc-ar" >> "$GITHUB_ENV"
|
||||||
|
echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS=-Clinker=aarch64-linux-gnu-gcc" >> "$GITHUB_ENV"
|
||||||
|
echo "CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc" >> "$GITHUB_ENV"
|
||||||
|
echo "CXX_aarch64_unknown_linux_gnu=aarch64-linux-gnu-g++" >> "$GITHUB_ENV"
|
||||||
|
echo "AR_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc-ar" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Verify Linux arm64 cross toolchain
|
||||||
|
if: matrix.target == 'aarch64-unknown-linux-gnu'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
which aarch64-linux-gnu-gcc
|
||||||
|
aarch64-linux-gnu-gcc -v
|
||||||
|
rustup target list --installed
|
||||||
|
echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=${CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER}"
|
||||||
|
echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_AR=${CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_AR}"
|
||||||
|
echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS=${CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS}"
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build and upload to draft release (Linux arm64)
|
||||||
|
if: matrix.target == 'aarch64-unknown-linux-gnu'
|
||||||
|
uses: tauri-apps/tauri-action@v0.6.1
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||||
|
PKG_CONFIG_ALLOW_CROSS: 1
|
||||||
|
PKG_CONFIG_PATH: /usr/lib/aarch64-linux-gnu/pkgconfig:/usr/share/pkgconfig
|
||||||
|
PKG_CONFIG_LIBDIR: /usr/lib/aarch64-linux-gnu/pkgconfig:/usr/share/pkgconfig
|
||||||
|
PKG_CONFIG_SYSROOT_DIR: /usr/aarch64-linux-gnu
|
||||||
|
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
|
||||||
|
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_AR: aarch64-linux-gnu-gcc-ar
|
||||||
|
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS: -Clinker=aarch64-linux-gnu-gcc
|
||||||
|
CC_aarch64_unknown_linux_gnu: aarch64-linux-gnu-gcc
|
||||||
|
CXX_aarch64_unknown_linux_gnu: aarch64-linux-gnu-g++
|
||||||
|
AR_aarch64_unknown_linux_gnu: aarch64-linux-gnu-gcc-ar
|
||||||
|
with:
|
||||||
|
releaseId: ${{ needs.create-draft-release.outputs.release_id }}
|
||||||
|
tagName: ${{ github.ref_name }}
|
||||||
|
releaseDraft: true
|
||||||
|
prerelease: ${{ needs.create-draft-release.outputs.prerelease }}
|
||||||
|
tauriScript: pnpm tauri
|
||||||
|
args: --target ${{ matrix.target }} --bundles deb,rpm
|
||||||
|
includeUpdaterJson: false
|
||||||
|
retryAttempts: 2
|
||||||
|
|
||||||
|
- name: Build and upload to draft release (Non-Linux arm64)
|
||||||
|
if: matrix.target != 'aarch64-unknown-linux-gnu'
|
||||||
|
uses: tauri-apps/tauri-action@v0.6.1
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||||
|
with:
|
||||||
|
releaseId: ${{ needs.create-draft-release.outputs.release_id }}
|
||||||
|
tagName: ${{ github.ref_name }}
|
||||||
|
releaseDraft: true
|
||||||
|
prerelease: ${{ needs.create-draft-release.outputs.prerelease }}
|
||||||
|
tauriScript: pnpm tauri
|
||||||
|
args: --target ${{ matrix.target }}
|
||||||
|
includeUpdaterJson: true
|
||||||
|
retryAttempts: 2
|
||||||
|
|
||||||
|
publish-release:
|
||||||
|
needs:
|
||||||
|
- create-draft-release
|
||||||
|
- build-tauri
|
||||||
|
if: ${{ needs.build-tauri.result == 'success' }}
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- name: Publish draft release
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
env:
|
||||||
|
RELEASE_ID: ${{ needs.create-draft-release.outputs.release_id }}
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const { owner, repo } = context.repo;
|
||||||
|
const releaseId = Number(process.env.RELEASE_ID);
|
||||||
|
const prerelease =
|
||||||
|
"${{ needs.create-draft-release.outputs.prerelease }}" === "true";
|
||||||
|
const tag = context.ref.replace("refs/tags/", "");
|
||||||
|
|
||||||
|
const { data: tagRef } = await github.rest.git.getRef({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
ref: `tags/${tag}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
let commitSha = tagRef.object.sha;
|
||||||
|
if (tagRef.object.type === "tag") {
|
||||||
|
const { data: annotatedTag } = await github.rest.git.getTag({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
tag_sha: commitSha,
|
||||||
|
});
|
||||||
|
if (annotatedTag.object.type !== "commit") {
|
||||||
|
core.setFailed(
|
||||||
|
`Tag ${tag} points to ${annotatedTag.object.type}, not a commit.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
commitSha = annotatedTag.object.sha;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: commit } = await github.rest.repos.getCommit({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
ref: commitSha,
|
||||||
|
});
|
||||||
|
|
||||||
|
const commitMessage =
|
||||||
|
commit.commit.message?.trim() || "_No commit message._";
|
||||||
|
|
||||||
|
const body = [
|
||||||
|
`Release channel: ${prerelease ? "Pre-release" : "Stable"}`,
|
||||||
|
"",
|
||||||
|
"## Release notes",
|
||||||
|
commitMessage,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
await github.rest.repos.updateRelease({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
release_id: releaseId,
|
||||||
|
body,
|
||||||
|
draft: false,
|
||||||
|
prerelease,
|
||||||
|
});
|
||||||
|
|
||||||
|
core.info(`Published release #${releaseId}`);
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
tsconfig.tsbuildinfo
|
||||||
|
|
||||||
|
# Next.js
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"semi": false,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"printWidth": 80
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
This file provides guidance to Code Agent when working with code in this repository.
|
||||||
|
|
||||||
|
## 项目概述
|
||||||
|
|
||||||
|
codeg 是一个桌面应用,用于聚合和浏览本地 AI 编码代理的会话记录。它从多个代理(Claude Code、Codex、OpenCode)的本地文件系统中读取会话数据,统一格式后在 UI 中展示。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **桌面运行时**: Tauri 2(Rust 后端 + webview 前端)
|
||||||
|
- **前端**: Next.js 16(静态导出模式)+ React 19 + TypeScript(strict)
|
||||||
|
- **样式**: Tailwind CSS v4 + shadcn/ui(radix-maia 风格)
|
||||||
|
- **包管理器**: pnpm
|
||||||
|
|
||||||
|
## 开发命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启动完整应用(Tauri + Next.js Turbopack 开发服务器)
|
||||||
|
pnpm tauri dev
|
||||||
|
|
||||||
|
# 仅启动前端
|
||||||
|
pnpm dev
|
||||||
|
|
||||||
|
# 构建前端(静态导出到 out/)
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
# 构建 Tauri 桌面应用
|
||||||
|
pnpm tauri build
|
||||||
|
|
||||||
|
# Lint 检查
|
||||||
|
pnpm eslint .
|
||||||
|
|
||||||
|
# Rust 检查(在 src-tauri/ 目录下执行)
|
||||||
|
cargo check
|
||||||
|
cargo clippy
|
||||||
|
cargo build
|
||||||
|
```
|
||||||
|
|
||||||
|
目前尚未配置测试框架。
|
||||||
|
|
||||||
|
## 架构
|
||||||
|
|
||||||
|
### Rust 后端(`src-tauri/src/`)
|
||||||
|
|
||||||
|
后端负责读取和解析本地文件系统上的代理会话文件:
|
||||||
|
|
||||||
|
- **`models/`** — 共享数据结构
|
||||||
|
- **`parsers/`** — 每个代理一个解析器
|
||||||
|
- **`commands/sessions.rs`** — 暴露给前端的 Tauri 命令
|
||||||
|
|
||||||
|
### 前端(`src/`)
|
||||||
|
|
||||||
|
- **`lib/types.ts`** — Rust 模型的 TypeScript 镜像。`AgentType` 为 `"claude_code" | "codex" | "open_code"`(snake_case,与 Rust serde 一致)
|
||||||
|
- **`lib/tauri.ts`** — 对每个 Tauri 命令的类型化 `invoke()` 封装
|
||||||
|
- **`app/`** — Next.js 页面,不使用动态路由
|
||||||
|
- **`components/`** — 项目组件
|
||||||
|
- **`components/ui/`** — shadcn 组件
|
||||||
|
|
||||||
|
### 数据流
|
||||||
|
|
||||||
|
前端调用 `invoke()` → Tauri 命令 → 解析器读取本地文件 → 返回 `SessionSummary[]` / `SessionDetail` → React 渲染
|
||||||
|
|
||||||
|
## 关键约束
|
||||||
|
|
||||||
|
- **仅支持静态导出**:`next.config.ts` 设置 `output: "export"`,不支持动态路由(`[param]`),必须使用查询参数替代
|
||||||
|
- **路径别名**:`@/*` 映射到 `./src/*`,导入写法为 `@/lib/utils`、`@/components/ui/button`
|
||||||
|
- **Rust serde 约定**:`AgentType` 序列化为 snake_case(`claude_code`、`open_code`)。Tauri 命令参数在 JS 侧使用 camelCase,Rust 侧使用 snake_case
|
||||||
|
|
||||||
|
## 代码风格
|
||||||
|
|
||||||
|
- Prettier:无分号、尾逗号(es5)、2 空格缩进、80 字符宽度
|
||||||
|
- ESLint:next/core-web-vitals + typescript + prettier
|
||||||
|
- TypeScript:strict 模式,启用 `noUnusedLocals` 和 `noUnusedParameters`
|
||||||
|
- Rust:2021 edition,使用 `thiserror` 定义错误类型
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## 项目概述
|
||||||
|
|
||||||
|
codeg 是一个桌面应用,用于聚合和浏览本地 AI 编码代理的会话记录。它从多个代理(Claude Code、Codex、OpenCode)的本地文件系统中读取会话数据,统一格式后在 UI 中展示。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **桌面运行时**: Tauri 2(Rust 后端 + webview 前端)
|
||||||
|
- **前端**: Next.js 16(静态导出模式)+ React 19 + TypeScript(strict)
|
||||||
|
- **样式**: Tailwind CSS v4 + shadcn/ui(radix-maia 风格)
|
||||||
|
- **包管理器**: pnpm
|
||||||
|
|
||||||
|
## 开发命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启动完整应用(Tauri + Next.js Turbopack 开发服务器)
|
||||||
|
pnpm tauri dev
|
||||||
|
|
||||||
|
# 仅启动前端
|
||||||
|
pnpm dev
|
||||||
|
|
||||||
|
# 构建前端(静态导出到 out/)
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
# 构建 Tauri 桌面应用
|
||||||
|
pnpm tauri build
|
||||||
|
|
||||||
|
# Lint 检查
|
||||||
|
pnpm eslint .
|
||||||
|
|
||||||
|
# Rust 检查(在 src-tauri/ 目录下执行)
|
||||||
|
cargo check
|
||||||
|
cargo clippy
|
||||||
|
cargo build
|
||||||
|
```
|
||||||
|
|
||||||
|
目前尚未配置测试框架。
|
||||||
|
|
||||||
|
## 架构
|
||||||
|
|
||||||
|
### Rust 后端(`src-tauri/src/`)
|
||||||
|
|
||||||
|
后端负责读取和解析本地文件系统上的代理会话文件:
|
||||||
|
|
||||||
|
- **`models/`** — 共享数据结构
|
||||||
|
- **`parsers/`** — 每个代理一个解析器
|
||||||
|
- **`commands/sessions.rs`** — 暴露给前端的 Tauri 命令
|
||||||
|
|
||||||
|
### 前端(`src/`)
|
||||||
|
|
||||||
|
- **`lib/types.ts`** — Rust 模型的 TypeScript 镜像。`AgentType` 为 `"claude_code" | "codex" | "open_code"`(snake_case,与 Rust serde 一致)
|
||||||
|
- **`lib/tauri.ts`** — 对每个 Tauri 命令的类型化 `invoke()` 封装
|
||||||
|
- **`app/`** — Next.js 页面,不使用动态路由
|
||||||
|
- **`components/`** — 项目组件
|
||||||
|
- **`components/ui/`** — shadcn 组件
|
||||||
|
|
||||||
|
### 数据流
|
||||||
|
|
||||||
|
前端调用 `invoke()` → Tauri 命令 → 解析器读取本地文件 → 返回 `SessionSummary[]` / `SessionDetail` → React 渲染
|
||||||
|
|
||||||
|
## 关键约束
|
||||||
|
|
||||||
|
- **仅支持静态导出**:`next.config.ts` 设置 `output: "export"`,不支持动态路由(`[param]`),必须使用查询参数替代
|
||||||
|
- **路径别名**:`@/*` 映射到 `./src/*`,导入写法为 `@/lib/utils`、`@/components/ui/button`
|
||||||
|
- **Rust serde 约定**:`AgentType` 序列化为 snake_case(`claude_code`、`open_code`)。Tauri 命令参数在 JS 侧使用 camelCase,Rust 侧使用 snake_case
|
||||||
|
|
||||||
|
## 代码风格
|
||||||
|
|
||||||
|
- Prettier:无分号、尾逗号(es5)、2 空格缩进、80 字符宽度
|
||||||
|
- ESLint:next/core-web-vitals + typescript + prettier
|
||||||
|
- TypeScript:strict 模式,启用 `noUnusedLocals` 和 `noUnusedParameters`
|
||||||
|
- Rust:2021 edition,使用 `thiserror` 定义错误类型
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
# Codeg
|
||||||
|
|
||||||
|
[](https://github.com/xintaofei/codeg/releases)
|
||||||
|
[](./LICENSE)
|
||||||
|
[](https://tauri.app/)
|
||||||
|
[](https://nextjs.org/)
|
||||||
|
|
||||||
|
Codeg(Code Generation)是一个面向多 Agent 的企业级代码生成工作台。
|
||||||
|
它把不同 AI 编码代理(Claude Code、Codex CLI、OpenCode、Gemini CLI 等)统一到一个桌面应用里,支持会话聚合、并行 worktree 开发、MCP 与 Skills 管理,以及 Git/文件/终端一体化操作。
|
||||||
|
|
||||||
|
> 当前版本:`v0.0.x`(快速迭代中,适合早期体验与共建)
|
||||||
|
|
||||||
|
## 项目定位
|
||||||
|
|
||||||
|
Codeg 的目标不是“又一个聊天窗口”,而是:
|
||||||
|
|
||||||
|
- 面向真实研发场景的企业级代码生成工作台
|
||||||
|
- 统一多代理协作入口
|
||||||
|
- 支持多分支 / worktree 并发任务开发
|
||||||
|
- 在单个项目上下文中聚合会话、变更、提交与执行链路
|
||||||
|
- 逐步演进为稳定高效的 Agent Code Generation Workspace
|
||||||
|
|
||||||
|
## 核心亮点
|
||||||
|
|
||||||
|
- 多 Agent 统一工作台:同一项目内可同时使用不同代理并行对话
|
||||||
|
- 本地会话聚合:导入并查看本机历史会话,统一结构化渲染(消息、工具调用、Token、上下文窗口)
|
||||||
|
- Worktree 并发开发:内置 `git worktree` 流程,支持多窗口并行任务
|
||||||
|
- MCP 管理中心:扫描本地 MCP,支持官方 Registry 与 Smithery 搜索/安装
|
||||||
|
- Skills 管理:支持全局 / 项目级 Skills 的查看、编辑、保存与删除
|
||||||
|
- 工程操作闭环:文件树、Diff、Git 变更、Git Log、提交窗口、内置终端、项目命令
|
||||||
|
|
||||||
|
## 能力矩阵
|
||||||
|
|
||||||
|
| 模块 | 当前状态 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 本地会话解析与导入 | ✅ | 已支持 Claude Code / Codex / OpenCode / Gemini CLI |
|
||||||
|
| ACP 实时连接与对话 | ✅ | 已支持 20+ Agent 适配(npx/uvx/二进制) |
|
||||||
|
| Worktree 并发开发 | ✅ | 分支管理 + `git_worktree_add` + 多文件夹窗口 |
|
||||||
|
| MCP 管理 | ✅ | 本地扫描 + Marketplace 搜索/安装(Official + Smithery) |
|
||||||
|
| Skills 管理 | ✅ | 已支持 Claude/Codex/OpenCode/Gemini/OpenClaw |
|
||||||
|
| 文件工作区 | ✅ | 预览、编辑、Diff、保存、冲突对比 |
|
||||||
|
|
||||||
|
## 支持范围
|
||||||
|
|
||||||
|
### 1) 会话解析(历史会话聚合)
|
||||||
|
|
||||||
|
| Agent | 环境变量优先路径 | macOS / Linux 默认路径 | Windows 默认路径 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Claude Code | `$CLAUDE_CONFIG_DIR/projects` | `~/.claude/projects` | `%USERPROFILE%\\.claude\\projects` |
|
||||||
|
| Codex CLI | `$CODEX_HOME/sessions` | `~/.codex/sessions` | `%USERPROFILE%\\.codex\\sessions` |
|
||||||
|
| OpenCode | `$XDG_DATA_HOME/opencode/opencode.db` | `~/.local/share/opencode/opencode.db` | `%USERPROFILE%\\.local\\share\\opencode\\opencode.db` |
|
||||||
|
| Gemini CLI | `$GEMINI_CLI_HOME/.gemini` | `~/.gemini` | `%USERPROFILE%\\.gemini` |
|
||||||
|
|
||||||
|
> 说明:以上默认路径按当前实现的回退逻辑整理,实际以环境变量为准。
|
||||||
|
|
||||||
|
### 2) ACP 连接(实时 Agent 会话)
|
||||||
|
|
||||||
|
当前注册表内置:
|
||||||
|
|
||||||
|
`Auggie, Autohand, Claude Code, Cline, Codebuddy Code, Codex CLI, Corust Agent, Factory Droid, Gemini CLI, GitHub Copilot, goose, Junie, Kimi CLI, Minion Code, Mistral Vibe, OpenClaw, OpenCode, Qoder CLI, Qwen Code, Stakpak`
|
||||||
|
|
||||||
|
### 3) Skills 设置页支持
|
||||||
|
|
||||||
|
- 已支持:`Claude Code / Codex / OpenCode / Gemini CLI / OpenClaw`
|
||||||
|
- 其他代理:后续逐步补齐
|
||||||
|
|
||||||
|
### 4) MCP 目标应用
|
||||||
|
|
||||||
|
当前可写入配置:
|
||||||
|
|
||||||
|
- Claude Code
|
||||||
|
- Codex
|
||||||
|
- OpenCode
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 环境要求
|
||||||
|
|
||||||
|
- Node.js `>=22`(推荐)
|
||||||
|
- pnpm `>=10`
|
||||||
|
- Rust stable(2021 edition)
|
||||||
|
- Tauri 2 构建依赖(参考官方文档)
|
||||||
|
|
||||||
|
Linux(Debian/Ubuntu)常见依赖示例:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y \
|
||||||
|
libwebkit2gtk-4.1-dev \
|
||||||
|
libayatana-appindicator3-dev \
|
||||||
|
librsvg2-dev \
|
||||||
|
patchelf
|
||||||
|
```
|
||||||
|
|
||||||
|
### 安装与开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# 启动完整桌面应用(Tauri + Next.js)
|
||||||
|
pnpm tauri dev
|
||||||
|
|
||||||
|
# 仅启动前端
|
||||||
|
pnpm dev
|
||||||
|
|
||||||
|
# 前端构建(静态导出到 out/)
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
# 构建桌面应用
|
||||||
|
pnpm tauri build
|
||||||
|
|
||||||
|
# Lint
|
||||||
|
pnpm lint .
|
||||||
|
|
||||||
|
# Rust 检查(在 src-tauri/ 目录)
|
||||||
|
cargo check
|
||||||
|
cargo clippy
|
||||||
|
cargo build
|
||||||
|
```
|
||||||
|
|
||||||
|
> 当前仓库尚未配置完整自动化测试框架(已有部分 Rust 单元测试)。
|
||||||
|
|
||||||
|
## 使用流程(建议)
|
||||||
|
|
||||||
|
1. 在欢迎页打开本地目录,或 Clone 仓库。
|
||||||
|
2. 进入 `Settings > Agents`,执行 Preflight、安装/配置代理。
|
||||||
|
3. 在分支菜单创建分支或 Worktree,启动并行任务窗口。
|
||||||
|
4. 新建会话并选择 Agent 开始编码。
|
||||||
|
5. 在右侧面板查看会话文件改动、Git Changes、Git Log。
|
||||||
|
6. 使用提交窗口完成选择文件、编辑提交信息与提交。
|
||||||
|
|
||||||
|
## 架构概览
|
||||||
|
|
||||||
|
```text
|
||||||
|
Next.js 16 (Static Export) + React 19
|
||||||
|
|
|
||||||
|
| invoke()
|
||||||
|
v
|
||||||
|
Tauri 2 Commands (Rust)
|
||||||
|
|- ACP Manager (agent lifecycle, streaming events, permissions)
|
||||||
|
|- Parsers (local session ingestion)
|
||||||
|
|- Git / File Tree / Terminal runtime
|
||||||
|
|- MCP marketplace + local config writer
|
||||||
|
|- SeaORM + SQLite (folders, conversations, settings)
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Local Filesystem / Local Agent Data / Git Repos
|
||||||
|
```
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/ # Next.js 前端
|
||||||
|
app/ # 页面与布局(静态导出模式)
|
||||||
|
components/ # 业务组件 + UI 组件
|
||||||
|
contexts/ # 全局状态(会话、终端、工作区等)
|
||||||
|
lib/ # Tauri 调用封装、类型与工具
|
||||||
|
|
||||||
|
src-tauri/src/ # Rust 后端
|
||||||
|
acp/ # Agent Connection Protocol 连接管理
|
||||||
|
commands/ # 暴露给前端的 Tauri 命令
|
||||||
|
parsers/ # 各代理会话解析器
|
||||||
|
db/ # SQLite/SeaORM 与迁移
|
||||||
|
terminal/ # PTY 终端管理
|
||||||
|
network/ # 代理等网络设置
|
||||||
|
```
|
||||||
|
|
||||||
|
## 开发约束
|
||||||
|
|
||||||
|
- 前端为静态导出模式(`next.config.ts` 中 `output: "export"`)
|
||||||
|
- 不使用 Next.js 动态路由(`[param]`),统一使用查询参数
|
||||||
|
- Tauri 命令参数:前端 `camelCase`,Rust 端 `snake_case`
|
||||||
|
- TypeScript 严格模式(`strict` + noUnused)
|
||||||
|
|
||||||
|
## 本地数据存储
|
||||||
|
|
||||||
|
- 应用数据库:`appDataDir/codeg.db`(SQLite + WAL)
|
||||||
|
- 会话原始数据:直接读取各 Agent 本地目录/文件,不做云端中转
|
||||||
|
- 可导入会话:按“支持范围”中的路径规则扫描并写入本地数据库索引
|
||||||
|
|
||||||
|
## 产品路线图
|
||||||
|
|
||||||
|
### Near-term
|
||||||
|
|
||||||
|
- 会话解析扩展到更多代理格式(统一抽象与插件化注册)
|
||||||
|
- MCP 安装管理(版本锁定、配置模板、环境校验)
|
||||||
|
- Skills 模板中心(可复用模板、项目级分发)
|
||||||
|
|
||||||
|
### Mid-term
|
||||||
|
|
||||||
|
- 多 Agent 协同编排(任务拆分、角色分工、结果合并)
|
||||||
|
- Worktree 任务面板(任务状态、上下游依赖、可视化流转)
|
||||||
|
- 团队级配置同步(Agent/MCP/Skills 配置分发)
|
||||||
|
|
||||||
|
### Long-term
|
||||||
|
|
||||||
|
- 插件化扩展机制(Parser / MCP / Skills 生态)
|
||||||
|
- 指标看板增强(Token/成本/时延/成功率)
|
||||||
|
- 组织级知识沉淀:会话资产化、检索、复盘与最佳实践沉淀
|
||||||
|
|
||||||
|
## 隐私与安全
|
||||||
|
|
||||||
|
- 默认以本地数据为主:会话解析、数据库与项目操作均在本机执行
|
||||||
|
- 仅在你主动使用时访问网络(如 Agent 安装、MCP 市场搜索、Git 远程操作)
|
||||||
|
- 支持系统代理配置,便于企业网络环境接入
|
||||||
|
|
||||||
|
## 贡献
|
||||||
|
|
||||||
|
欢迎通过 Issue / PR 共建,建议优先从以下方向参与:
|
||||||
|
|
||||||
|
- 新代理会话解析器
|
||||||
|
- MCP 兼容层与配置适配
|
||||||
|
- Worktree 并行开发体验优化
|
||||||
|
- 会话可视化与指标面板优化
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Apache-2.0. See `LICENSE`.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "radix-maia",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/app/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"rtl": false,
|
||||||
|
"menuColor": "default",
|
||||||
|
"menuAccent": "subtle",
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"registries": {
|
||||||
|
"@ai-elements": "https://ai-sdk.dev/elements/api/registry/{name}.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config"
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals"
|
||||||
|
import nextTs from "eslint-config-next/typescript"
|
||||||
|
import eslintConfigPrettier from "eslint-config-prettier"
|
||||||
|
import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended"
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
"src-tauri/target/**",
|
||||||
|
]),
|
||||||
|
eslintConfigPrettier,
|
||||||
|
eslintPluginPrettierRecommended,
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
"prettier/prettier": "error",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
export default eslintConfig
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { NextConfig } from "next"
|
||||||
|
|
||||||
|
const isProd = process.env.NODE_ENV === "production"
|
||||||
|
const internalHost = process.env.TAURI_DEV_HOST || "localhost"
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: "export",
|
||||||
|
images: {
|
||||||
|
unoptimized: true,
|
||||||
|
},
|
||||||
|
assetPrefix: isProd ? undefined : `http://${internalHost}:3000`,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default nextConfig
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
{
|
||||||
|
"name": "codeg",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.13",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev --turbopack",
|
||||||
|
"build": "next build",
|
||||||
|
"lint": "eslint",
|
||||||
|
"tauri": "tauri"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@base-ui/react": "^1.1.0",
|
||||||
|
"@lobehub/icons": "^4.11.0",
|
||||||
|
"@monaco-editor/react": "^4.7.0",
|
||||||
|
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||||
|
"@streamdown/cjk": "^1.0.2",
|
||||||
|
"@streamdown/code": "^1.0.2",
|
||||||
|
"@streamdown/math": "^1.0.2",
|
||||||
|
"@streamdown/mermaid": "^1.0.2",
|
||||||
|
"@tanstack/react-virtual": "^3.13.18",
|
||||||
|
"@tauri-apps/api": "^2",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||||
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
|
"@tauri-apps/plugin-process": "^2.3.1",
|
||||||
|
"@tauri-apps/plugin-updater": "^2.10.0",
|
||||||
|
"@tauri-apps/plugin-window-state": "~2.4.1",
|
||||||
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
|
"@xterm/xterm": "^6.0.0",
|
||||||
|
"ai": "^6.0.78",
|
||||||
|
"ansi-to-react": "^6.2.6",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"ignore": "^7.0.5",
|
||||||
|
"lucide-react": "^0.563.0",
|
||||||
|
"monaco-editor": "^0.55.1",
|
||||||
|
"motion": "^12.34.0",
|
||||||
|
"next": "^16",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"radix-ui": "^1.4.3",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-resizable-panels": "^2.1.9",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
|
"shadcn": "^3.8.4",
|
||||||
|
"shiki": "^3.22.0",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
|
"streamdown": "^2.2.0",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
|
"use-stick-to-bottom": "^1.1.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
|
"@tauri-apps/cli": "^2",
|
||||||
|
"@types/node": "25.2.2",
|
||||||
|
"@types/react": "^19.1.8",
|
||||||
|
"@types/react-dom": "^19.1.6",
|
||||||
|
"eslint": "^9.39.2",
|
||||||
|
"eslint-config-next": "^16.1.6",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-prettier": "^5.5.5",
|
||||||
|
"prettier": "^3.8.1",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"typescript": "~5.8.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6.3c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.4zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.986 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957a145.95 145.95 0 0 1 7.055 2.218c16.463 5.669 39.293 16.77 39.293 30.09c0 14.143-24.44 25.676-41.544 30.736Zm-19.657-75.06c-2.894 9.362-6.825 19.357-11.69 29.7c-4.75-10.07-9.975-19.79-15.646-28.854a193.287 193.287 0 0 1 27.336-.847ZM128.073 197.754c-5.768-6.455-11.407-13.6-16.834-21.294c5.394.256 10.905.393 16.517.393c5.776 0 11.456-.152 16.996-.438c-5.357 7.562-10.927 14.634-16.68 21.339ZM44.317 126.452c-16.037-5.47-40.063-16.543-40.063-30.834c0-13.397 22.197-24.37 38.51-29.925a145.946 145.946 0 0 1 8.106-2.563c3.175 10.186 7.427 20.983 12.592 32.075c-5.003 10.765-9.147 21.406-12.22 31.518a143.893 143.893 0 0 1-6.925-2.271Zm6.453-61.789c-2.625-14.27-2.164-27.457 1.15-36.15c.907-2.381 2.11-4.277 3.567-5.48c3.455-2.454 11.456-.887 23.527 6.843a141.74 141.74 0 0 1 6.054 4.103c-6.377 6.46-12.596 13.626-18.476 21.35c-7.614 1.165-14.907 2.685-21.822 4.634a167.003 167.003 0 0 1-1.5-5.3h2.5Zm46.997 41.558a193.95 193.95 0 0 1 7.527-13.154a196.028 196.028 0 0 1 8.08-12.284c7.504-.65 15.23-.987 23.095-.987c7.917 0 15.698.342 23.253.997c2.697 3.888 5.369 7.93 7.939 12.085c2.68 4.328 5.13 8.648 7.362 12.917a192.632 192.632 0 0 1-7.427 13.094a199.203 199.203 0 0 1-8.108 12.35c-7.456.617-15.152.946-23.019.946c-7.9 0-15.623-.335-23.139-.963a195.966 195.966 0 0 1-7.99-12.284a191.428 191.428 0 0 1-7.573-12.917Zm64.094 35.573c3.064 5.652 5.832 11.19 8.263 16.525c-5.863 1.468-12.256 2.635-19.082 3.478c2.384-3.514 4.73-7.17 7.006-10.945c1.274-2.292 2.532-4.654 3.813-7.058ZM91.29 137.095c-2.404-4.287-4.635-8.503-6.705-12.614c1.925-4.032 4.039-8.119 6.342-12.247a195.328 195.328 0 0 1 7.212-11.718c5.763-.803 11.852-1.316 18.187-1.539a196.41 196.41 0 0 0-7.183 13.131a195.928 195.928 0 0 0-7.224 13.139c-3.743.744-7.337 1.588-10.824 2.476l.195 1.372Zm7.283 25.699c6.745.864 13.894 1.435 21.341 1.691a195.79 195.79 0 0 0 7.422-13.362a195.47 195.47 0 0 0 6.892-12.922c3.86.72 7.556 1.501 11.044 2.354c-2.478 5.477-5.286 11.148-8.355 16.899c-2.33 4.352-4.765 8.549-7.278 12.559c-5.685.83-11.777 1.376-18.177 1.617a196.293 196.293 0 0 1-12.889-8.836Zm28.058-22.285c2.653-5.12 5.046-10.14 7.167-15.005c2.116 4.86 4.502 9.873 7.151 14.988a193.87 193.87 0 0 1-14.318.017ZM128.681 28.72c5.692 6.243 11.295 13.183 16.697 20.693c-5.347-.26-10.803-.394-16.34-.394c-5.673 0-11.261.143-16.734.417c5.372-7.63 10.904-14.631 16.377-20.716ZM40.95 13.571c.558-.323 1.232-.52 2.012-.52c3.238 0 8.647 2.49 16.546 8.468a141.093 141.093 0 0 1 3.853 3.128c-6.371 6.449-12.57 13.597-18.422 21.29c-7.604 1.157-14.846 2.662-21.697 4.587c-1.27-5.418-2.033-10.596-2.303-15.374c-.558-9.883 1.143-17.09 4.217-18.866l15.794-.713ZM64.245 205.64c-1.076.621-2.386.928-3.892.928c-6.281 0-15.693-5.663-25.583-14.898a142.675 142.675 0 0 1-5.676-5.529c6.428-6.558 13.248-13.617 20.267-20.935c7.752-1.249 15.56-2.828 23.215-4.684c1.358 5.566 2.244 10.85 2.654 15.78c1.09 13.095-2.107 23.44-6.598 26.187l-4.387 3.15ZM192.872 202.6c-6.281 0-15.693-5.663-25.582-14.898a142.673 142.673 0 0 1-5.677-5.53c6.428-6.557 13.248-13.616 20.267-20.934c7.752-1.249 15.56-2.828 23.215-4.684c1.358 5.566 2.244 10.85 2.654 15.78c1.09 13.095-2.107 23.44-6.598 26.187c-1.076.622-2.386.928-3.892.928l-4.387 3.15ZM128.073 94.831a18.837 18.837 0 1 1 0 37.674a18.837 18.837 0 0 1 0-37.674Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 4.4 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
|
||||||
|
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
# Generated by Cargo
|
||||||
|
# will have compiled files and executables
|
||||||
|
/target/
|
||||||
|
|
||||||
|
# Generated by Tauri
|
||||||
|
# will have schema files for capabilities auto-completion
|
||||||
|
/gen/schemas
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
[package]
|
||||||
|
name = "codeg"
|
||||||
|
version = "0.0.13"
|
||||||
|
description = "Agent Code Generation App"
|
||||||
|
authors = ["feitao"]
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
# The `_lib` suffix may seem redundant but it is necessary
|
||||||
|
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||||
|
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||||
|
name = "codeg_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
tauri-plugin-opener = "2"
|
||||||
|
tauri-plugin-dialog = "2"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
thiserror = "2"
|
||||||
|
dirs = "6"
|
||||||
|
walkdir = "2"
|
||||||
|
sacp = "11.0.0-alpha.1"
|
||||||
|
sacp-tokio = "11.0.0-alpha.1"
|
||||||
|
tokio = { version = "1", features = ["process", "io-util", "sync", "macros", "rt"] }
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
futures = "0.3"
|
||||||
|
reqwest = { version = "0.12", features = ["stream"] }
|
||||||
|
flate2 = "1"
|
||||||
|
bzip2 = "0.5"
|
||||||
|
tar = "0.4"
|
||||||
|
zip = "2"
|
||||||
|
regex = "1"
|
||||||
|
portable-pty = "0.8"
|
||||||
|
urlencoding = "2"
|
||||||
|
fix-path-env = { git = "https://github.com/tauri-apps/fix-path-env-rs", version = "0.0.0" }
|
||||||
|
sea-orm = { version = "1.1", features = ["sqlx-sqlite", "runtime-tokio-rustls", "macros"] }
|
||||||
|
sea-orm-migration = { version = "1.1", features = ["sqlx-sqlite", "runtime-tokio-rustls"] }
|
||||||
|
toml = "0.8"
|
||||||
|
notify = "6"
|
||||||
|
|
||||||
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
|
tauri-plugin-window-state = "2"
|
||||||
|
tauri-plugin-updater = "2"
|
||||||
|
tauri-plugin-process = "2"
|
||||||
|
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
|
windows-sys = { version = "0.59", features = ["Win32_Storage_FileSystem"] }
|
||||||
|
|
||||||
|
[patch.crates-io]
|
||||||
|
sacp-tokio = { path = "vendor/sacp-tokio" }
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Capability for the main window",
|
||||||
|
"windows": ["welcome", "folder-*", "commit-*", "settings"],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"core:window:default",
|
||||||
|
"core:window:allow-start-dragging",
|
||||||
|
"core:window:allow-close",
|
||||||
|
"core:window:allow-minimize",
|
||||||
|
"core:window:allow-toggle-maximize",
|
||||||
|
"core:window:allow-unmaximize",
|
||||||
|
"opener:default",
|
||||||
|
{
|
||||||
|
"identifier": "opener:allow-open-path",
|
||||||
|
"allow": [
|
||||||
|
{
|
||||||
|
"path": "$HOME/**"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"dialog:default",
|
||||||
|
"updater:default",
|
||||||
|
"process:default"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"identifier": "desktop-capability",
|
||||||
|
"platforms": [
|
||||||
|
"macOS",
|
||||||
|
"windows",
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"windows": [
|
||||||
|
"welcome",
|
||||||
|
"folder-*",
|
||||||
|
"commit-*",
|
||||||
|
"settings"
|
||||||
|
],
|
||||||
|
"permissions": [
|
||||||
|
"window-state:default"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 974 B |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 903 B |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,417 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
|
use std::io::Read;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::acp::error::AcpError;
|
||||||
|
use crate::acp::registry;
|
||||||
|
use crate::models::agent::AgentType;
|
||||||
|
|
||||||
|
pub(crate) fn cache_dir() -> Result<PathBuf, AcpError> {
|
||||||
|
let base = dirs::cache_dir()
|
||||||
|
.ok_or_else(|| AcpError::DownloadFailed("cannot determine cache directory".into()))?;
|
||||||
|
Ok(base.join("app.codeg").join("acp-binaries"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_version_label(version: &str) -> String {
|
||||||
|
let trimmed = version.trim();
|
||||||
|
if let Some(stripped) = trimmed
|
||||||
|
.strip_prefix('v')
|
||||||
|
.or_else(|| trimmed.strip_prefix('V'))
|
||||||
|
{
|
||||||
|
stripped.trim().to_string()
|
||||||
|
} else {
|
||||||
|
trimmed.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn agent_cache_key(agent_type: AgentType) -> String {
|
||||||
|
registry::registry_id_for(agent_type).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn binary_dir(agent_id: &str, version: &str) -> Result<PathBuf, AcpError> {
|
||||||
|
let version = normalize_version_label(version);
|
||||||
|
if version.is_empty() {
|
||||||
|
return Err(AcpError::DownloadFailed(
|
||||||
|
"binary version is empty".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(cache_dir()?
|
||||||
|
.join(agent_id)
|
||||||
|
.join(version)
|
||||||
|
.join(registry::current_platform()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_agent_cache(agent_type: AgentType) -> Result<(), AcpError> {
|
||||||
|
let agent_id = agent_cache_key(agent_type);
|
||||||
|
let dir = cache_dir()?.join(agent_id);
|
||||||
|
if dir.exists() {
|
||||||
|
std::fs::remove_dir_all(&dir)
|
||||||
|
.map_err(|e| AcpError::DownloadFailed(format!("failed to clear cache: {e}")))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn installed_binary_path(agent_id: &str, version: &str, cmd_name: &str) -> Option<PathBuf> {
|
||||||
|
let bin_name = if cfg!(target_os = "windows") {
|
||||||
|
format!("{cmd_name}.exe")
|
||||||
|
} else {
|
||||||
|
cmd_name.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let normalized = normalize_version_label(version);
|
||||||
|
if normalized.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = cache_dir()
|
||||||
|
.ok()?
|
||||||
|
.join(agent_id)
|
||||||
|
.join(normalized)
|
||||||
|
.join(registry::current_platform())
|
||||||
|
.join(bin_name);
|
||||||
|
|
||||||
|
if !path.exists() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if is_binary_file_compatible(path.as_path()) {
|
||||||
|
return Some(path);
|
||||||
|
}
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn installed_version_labels(agent_id: &str, cmd_name: &str) -> Result<Vec<String>, AcpError> {
|
||||||
|
let root = cache_dir()?.join(agent_id);
|
||||||
|
if !root.exists() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut versions = Vec::new();
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
let entries = std::fs::read_dir(&root)
|
||||||
|
.map_err(|e| AcpError::DownloadFailed(format!("failed to read cache dir: {e}")))?;
|
||||||
|
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if !path.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let raw_version = entry.file_name().to_string_lossy().to_string();
|
||||||
|
let normalized = normalize_version_label(&raw_version);
|
||||||
|
if normalized.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if installed_binary_path(agent_id, &normalized, cmd_name).is_some()
|
||||||
|
&& seen.insert(normalized.clone())
|
||||||
|
{
|
||||||
|
versions.push(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(versions)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn installed_version_for_agent(
|
||||||
|
agent_type: AgentType,
|
||||||
|
cmd_name: &str,
|
||||||
|
) -> Result<Option<String>, AcpError> {
|
||||||
|
let agent_id = agent_cache_key(agent_type);
|
||||||
|
let mut versions = installed_version_labels(&agent_id, cmd_name)?;
|
||||||
|
if versions.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
versions.sort_by(|a, b| version_cmp(a, b));
|
||||||
|
Ok(versions.pop())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn detect_installed_version(
|
||||||
|
agent_type: AgentType,
|
||||||
|
cmd_name: &str,
|
||||||
|
) -> Result<Option<String>, AcpError> {
|
||||||
|
installed_version_for_agent(agent_type, cmd_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn version_cmp(a: &str, b: &str) -> std::cmp::Ordering {
|
||||||
|
let mut a_parts = parse_version_parts(a);
|
||||||
|
let mut b_parts = parse_version_parts(b);
|
||||||
|
let len = a_parts.len().max(b_parts.len());
|
||||||
|
a_parts.resize(len, 0);
|
||||||
|
b_parts.resize(len, 0);
|
||||||
|
|
||||||
|
for i in 0..len {
|
||||||
|
match a_parts[i].cmp(&b_parts[i]) {
|
||||||
|
std::cmp::Ordering::Equal => continue,
|
||||||
|
order => return order,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.cmp(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_version_parts(input: &str) -> Vec<u32> {
|
||||||
|
input
|
||||||
|
.trim_start_matches(|c: char| !c.is_ascii_digit())
|
||||||
|
.split('.')
|
||||||
|
.map(|part| {
|
||||||
|
let numeric: String = part.chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||||
|
numeric.parse::<u32>().unwrap_or(0)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure a binary agent is available locally.
|
||||||
|
/// Returns the absolute path to the executable.
|
||||||
|
pub async fn ensure_binary_for_agent(
|
||||||
|
agent_type: AgentType,
|
||||||
|
version: &str,
|
||||||
|
archive_url: &str,
|
||||||
|
cmd_name: &str,
|
||||||
|
) -> Result<PathBuf, AcpError> {
|
||||||
|
if let Some(path) = find_cached_binary_for_agent(agent_type, version, cmd_name)? {
|
||||||
|
return Ok(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let agent_id = agent_cache_key(agent_type);
|
||||||
|
ensure_binary(&agent_id, version, archive_url, cmd_name).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure a binary is available for a specific cache key.
|
||||||
|
/// Returns the absolute path to the executable.
|
||||||
|
pub async fn ensure_binary(
|
||||||
|
agent_id: &str,
|
||||||
|
version: &str,
|
||||||
|
archive_url: &str,
|
||||||
|
cmd_name: &str,
|
||||||
|
) -> Result<PathBuf, AcpError> {
|
||||||
|
if let Some(path) = find_cached_binary(agent_id, version, cmd_name)? {
|
||||||
|
return Ok(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let dir = binary_dir(agent_id, version)?;
|
||||||
|
let bin_name = if cfg!(target_os = "windows") {
|
||||||
|
format!("{cmd_name}.exe")
|
||||||
|
} else {
|
||||||
|
cmd_name.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Download and extract
|
||||||
|
std::fs::create_dir_all(&dir)
|
||||||
|
.map_err(|e| AcpError::DownloadFailed(format!("failed to create cache dir: {e}")))?;
|
||||||
|
|
||||||
|
let tmp_dir = dir.join(".tmp");
|
||||||
|
if tmp_dir.exists() {
|
||||||
|
let _ = std::fs::remove_dir_all(&tmp_dir);
|
||||||
|
}
|
||||||
|
std::fs::create_dir_all(&tmp_dir)
|
||||||
|
.map_err(|e| AcpError::DownloadFailed(format!("failed to create tmp dir: {e}")))?;
|
||||||
|
|
||||||
|
let result: Result<PathBuf, AcpError> = async {
|
||||||
|
let archive_path = tmp_dir.join("archive");
|
||||||
|
download_file(archive_url, &archive_path).await?;
|
||||||
|
|
||||||
|
let extract_dir = tmp_dir.join("extracted");
|
||||||
|
std::fs::create_dir_all(&extract_dir)
|
||||||
|
.map_err(|e| AcpError::DownloadFailed(format!("failed to create extract dir: {e}")))?;
|
||||||
|
|
||||||
|
if archive_url.ends_with(".tar.gz") || archive_url.ends_with(".tgz") {
|
||||||
|
extract_tar_gz(&archive_path, &extract_dir)?;
|
||||||
|
} else if archive_url.ends_with(".tar.bz2") || archive_url.ends_with(".tbz2") {
|
||||||
|
extract_tar_bz2(&archive_path, &extract_dir)?;
|
||||||
|
} else if archive_url.ends_with(".zip") {
|
||||||
|
extract_zip(&archive_path, &extract_dir)?;
|
||||||
|
} else {
|
||||||
|
return Err(AcpError::DownloadFailed(format!(
|
||||||
|
"unsupported archive format: {archive_url}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the binary in extracted files and move to final location.
|
||||||
|
let extracted_bin = find_binary_recursive(&extract_dir, &bin_name).ok_or_else(|| {
|
||||||
|
AcpError::DownloadFailed(format!("binary '{bin_name}' not found in archive"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let final_path = dir.join(&bin_name);
|
||||||
|
std::fs::copy(&extracted_bin, &final_path)
|
||||||
|
.map_err(|e| AcpError::DownloadFailed(format!("failed to copy binary: {e}")))?;
|
||||||
|
|
||||||
|
if !is_binary_file_compatible(&final_path) {
|
||||||
|
let _ = std::fs::remove_file(&final_path);
|
||||||
|
return Err(AcpError::DownloadFailed(
|
||||||
|
"downloaded binary format is invalid for current platform".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
set_executable_permissions(&final_path)?;
|
||||||
|
Ok(final_path)
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Always clean up temp extraction artifacts.
|
||||||
|
let _ = std::fs::remove_dir_all(&tmp_dir);
|
||||||
|
if result.is_err() {
|
||||||
|
// Avoid leaving empty version/platform directories on failed downloads.
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn find_cached_binary(
|
||||||
|
agent_id: &str,
|
||||||
|
version: &str,
|
||||||
|
cmd_name: &str,
|
||||||
|
) -> Result<Option<PathBuf>, AcpError> {
|
||||||
|
Ok(installed_binary_path(agent_id, version, cmd_name))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn find_cached_binary_for_agent(
|
||||||
|
agent_type: AgentType,
|
||||||
|
version: &str,
|
||||||
|
cmd_name: &str,
|
||||||
|
) -> Result<Option<PathBuf>, AcpError> {
|
||||||
|
let agent_id = agent_cache_key(agent_type);
|
||||||
|
find_cached_binary(&agent_id, version, cmd_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn find_binary_recursive(dir: &PathBuf, name: &str) -> Option<PathBuf> {
|
||||||
|
if !dir.exists() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
for entry in walkdir::WalkDir::new(dir).into_iter().flatten() {
|
||||||
|
if entry.file_type().is_file() && entry.file_name().to_string_lossy() == name {
|
||||||
|
return Some(entry.into_path());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn download_file(url: &str, dest: &PathBuf) -> Result<(), AcpError> {
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.get(url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AcpError::DownloadFailed(format!("HTTP request failed: {e}")))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(AcpError::DownloadFailed(format!(
|
||||||
|
"HTTP {} for {url}",
|
||||||
|
response.status()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = response
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AcpError::DownloadFailed(format!("failed to read response: {e}")))?;
|
||||||
|
|
||||||
|
std::fs::write(dest, &bytes)
|
||||||
|
.map_err(|e| AcpError::DownloadFailed(format!("failed to write archive: {e}")))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_tar_gz(archive: &PathBuf, dest: &PathBuf) -> Result<(), AcpError> {
|
||||||
|
let file = std::fs::File::open(archive)
|
||||||
|
.map_err(|e| AcpError::DownloadFailed(format!("failed to open archive: {e}")))?;
|
||||||
|
let gz = flate2::read::GzDecoder::new(file);
|
||||||
|
let mut tar = tar::Archive::new(gz);
|
||||||
|
tar.unpack(dest)
|
||||||
|
.map_err(|e| AcpError::DownloadFailed(format!("failed to extract tar.gz: {e}")))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_tar_bz2(archive: &PathBuf, dest: &PathBuf) -> Result<(), AcpError> {
|
||||||
|
let file = std::fs::File::open(archive)
|
||||||
|
.map_err(|e| AcpError::DownloadFailed(format!("failed to open archive: {e}")))?;
|
||||||
|
let bz = bzip2::read::BzDecoder::new(file);
|
||||||
|
let mut tar = tar::Archive::new(bz);
|
||||||
|
tar.unpack(dest)
|
||||||
|
.map_err(|e| AcpError::DownloadFailed(format!("failed to extract tar.bz2: {e}")))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_zip(archive: &PathBuf, dest: &PathBuf) -> Result<(), AcpError> {
|
||||||
|
let file = std::fs::File::open(archive)
|
||||||
|
.map_err(|e| AcpError::DownloadFailed(format!("failed to open archive: {e}")))?;
|
||||||
|
let mut zip = zip::ZipArchive::new(file)
|
||||||
|
.map_err(|e| AcpError::DownloadFailed(format!("failed to read zip: {e}")))?;
|
||||||
|
zip.extract(dest)
|
||||||
|
.map_err(|e| AcpError::DownloadFailed(format!("failed to extract zip: {e}")))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_executable_permissions(path: &Path) -> Result<(), AcpError> {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let mut perms = std::fs::metadata(path)
|
||||||
|
.map_err(|e| AcpError::DownloadFailed(e.to_string()))?
|
||||||
|
.permissions();
|
||||||
|
perms.set_mode(0o755);
|
||||||
|
std::fs::set_permissions(path, perms).map_err(|e| AcpError::DownloadFailed(e.to_string()))
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
{
|
||||||
|
let _ = path;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_binary_file_compatible(path: &Path) -> bool {
|
||||||
|
let mut file = match std::fs::File::open(path) {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
let mut header = [0_u8; 4];
|
||||||
|
if file.read_exact(&mut header).is_err() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
matches!(
|
||||||
|
header,
|
||||||
|
[0xFE, 0xED, 0xFA, 0xCE]
|
||||||
|
| [0xCE, 0xFA, 0xED, 0xFE]
|
||||||
|
| [0xFE, 0xED, 0xFA, 0xCF]
|
||||||
|
| [0xCF, 0xFA, 0xED, 0xFE]
|
||||||
|
| [0xCA, 0xFE, 0xBA, 0xBE]
|
||||||
|
| [0xBE, 0xBA, 0xFE, 0xCA]
|
||||||
|
| [0xCA, 0xFE, 0xBA, 0xBF]
|
||||||
|
| [0xBF, 0xBA, 0xFE, 0xCA]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
header == [0x7F, b'E', b'L', b'F']
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
header[0] == b'M' && header[1] == b'Z'
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||||
|
{
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cache_key_uses_registry_id() {
|
||||||
|
assert_eq!(agent_cache_key(AgentType::OpenCode), "opencode");
|
||||||
|
assert_eq!(agent_cache_key(AgentType::Codex), "codex-acp");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn version_normalization_is_consistent() {
|
||||||
|
assert_eq!(normalize_version_label("v1.2.15"), "1.2.15");
|
||||||
|
assert_eq!(normalize_version_label("V0.9.4 "), "0.9.4");
|
||||||
|
assert_eq!(normalize_version_label("1.25.1"), "1.25.1");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AcpError {
|
||||||
|
#[error("agent process failed to spawn: {0}")]
|
||||||
|
SpawnFailed(String),
|
||||||
|
#[error("connection not found: {0}")]
|
||||||
|
ConnectionNotFound(String),
|
||||||
|
#[error("ACP protocol error: {0}")]
|
||||||
|
Protocol(String),
|
||||||
|
#[error("agent process exited unexpectedly")]
|
||||||
|
ProcessExited,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[error("conversation error: {0}")]
|
||||||
|
Conversation(String),
|
||||||
|
#[error("binary download failed: {0}")]
|
||||||
|
DownloadFailed(String),
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[error("agent not found: {0}")]
|
||||||
|
AgentNotFound(String),
|
||||||
|
#[error("platform not supported: {0}")]
|
||||||
|
PlatformNotSupported(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AcpError {
|
||||||
|
pub fn protocol(raw: impl Into<String>) -> Self {
|
||||||
|
let raw = raw.into();
|
||||||
|
let sanitized = sanitize_protocol_message(&raw);
|
||||||
|
|
||||||
|
if is_executable_format_error(&sanitized) {
|
||||||
|
return Self::Protocol(
|
||||||
|
"Agent executable appears incompatible or corrupted. Please retry to re-download it."
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Self::Protocol(sanitized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serialize for AcpError {
|
||||||
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: serde::Serializer,
|
||||||
|
{
|
||||||
|
serializer.serialize_str(&self.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_protocol_message(raw: &str) -> String {
|
||||||
|
let without_spawned_at = regex::Regex::new(r#"\s*,?\s*"spawned_at"\s*:\s*"[^"]*"\s*,?"#)
|
||||||
|
.ok()
|
||||||
|
.map(|re| re.replace_all(raw, "").into_owned())
|
||||||
|
.unwrap_or_else(|| raw.to_string());
|
||||||
|
|
||||||
|
let without_dangling_comma = regex::Regex::new(r#",\s*([}\]])"#)
|
||||||
|
.ok()
|
||||||
|
.map(|re| re.replace_all(&without_spawned_at, "$1").into_owned())
|
||||||
|
.unwrap_or(without_spawned_at);
|
||||||
|
|
||||||
|
regex::Regex::new(r#"/(?:Users|home)/[^"\s]+"#)
|
||||||
|
.ok()
|
||||||
|
.map(|re| {
|
||||||
|
re.replace_all(&without_dangling_comma, "<local-path>")
|
||||||
|
.into_owned()
|
||||||
|
})
|
||||||
|
.unwrap_or(without_dangling_comma)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_executable_format_error(message: &str) -> bool {
|
||||||
|
let lowered = message.to_lowercase();
|
||||||
|
lowered.contains("malformed mach-o file")
|
||||||
|
|| lowered.contains("exec format error")
|
||||||
|
|| lowered.contains("bad cpu type in executable")
|
||||||
|
|| lowered.contains("not a valid win32 application")
|
||||||
|
|| lowered.contains("is not a valid application for this os platform")
|
||||||
|
}
|
||||||
@@ -0,0 +1,512 @@
|
|||||||
|
use std::fs::{File, OpenOptions};
|
||||||
|
use std::io::{BufRead, BufReader, ErrorKind, Write};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use sacp::schema::{
|
||||||
|
ReadTextFileRequest, ReadTextFileResponse, WriteTextFileRequest, WriteTextFileResponse,
|
||||||
|
};
|
||||||
|
use tokio::sync::Semaphore;
|
||||||
|
|
||||||
|
const FS_MAX_CONCURRENT_OPS: usize = 8;
|
||||||
|
const FS_IO_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
const FS_MAX_FILE_SIZE_BYTES: u64 = 16 * 1024 * 1024;
|
||||||
|
const FS_MAX_READ_RESPONSE_BYTES: usize = 2 * 1024 * 1024;
|
||||||
|
const FS_MAX_WRITE_BYTES: usize = 2 * 1024 * 1024;
|
||||||
|
const FS_SLOW_OPERATION_MS: u128 = 200;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum FileSystemRuntimeError {
|
||||||
|
InvalidParams(String),
|
||||||
|
Internal(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileSystemRuntimeError {
|
||||||
|
pub fn to_rpc_error(self) -> sacp::Error {
|
||||||
|
match self {
|
||||||
|
Self::InvalidParams(message) => sacp::Error::invalid_params().data(message),
|
||||||
|
Self::Internal(message) => sacp::util::internal_error(message),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct FileSystemRuntime {
|
||||||
|
workspace_root: PathBuf,
|
||||||
|
workspace_root_canonical: Option<PathBuf>,
|
||||||
|
io_semaphore: Arc<Semaphore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileSystemRuntime {
|
||||||
|
pub fn new(workspace_root: PathBuf) -> Self {
|
||||||
|
let workspace_root = if workspace_root.is_absolute() {
|
||||||
|
workspace_root
|
||||||
|
} else {
|
||||||
|
std::env::current_dir()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.join(workspace_root)
|
||||||
|
};
|
||||||
|
let workspace_root_canonical = std::fs::canonicalize(&workspace_root).ok();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
workspace_root,
|
||||||
|
workspace_root_canonical,
|
||||||
|
io_semaphore: Arc::new(Semaphore::new(FS_MAX_CONCURRENT_OPS)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn read_text_file(
|
||||||
|
&self,
|
||||||
|
request: ReadTextFileRequest,
|
||||||
|
) -> Result<ReadTextFileResponse, FileSystemRuntimeError> {
|
||||||
|
if !request.path.is_absolute() {
|
||||||
|
return Err(FileSystemRuntimeError::InvalidParams(
|
||||||
|
"fs/read_text_file requires an absolute path".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if matches!(request.line, Some(0)) {
|
||||||
|
return Err(FileSystemRuntimeError::InvalidParams(
|
||||||
|
"fs/read_text_file line must be >= 1".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let _permit = self
|
||||||
|
.io_semaphore
|
||||||
|
.clone()
|
||||||
|
.acquire_owned()
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
FileSystemRuntimeError::Internal("filesystem runtime closed".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let workspace_root = self.workspace_root.clone();
|
||||||
|
let workspace_root_canonical = self.workspace_root_canonical.clone();
|
||||||
|
let path = request.path;
|
||||||
|
let line = request.line;
|
||||||
|
let limit = request.limit;
|
||||||
|
let started_at = Instant::now();
|
||||||
|
let path_for_log = path.clone();
|
||||||
|
|
||||||
|
let response = run_blocking_with_timeout("fs/read_text_file", move || {
|
||||||
|
read_text_file_impl(
|
||||||
|
&path,
|
||||||
|
line,
|
||||||
|
limit,
|
||||||
|
&workspace_root,
|
||||||
|
workspace_root_canonical.as_deref(),
|
||||||
|
)
|
||||||
|
.map(ReadTextFileResponse::new)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
log_if_slow("fs/read_text_file", &path_for_log, started_at);
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn write_text_file(
|
||||||
|
&self,
|
||||||
|
request: WriteTextFileRequest,
|
||||||
|
) -> Result<WriteTextFileResponse, FileSystemRuntimeError> {
|
||||||
|
if !request.path.is_absolute() {
|
||||||
|
return Err(FileSystemRuntimeError::InvalidParams(
|
||||||
|
"fs/write_text_file requires an absolute path".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if request.content.len() > FS_MAX_WRITE_BYTES {
|
||||||
|
return Err(FileSystemRuntimeError::InvalidParams(format!(
|
||||||
|
"write payload too large ({} bytes, limit {} bytes)",
|
||||||
|
request.content.len(),
|
||||||
|
FS_MAX_WRITE_BYTES
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let _permit = self
|
||||||
|
.io_semaphore
|
||||||
|
.clone()
|
||||||
|
.acquire_owned()
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
FileSystemRuntimeError::Internal("filesystem runtime closed".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let workspace_root = self.workspace_root.clone();
|
||||||
|
let workspace_root_canonical = self.workspace_root_canonical.clone();
|
||||||
|
let path = request.path;
|
||||||
|
let content = request.content;
|
||||||
|
let started_at = Instant::now();
|
||||||
|
let path_for_log = path.clone();
|
||||||
|
|
||||||
|
let response = run_blocking_with_timeout("fs/write_text_file", move || {
|
||||||
|
ensure_path_in_workspace(
|
||||||
|
&path,
|
||||||
|
&workspace_root,
|
||||||
|
workspace_root_canonical.as_deref(),
|
||||||
|
true,
|
||||||
|
)?;
|
||||||
|
atomic_write_text(&path, content.as_bytes())?;
|
||||||
|
Ok(WriteTextFileResponse::new())
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
log_if_slow("fs/write_text_file", &path_for_log, started_at);
|
||||||
|
response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_blocking_with_timeout<T, F>(
|
||||||
|
operation: &'static str,
|
||||||
|
f: F,
|
||||||
|
) -> Result<T, FileSystemRuntimeError>
|
||||||
|
where
|
||||||
|
T: Send + 'static,
|
||||||
|
F: FnOnce() -> Result<T, FileSystemRuntimeError> + Send + 'static,
|
||||||
|
{
|
||||||
|
let task = tokio::task::spawn_blocking(f);
|
||||||
|
let join_result = tokio::time::timeout(FS_IO_TIMEOUT, task)
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
FileSystemRuntimeError::Internal(format!(
|
||||||
|
"{operation} timed out after {}s",
|
||||||
|
FS_IO_TIMEOUT.as_secs()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let op_result = join_result.map_err(|err| {
|
||||||
|
FileSystemRuntimeError::Internal(format!("{operation} worker failed: {err}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
op_result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_text_file_impl(
|
||||||
|
path: &Path,
|
||||||
|
line: Option<u32>,
|
||||||
|
limit: Option<u32>,
|
||||||
|
workspace_root: &Path,
|
||||||
|
workspace_root_canonical: Option<&Path>,
|
||||||
|
) -> Result<String, FileSystemRuntimeError> {
|
||||||
|
ensure_path_in_workspace(path, workspace_root, workspace_root_canonical, false)?;
|
||||||
|
|
||||||
|
let metadata = std::fs::metadata(path).map_err(|err| map_io_error("read", path, err))?;
|
||||||
|
if metadata.len() > FS_MAX_FILE_SIZE_BYTES {
|
||||||
|
return Err(FileSystemRuntimeError::InvalidParams(format!(
|
||||||
|
"file too large for fs/read_text_file ({} bytes, limit {} bytes)",
|
||||||
|
metadata.len(),
|
||||||
|
FS_MAX_FILE_SIZE_BYTES
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let file = File::open(path).map_err(|err| map_io_error("read", path, err))?;
|
||||||
|
let mut reader = BufReader::new(file);
|
||||||
|
|
||||||
|
let start_line = usize::try_from(line.unwrap_or(1)).unwrap_or(usize::MAX);
|
||||||
|
let max_lines = limit.map(|v| usize::try_from(v).unwrap_or(usize::MAX));
|
||||||
|
|
||||||
|
let mut current_line = 1usize;
|
||||||
|
let mut taken = 0usize;
|
||||||
|
let mut out = String::with_capacity(
|
||||||
|
usize::try_from(metadata.len())
|
||||||
|
.unwrap_or(FS_MAX_READ_RESPONSE_BYTES)
|
||||||
|
.min(FS_MAX_READ_RESPONSE_BYTES),
|
||||||
|
);
|
||||||
|
let mut line_buf = String::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
line_buf.clear();
|
||||||
|
let bytes_read = reader
|
||||||
|
.read_line(&mut line_buf)
|
||||||
|
.map_err(|err| map_io_error("read", path, err))?;
|
||||||
|
if bytes_read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if current_line >= start_line {
|
||||||
|
if let Some(max) = max_lines {
|
||||||
|
if taken >= max {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if out.len().saturating_add(line_buf.len()) > FS_MAX_READ_RESPONSE_BYTES {
|
||||||
|
return Err(FileSystemRuntimeError::InvalidParams(format!(
|
||||||
|
"read result too large (limit {} bytes). Narrow with line/limit.",
|
||||||
|
FS_MAX_READ_RESPONSE_BYTES
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push_str(&line_buf);
|
||||||
|
taken += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
current_line = current_line.saturating_add(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn atomic_write_text(path: &Path, bytes: &[u8]) -> Result<(), FileSystemRuntimeError> {
|
||||||
|
let parent = path.parent().ok_or_else(|| {
|
||||||
|
FileSystemRuntimeError::InvalidParams(format!(
|
||||||
|
"cannot determine parent directory for path: {}",
|
||||||
|
path.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !parent.exists() {
|
||||||
|
return Err(FileSystemRuntimeError::InvalidParams(format!(
|
||||||
|
"parent directory does not exist: {}",
|
||||||
|
parent.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let temp_path = parent.join(format!(
|
||||||
|
".codeg-fs-{}.{}.tmp",
|
||||||
|
std::process::id(),
|
||||||
|
uuid::Uuid::new_v4().simple()
|
||||||
|
));
|
||||||
|
|
||||||
|
let existing_permissions = std::fs::metadata(path).ok().map(|m| m.permissions());
|
||||||
|
|
||||||
|
let write_result = (|| {
|
||||||
|
let mut tmp = OpenOptions::new()
|
||||||
|
.create_new(true)
|
||||||
|
.write(true)
|
||||||
|
.open(&temp_path)
|
||||||
|
.map_err(|err| map_io_error("create temporary file", &temp_path, err))?;
|
||||||
|
|
||||||
|
tmp.write_all(bytes)
|
||||||
|
.map_err(|err| map_io_error("write", &temp_path, err))?;
|
||||||
|
tmp.sync_all()
|
||||||
|
.map_err(|err| map_io_error("flush", &temp_path, err))?;
|
||||||
|
|
||||||
|
if let Some(permissions) = existing_permissions {
|
||||||
|
std::fs::set_permissions(&temp_path, permissions)
|
||||||
|
.map_err(|err| map_io_error("set permissions", &temp_path, err))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
replace_file(&temp_path, path)?;
|
||||||
|
sync_directory(parent)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})();
|
||||||
|
|
||||||
|
if write_result.is_err() {
|
||||||
|
let _ = std::fs::remove_file(&temp_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
write_result
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn replace_file(temp_path: &Path, target_path: &Path) -> Result<(), FileSystemRuntimeError> {
|
||||||
|
std::fs::rename(temp_path, target_path).map_err(|err| map_io_error("replace", target_path, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn replace_file(temp_path: &Path, target_path: &Path) -> Result<(), FileSystemRuntimeError> {
|
||||||
|
use std::os::windows::ffi::OsStrExt;
|
||||||
|
|
||||||
|
use windows_sys::Win32::Storage::FileSystem::{
|
||||||
|
MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn to_wide(path: &Path) -> Vec<u16> {
|
||||||
|
path.as_os_str()
|
||||||
|
.encode_wide()
|
||||||
|
.chain(std::iter::once(0))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
let src = to_wide(temp_path);
|
||||||
|
let dst = to_wide(target_path);
|
||||||
|
|
||||||
|
// SAFETY: pointers are valid, null-terminated UTF-16 buffers alive for the call.
|
||||||
|
let ok = unsafe {
|
||||||
|
MoveFileExW(
|
||||||
|
src.as_ptr(),
|
||||||
|
dst.as_ptr(),
|
||||||
|
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
if ok == 0 {
|
||||||
|
let err = std::io::Error::last_os_error();
|
||||||
|
return Err(map_io_error("atomically replace", target_path, err));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(unix, target_os = "windows")))]
|
||||||
|
fn replace_file(temp_path: &Path, target_path: &Path) -> Result<(), FileSystemRuntimeError> {
|
||||||
|
std::fs::rename(temp_path, target_path).map_err(|err| map_io_error("replace", target_path, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn sync_directory(path: &Path) -> Result<(), FileSystemRuntimeError> {
|
||||||
|
let dir = File::open(path).map_err(|err| map_io_error("sync directory", path, err))?;
|
||||||
|
dir.sync_all()
|
||||||
|
.map_err(|err| map_io_error("sync directory", path, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn sync_directory(_path: &Path) -> Result<(), FileSystemRuntimeError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn canonical_workspace_root(workspace_root: &Path, canonical: Option<&Path>) -> PathBuf {
|
||||||
|
canonical
|
||||||
|
.map(Path::to_path_buf)
|
||||||
|
.or_else(|| std::fs::canonicalize(workspace_root).ok())
|
||||||
|
.unwrap_or_else(|| workspace_root.to_path_buf())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_path_in_workspace(
|
||||||
|
path: &Path,
|
||||||
|
workspace_root: &Path,
|
||||||
|
workspace_root_canonical: Option<&Path>,
|
||||||
|
for_write: bool,
|
||||||
|
) -> Result<(), FileSystemRuntimeError> {
|
||||||
|
let root = canonical_workspace_root(workspace_root, workspace_root_canonical);
|
||||||
|
let target = canonical_target_path(path, for_write)?;
|
||||||
|
|
||||||
|
if !target.starts_with(&root) {
|
||||||
|
return Err(FileSystemRuntimeError::InvalidParams(format!(
|
||||||
|
"path is outside workspace root: {}",
|
||||||
|
path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn canonical_target_path(path: &Path, for_write: bool) -> Result<PathBuf, FileSystemRuntimeError> {
|
||||||
|
if !for_write || path.exists() {
|
||||||
|
return std::fs::canonicalize(path).map_err(|err| map_io_error("access", path, err));
|
||||||
|
}
|
||||||
|
|
||||||
|
let parent = path.parent().ok_or_else(|| {
|
||||||
|
FileSystemRuntimeError::InvalidParams(format!(
|
||||||
|
"cannot determine parent directory for path: {}",
|
||||||
|
path.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !parent.exists() {
|
||||||
|
return Err(FileSystemRuntimeError::InvalidParams(format!(
|
||||||
|
"parent directory does not exist: {}",
|
||||||
|
parent.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::fs::canonicalize(parent).map_err(|err| map_io_error("access", parent, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_io_error(action: &str, path: &Path, err: std::io::Error) -> FileSystemRuntimeError {
|
||||||
|
match err.kind() {
|
||||||
|
ErrorKind::NotFound
|
||||||
|
| ErrorKind::PermissionDenied
|
||||||
|
| ErrorKind::InvalidInput
|
||||||
|
| ErrorKind::InvalidData => FileSystemRuntimeError::InvalidParams(format!(
|
||||||
|
"failed to {action} {}: {err}",
|
||||||
|
path.display()
|
||||||
|
)),
|
||||||
|
_ => FileSystemRuntimeError::Internal(format!(
|
||||||
|
"failed to {action} {}: {err}",
|
||||||
|
path.display()
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log_if_slow(operation: &str, path: &Path, started_at: Instant) {
|
||||||
|
let elapsed = started_at.elapsed();
|
||||||
|
if elapsed.as_millis() >= FS_SLOW_OPERATION_MS {
|
||||||
|
eprintln!(
|
||||||
|
"[ACP] {operation} slow path={} elapsed_ms={}",
|
||||||
|
path.display(),
|
||||||
|
elapsed.as_millis()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
fn temp_workspace() -> PathBuf {
|
||||||
|
let path = std::env::temp_dir().join(format!("codeg-fs-test-{}", uuid::Uuid::new_v4()));
|
||||||
|
fs::create_dir_all(&path).expect("create test workspace");
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn read_honors_line_and_limit() {
|
||||||
|
let workspace = temp_workspace();
|
||||||
|
let file = workspace.join("sample.txt");
|
||||||
|
fs::write(&file, "a\nb\nc\nd\n").expect("write file");
|
||||||
|
|
||||||
|
let runtime = FileSystemRuntime::new(workspace.clone());
|
||||||
|
let response = runtime
|
||||||
|
.read_text_file(ReadTextFileRequest::new("sid", &file).line(2).limit(2))
|
||||||
|
.await
|
||||||
|
.expect("read file");
|
||||||
|
|
||||||
|
assert_eq!(response.content, "b\nc\n");
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(workspace);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn rejects_path_outside_workspace() {
|
||||||
|
let workspace = temp_workspace();
|
||||||
|
let outside = std::env::temp_dir().join(format!("outside-{}.txt", uuid::Uuid::new_v4()));
|
||||||
|
fs::write(&outside, "x").expect("write outside file");
|
||||||
|
|
||||||
|
let runtime = FileSystemRuntime::new(workspace.clone());
|
||||||
|
let error = runtime
|
||||||
|
.read_text_file(ReadTextFileRequest::new("sid", &outside))
|
||||||
|
.await
|
||||||
|
.expect_err("should reject outside path");
|
||||||
|
|
||||||
|
match error {
|
||||||
|
FileSystemRuntimeError::InvalidParams(message) => {
|
||||||
|
assert!(message.contains("outside workspace"));
|
||||||
|
}
|
||||||
|
other => panic!("unexpected error: {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = fs::remove_file(outside);
|
||||||
|
let _ = fs::remove_dir_all(workspace);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn write_replaces_existing_content() {
|
||||||
|
let workspace = temp_workspace();
|
||||||
|
let file = workspace.join("target.txt");
|
||||||
|
fs::write(&file, "old").expect("write old content");
|
||||||
|
|
||||||
|
let runtime = FileSystemRuntime::new(workspace.clone());
|
||||||
|
runtime
|
||||||
|
.write_text_file(WriteTextFileRequest::new("sid", &file, "new-content"))
|
||||||
|
.await
|
||||||
|
.expect("write file");
|
||||||
|
|
||||||
|
let content = fs::read_to_string(&file).expect("read file");
|
||||||
|
assert_eq!(content, "new-content");
|
||||||
|
|
||||||
|
let leaked_tmp = fs::read_dir(&workspace)
|
||||||
|
.expect("read workspace")
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
.any(|entry| {
|
||||||
|
entry
|
||||||
|
.file_name()
|
||||||
|
.to_string_lossy()
|
||||||
|
.starts_with(".codeg-fs-")
|
||||||
|
});
|
||||||
|
assert!(!leaked_tmp, "temporary file should be cleaned up");
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(workspace);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
use crate::acp::connection::{spawn_agent_connection, AgentConnection, ConnectionCommand};
|
||||||
|
use crate::acp::error::AcpError;
|
||||||
|
use crate::acp::types::{ConnectionInfo, PromptInputBlock};
|
||||||
|
use crate::models::agent::AgentType;
|
||||||
|
|
||||||
|
pub struct ConnectionManager {
|
||||||
|
connections: Arc<Mutex<HashMap<String, AgentConnection>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConnectionManager {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
connections: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn spawn_agent(
|
||||||
|
&self,
|
||||||
|
agent_type: AgentType,
|
||||||
|
working_dir: Option<String>,
|
||||||
|
session_id: Option<String>,
|
||||||
|
runtime_env: BTreeMap<String, String>,
|
||||||
|
owner_window_label: String,
|
||||||
|
app_handle: tauri::AppHandle,
|
||||||
|
) -> Result<String, AcpError> {
|
||||||
|
let connection_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
eprintln!(
|
||||||
|
"[ACP] spawning connection id={} owner_window={} agent={:?}",
|
||||||
|
connection_id, owner_window_label, agent_type
|
||||||
|
);
|
||||||
|
|
||||||
|
let conn = spawn_agent_connection(
|
||||||
|
connection_id.clone(),
|
||||||
|
agent_type,
|
||||||
|
working_dir,
|
||||||
|
session_id,
|
||||||
|
runtime_env,
|
||||||
|
owner_window_label,
|
||||||
|
app_handle,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
self.connections
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert(connection_id.clone(), conn);
|
||||||
|
|
||||||
|
Ok(connection_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send_prompt(
|
||||||
|
&self,
|
||||||
|
conn_id: &str,
|
||||||
|
blocks: Vec<PromptInputBlock>,
|
||||||
|
) -> Result<(), AcpError> {
|
||||||
|
let cmd_tx = {
|
||||||
|
let connections = self.connections.lock().await;
|
||||||
|
let conn = connections
|
||||||
|
.get(conn_id)
|
||||||
|
.ok_or_else(|| AcpError::ConnectionNotFound(conn_id.into()))?;
|
||||||
|
conn.cmd_tx.clone()
|
||||||
|
};
|
||||||
|
cmd_tx
|
||||||
|
.send(ConnectionCommand::Prompt { blocks })
|
||||||
|
.await
|
||||||
|
.map_err(|_| AcpError::ProcessExited)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_mode(&self, conn_id: &str, mode_id: String) -> Result<(), AcpError> {
|
||||||
|
let cmd_tx = {
|
||||||
|
let connections = self.connections.lock().await;
|
||||||
|
let conn = connections
|
||||||
|
.get(conn_id)
|
||||||
|
.ok_or_else(|| AcpError::ConnectionNotFound(conn_id.into()))?;
|
||||||
|
conn.cmd_tx.clone()
|
||||||
|
};
|
||||||
|
cmd_tx
|
||||||
|
.send(ConnectionCommand::SetMode { mode_id })
|
||||||
|
.await
|
||||||
|
.map_err(|_| AcpError::ProcessExited)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_config_option(
|
||||||
|
&self,
|
||||||
|
conn_id: &str,
|
||||||
|
config_id: String,
|
||||||
|
value_id: String,
|
||||||
|
) -> Result<(), AcpError> {
|
||||||
|
let cmd_tx = {
|
||||||
|
let connections = self.connections.lock().await;
|
||||||
|
let conn = connections
|
||||||
|
.get(conn_id)
|
||||||
|
.ok_or_else(|| AcpError::ConnectionNotFound(conn_id.into()))?;
|
||||||
|
conn.cmd_tx.clone()
|
||||||
|
};
|
||||||
|
cmd_tx
|
||||||
|
.send(ConnectionCommand::SetConfigOption {
|
||||||
|
config_id,
|
||||||
|
value_id,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| AcpError::ProcessExited)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn cancel(&self, conn_id: &str) -> Result<(), AcpError> {
|
||||||
|
let cmd_tx = {
|
||||||
|
let connections = self.connections.lock().await;
|
||||||
|
let conn = connections
|
||||||
|
.get(conn_id)
|
||||||
|
.ok_or_else(|| AcpError::ConnectionNotFound(conn_id.into()))?;
|
||||||
|
conn.cmd_tx.clone()
|
||||||
|
};
|
||||||
|
cmd_tx
|
||||||
|
.send(ConnectionCommand::Cancel)
|
||||||
|
.await
|
||||||
|
.map_err(|_| AcpError::ProcessExited)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn respond_permission(
|
||||||
|
&self,
|
||||||
|
conn_id: &str,
|
||||||
|
request_id: &str,
|
||||||
|
option_id: &str,
|
||||||
|
) -> Result<(), AcpError> {
|
||||||
|
let cmd_tx = {
|
||||||
|
let connections = self.connections.lock().await;
|
||||||
|
let conn = connections
|
||||||
|
.get(conn_id)
|
||||||
|
.ok_or_else(|| AcpError::ConnectionNotFound(conn_id.into()))?;
|
||||||
|
conn.cmd_tx.clone()
|
||||||
|
};
|
||||||
|
cmd_tx
|
||||||
|
.send(ConnectionCommand::RespondPermission {
|
||||||
|
request_id: request_id.into(),
|
||||||
|
option_id: option_id.into(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| AcpError::ProcessExited)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn disconnect(&self, conn_id: &str) -> Result<(), AcpError> {
|
||||||
|
let cmd_tx = {
|
||||||
|
let mut connections = self.connections.lock().await;
|
||||||
|
connections.remove(conn_id).map(|conn| conn.cmd_tx)
|
||||||
|
};
|
||||||
|
if let Some(cmd_tx) = cmd_tx {
|
||||||
|
let _ = cmd_tx.send(ConnectionCommand::Disconnect).await;
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(AcpError::ConnectionNotFound(conn_id.into()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn disconnect_by_owner_window(&self, owner_window_label: &str) -> usize {
|
||||||
|
let cmd_txs = {
|
||||||
|
let mut connections = self.connections.lock().await;
|
||||||
|
let ids: Vec<String> = connections
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(id, conn)| {
|
||||||
|
if conn.owner_window_label == owner_window_label {
|
||||||
|
Some(id.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut txs = Vec::with_capacity(ids.len());
|
||||||
|
for id in ids {
|
||||||
|
if let Some(conn) = connections.remove(&id) {
|
||||||
|
txs.push(conn.cmd_tx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
txs
|
||||||
|
};
|
||||||
|
|
||||||
|
let disconnected = cmd_txs.len();
|
||||||
|
for cmd_tx in cmd_txs {
|
||||||
|
let _ = cmd_tx.send(ConnectionCommand::Disconnect).await;
|
||||||
|
}
|
||||||
|
eprintln!(
|
||||||
|
"[ACP] disconnect by owner window owner_window={} count={}",
|
||||||
|
owner_window_label, disconnected
|
||||||
|
);
|
||||||
|
disconnected
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_connections(&self) -> Vec<ConnectionInfo> {
|
||||||
|
let connections = self.connections.lock().await;
|
||||||
|
connections.values().map(|c| c.info()).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
pub mod binary_cache;
|
||||||
|
pub mod connection;
|
||||||
|
pub mod error;
|
||||||
|
pub mod file_system_runtime;
|
||||||
|
pub mod manager;
|
||||||
|
pub mod preflight;
|
||||||
|
pub mod registry;
|
||||||
|
pub mod terminal_runtime;
|
||||||
|
pub mod types;
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use crate::acp::binary_cache;
|
||||||
|
use crate::acp::registry::{self, AgentDistribution};
|
||||||
|
use crate::models::agent::AgentType;
|
||||||
|
|
||||||
|
/// Cache for NPX environment check results.
|
||||||
|
/// Stores `Some(checks)` after a successful (all-pass) run;
|
||||||
|
/// stays `None` if checks failed so they are retried next time.
|
||||||
|
static NPX_ENV_CACHE: Mutex<Option<Vec<CheckItem>>> = Mutex::new(None);
|
||||||
|
/// Cache for UVX environment check results.
|
||||||
|
/// Stores `Some(checks)` after a successful (all-pass) run;
|
||||||
|
/// stays `None` if checks failed so they are retried next time.
|
||||||
|
static UVX_ENV_CACHE: Mutex<Option<Vec<CheckItem>>> = Mutex::new(None);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub enum FixActionKind {
|
||||||
|
OpenUrl,
|
||||||
|
RedownloadBinary,
|
||||||
|
RetryConnection,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct FixAction {
|
||||||
|
pub label: String,
|
||||||
|
pub kind: FixActionKind,
|
||||||
|
pub payload: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum CheckStatus {
|
||||||
|
Pass,
|
||||||
|
Fail,
|
||||||
|
Warn,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct CheckItem {
|
||||||
|
pub check_id: String,
|
||||||
|
pub label: String,
|
||||||
|
pub status: CheckStatus,
|
||||||
|
pub message: String,
|
||||||
|
pub fixes: Vec<FixAction>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct PreflightResult {
|
||||||
|
pub agent_type: AgentType,
|
||||||
|
pub agent_name: String,
|
||||||
|
pub passed: bool,
|
||||||
|
pub checks: Vec<CheckItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run_preflight(agent_type: AgentType) -> PreflightResult {
|
||||||
|
let meta = registry::get_agent_meta(agent_type);
|
||||||
|
let checks = match &meta.distribution {
|
||||||
|
AgentDistribution::Npx { node_required, .. } => check_npx_environment(*node_required).await,
|
||||||
|
AgentDistribution::Uvx { .. } => check_uvx_environment().await,
|
||||||
|
AgentDistribution::Binary {
|
||||||
|
version,
|
||||||
|
cmd,
|
||||||
|
platforms,
|
||||||
|
..
|
||||||
|
} => check_binary_environment(agent_type, version, cmd, platforms).await,
|
||||||
|
};
|
||||||
|
|
||||||
|
let passed = checks
|
||||||
|
.iter()
|
||||||
|
.all(|c| !matches!(c.status, CheckStatus::Fail));
|
||||||
|
|
||||||
|
PreflightResult {
|
||||||
|
agent_type,
|
||||||
|
agent_name: meta.name.to_string(),
|
||||||
|
passed,
|
||||||
|
checks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn check_npx_environment(node_required: Option<&str>) -> Vec<CheckItem> {
|
||||||
|
// Return cached result if a previous check passed.
|
||||||
|
// The cache stores only the base checks (node_available + npx_available);
|
||||||
|
// the per-agent node_version check is appended separately.
|
||||||
|
let cached = NPX_ENV_CACHE.lock().unwrap().clone();
|
||||||
|
if let Some(cached) = cached {
|
||||||
|
let mut checks = cached;
|
||||||
|
if let Some(required) = node_required {
|
||||||
|
// Extract node version string from the cached node_available message
|
||||||
|
// (format: "Node.js v20.19.0 available")
|
||||||
|
let node_ver = extract_node_version_from_message(&checks[0].message);
|
||||||
|
checks.push(build_node_version_check(node_ver.as_deref(), required));
|
||||||
|
}
|
||||||
|
return checks;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run node and npx checks in parallel
|
||||||
|
let (node_result, npx_result) = tokio::join!(
|
||||||
|
crate::process::tokio_command("node")
|
||||||
|
.arg("--version")
|
||||||
|
.output(),
|
||||||
|
crate::process::tokio_command("npx")
|
||||||
|
.arg("--version")
|
||||||
|
.output(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Track the raw node version string for reuse in the version check
|
||||||
|
let mut node_version_str: Option<String> = None;
|
||||||
|
|
||||||
|
let node_check = match node_result {
|
||||||
|
Ok(output) if output.status.success() => {
|
||||||
|
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
node_version_str = Some(version.clone());
|
||||||
|
CheckItem {
|
||||||
|
check_id: "node_available".into(),
|
||||||
|
label: "Node.js".into(),
|
||||||
|
status: CheckStatus::Pass,
|
||||||
|
message: format!("Node.js {version} available"),
|
||||||
|
fixes: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => CheckItem {
|
||||||
|
check_id: "node_available".into(),
|
||||||
|
label: "Node.js".into(),
|
||||||
|
status: CheckStatus::Fail,
|
||||||
|
message: "Node.js is not installed or not in PATH".into(),
|
||||||
|
fixes: vec![FixAction {
|
||||||
|
label: "Install Node.js".into(),
|
||||||
|
kind: FixActionKind::OpenUrl,
|
||||||
|
payload: "https://nodejs.org/".into(),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let npx_check = match npx_result {
|
||||||
|
Ok(output) if output.status.success() => {
|
||||||
|
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
CheckItem {
|
||||||
|
check_id: "npx_available".into(),
|
||||||
|
label: "npx".into(),
|
||||||
|
status: CheckStatus::Pass,
|
||||||
|
message: format!("npx {version} available"),
|
||||||
|
fixes: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => CheckItem {
|
||||||
|
check_id: "npx_available".into(),
|
||||||
|
label: "npx".into(),
|
||||||
|
status: CheckStatus::Fail,
|
||||||
|
message: "npx is not installed or not in PATH".into(),
|
||||||
|
fixes: vec![FixAction {
|
||||||
|
label: "Install Node.js".into(),
|
||||||
|
kind: FixActionKind::OpenUrl,
|
||||||
|
payload: "https://nodejs.org/".into(),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut checks = vec![node_check, npx_check];
|
||||||
|
|
||||||
|
// Cache only if all checks passed — failed results are not cached so
|
||||||
|
// the user can retry after installing the missing tools.
|
||||||
|
let all_passed = checks
|
||||||
|
.iter()
|
||||||
|
.all(|c| !matches!(c.status, CheckStatus::Fail));
|
||||||
|
if all_passed {
|
||||||
|
*NPX_ENV_CACHE.lock().unwrap() = Some(checks.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// After caching the base checks, append the per-agent Node.js version
|
||||||
|
// requirement if specified. Only meaningful when node is available.
|
||||||
|
if let Some(required) = node_required {
|
||||||
|
if all_passed {
|
||||||
|
checks.push(build_node_version_check(
|
||||||
|
node_version_str.as_deref(),
|
||||||
|
required,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checks
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a Node.js version string like "v20.19.0" or "20.19.0" into (major, minor, patch).
|
||||||
|
/// Handles pre-release suffixes such as "v22.0.0-nightly" by stripping non-numeric tails.
|
||||||
|
fn parse_node_version(v: &str) -> Option<(u32, u32, u32)> {
|
||||||
|
let v = v.trim().trim_start_matches('v');
|
||||||
|
let mut parts = v.splitn(3, '.');
|
||||||
|
let major = parts.next()?.parse().ok()?;
|
||||||
|
let minor = parts.next()?.parse().ok()?;
|
||||||
|
let patch_str = parts.next()?;
|
||||||
|
// Strip pre-release/build suffixes: "0-nightly" → "0", "3+build" → "3"
|
||||||
|
let patch_digits: String = patch_str
|
||||||
|
.chars()
|
||||||
|
.take_while(|c| c.is_ascii_digit())
|
||||||
|
.collect();
|
||||||
|
let patch = patch_digits.parse().ok()?;
|
||||||
|
Some((major, minor, patch))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the node version string from a cached node_available message.
|
||||||
|
/// Expected format: "Node.js v20.19.0 available" → Some("v20.19.0")
|
||||||
|
fn extract_node_version_from_message(message: &str) -> Option<String> {
|
||||||
|
message
|
||||||
|
.split_whitespace()
|
||||||
|
.find(|s| s.starts_with('v') && s.contains('.'))
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a `CheckItem` for the Node.js version requirement check.
|
||||||
|
/// `current_version` is the raw output from `node --version` (e.g. "v20.19.0").
|
||||||
|
fn build_node_version_check(current_version: Option<&str>, required: &str) -> CheckItem {
|
||||||
|
let current_version = match current_version {
|
||||||
|
Some(v) => v,
|
||||||
|
None => {
|
||||||
|
return CheckItem {
|
||||||
|
check_id: "node_version".into(),
|
||||||
|
label: "Node.js version".into(),
|
||||||
|
status: CheckStatus::Fail,
|
||||||
|
message: "Cannot determine Node.js version".into(),
|
||||||
|
fixes: vec![],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let current = parse_node_version(current_version);
|
||||||
|
let required_parsed = parse_node_version(required);
|
||||||
|
|
||||||
|
match (current, required_parsed) {
|
||||||
|
(Some(cur), Some(req)) if cur >= req => CheckItem {
|
||||||
|
check_id: "node_version".into(),
|
||||||
|
label: "Node.js version".into(),
|
||||||
|
status: CheckStatus::Pass,
|
||||||
|
message: format!(
|
||||||
|
"Node.js {current_version} meets the minimum requirement (>={required})"
|
||||||
|
),
|
||||||
|
fixes: vec![],
|
||||||
|
},
|
||||||
|
(Some(_), Some(_)) => CheckItem {
|
||||||
|
check_id: "node_version".into(),
|
||||||
|
label: "Node.js version".into(),
|
||||||
|
status: CheckStatus::Fail,
|
||||||
|
message: format!(
|
||||||
|
"Node.js {current_version} is too old — this package requires Node.js >={required}"
|
||||||
|
),
|
||||||
|
fixes: vec![FixAction {
|
||||||
|
label: "Update Node.js".into(),
|
||||||
|
kind: FixActionKind::OpenUrl,
|
||||||
|
payload: "https://nodejs.org/".into(),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
_ => CheckItem {
|
||||||
|
check_id: "node_version".into(),
|
||||||
|
label: "Node.js version".into(),
|
||||||
|
status: CheckStatus::Warn,
|
||||||
|
message: format!("Cannot parse Node.js version; required >={required}"),
|
||||||
|
fixes: vec![],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn check_uvx_environment() -> Vec<CheckItem> {
|
||||||
|
// Return cached result if a previous check passed
|
||||||
|
let cached = UVX_ENV_CACHE.lock().unwrap().clone();
|
||||||
|
if let Some(cached) = cached {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run uv and uvx checks in parallel
|
||||||
|
let (uv_result, uvx_result) = tokio::join!(
|
||||||
|
crate::process::tokio_command("uv")
|
||||||
|
.arg("--version")
|
||||||
|
.output(),
|
||||||
|
crate::process::tokio_command("uvx")
|
||||||
|
.arg("--version")
|
||||||
|
.output(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let install_fix = vec![FixAction {
|
||||||
|
label: "Install uv".into(),
|
||||||
|
kind: FixActionKind::OpenUrl,
|
||||||
|
payload: "https://docs.astral.sh/uv/getting-started/installation/".into(),
|
||||||
|
}];
|
||||||
|
|
||||||
|
let uv_check = match uv_result {
|
||||||
|
Ok(output) if output.status.success() => {
|
||||||
|
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
CheckItem {
|
||||||
|
check_id: "uv_available".into(),
|
||||||
|
label: "uv".into(),
|
||||||
|
status: CheckStatus::Pass,
|
||||||
|
message: format!("uv {version} available"),
|
||||||
|
fixes: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => CheckItem {
|
||||||
|
check_id: "uv_available".into(),
|
||||||
|
label: "uv".into(),
|
||||||
|
status: CheckStatus::Fail,
|
||||||
|
message: "uv is not installed or not in PATH".into(),
|
||||||
|
fixes: install_fix.clone(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let uvx_check = match uvx_result {
|
||||||
|
Ok(output) if output.status.success() => {
|
||||||
|
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
CheckItem {
|
||||||
|
check_id: "uvx_available".into(),
|
||||||
|
label: "uvx".into(),
|
||||||
|
status: CheckStatus::Pass,
|
||||||
|
message: format!("uvx {version} available"),
|
||||||
|
fixes: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => CheckItem {
|
||||||
|
check_id: "uvx_available".into(),
|
||||||
|
label: "uvx".into(),
|
||||||
|
status: CheckStatus::Fail,
|
||||||
|
message: "uvx is not installed or not in PATH".into(),
|
||||||
|
fixes: install_fix,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let checks = vec![uv_check, uvx_check];
|
||||||
|
|
||||||
|
let all_passed = checks
|
||||||
|
.iter()
|
||||||
|
.all(|c| !matches!(c.status, CheckStatus::Fail));
|
||||||
|
if all_passed {
|
||||||
|
*UVX_ENV_CACHE.lock().unwrap() = Some(checks.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
checks
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn check_binary_environment(
|
||||||
|
agent_type: AgentType,
|
||||||
|
version: &str,
|
||||||
|
cmd: &str,
|
||||||
|
platforms: &[registry::PlatformBinary],
|
||||||
|
) -> Vec<CheckItem> {
|
||||||
|
let mut checks = Vec::new();
|
||||||
|
|
||||||
|
// Check platform support
|
||||||
|
let current = registry::current_platform();
|
||||||
|
let platform_supported = platforms.iter().any(|p| p.platform == current);
|
||||||
|
|
||||||
|
let platform_check = if platform_supported {
|
||||||
|
CheckItem {
|
||||||
|
check_id: "platform_supported".into(),
|
||||||
|
label: "Platform".into(),
|
||||||
|
status: CheckStatus::Pass,
|
||||||
|
message: format!("Platform {current} is supported"),
|
||||||
|
fixes: vec![],
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
CheckItem {
|
||||||
|
check_id: "platform_supported".into(),
|
||||||
|
label: "Platform".into(),
|
||||||
|
status: CheckStatus::Fail,
|
||||||
|
message: format!("Platform {current} is not supported"),
|
||||||
|
fixes: vec![],
|
||||||
|
}
|
||||||
|
};
|
||||||
|
checks.push(platform_check);
|
||||||
|
|
||||||
|
// Check binary cache
|
||||||
|
if platform_supported {
|
||||||
|
let cache_check = match binary_cache::find_cached_binary_for_agent(agent_type, version, cmd)
|
||||||
|
{
|
||||||
|
Ok(Some(_)) => CheckItem {
|
||||||
|
check_id: "binary_cached".into(),
|
||||||
|
label: "Binary cache".into(),
|
||||||
|
status: CheckStatus::Pass,
|
||||||
|
message: "Binary is cached locally".into(),
|
||||||
|
fixes: vec![],
|
||||||
|
},
|
||||||
|
Ok(None) => CheckItem {
|
||||||
|
check_id: "binary_cached".into(),
|
||||||
|
label: "Binary cache".into(),
|
||||||
|
status: CheckStatus::Warn,
|
||||||
|
message: "Binary not cached yet, will be downloaded on first connection".into(),
|
||||||
|
fixes: vec![],
|
||||||
|
},
|
||||||
|
Err(_) => CheckItem {
|
||||||
|
check_id: "binary_cached".into(),
|
||||||
|
label: "Binary cache".into(),
|
||||||
|
status: CheckStatus::Warn,
|
||||||
|
message: "Cannot determine binary cache path".into(),
|
||||||
|
fixes: vec![],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
checks.push(cache_check);
|
||||||
|
}
|
||||||
|
|
||||||
|
checks
|
||||||
|
}
|
||||||
@@ -0,0 +1,542 @@
|
|||||||
|
use crate::models::agent::AgentType;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum AgentDistribution {
|
||||||
|
Npx {
|
||||||
|
version: &'static str,
|
||||||
|
package: &'static str,
|
||||||
|
args: &'static [&'static str],
|
||||||
|
env: &'static [(&'static str, &'static str)],
|
||||||
|
/// Minimum Node.js version required, e.g. "22.12.0". None means no specific requirement.
|
||||||
|
node_required: Option<&'static str>,
|
||||||
|
},
|
||||||
|
Uvx {
|
||||||
|
version: &'static str,
|
||||||
|
package: &'static str,
|
||||||
|
args: &'static [&'static str],
|
||||||
|
env: &'static [(&'static str, &'static str)],
|
||||||
|
},
|
||||||
|
Binary {
|
||||||
|
version: &'static str,
|
||||||
|
cmd: &'static str,
|
||||||
|
args: &'static [&'static str],
|
||||||
|
env: &'static [(&'static str, &'static str)],
|
||||||
|
platforms: &'static [PlatformBinary],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PlatformBinary {
|
||||||
|
pub platform: &'static str,
|
||||||
|
pub url: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AcpAgentMeta {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub agent_type: AgentType,
|
||||||
|
pub name: &'static str,
|
||||||
|
pub description: &'static str,
|
||||||
|
pub distribution: AgentDistribution,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AcpAgentMeta {
|
||||||
|
pub fn registry_version(&self) -> Option<&'static str> {
|
||||||
|
match &self.distribution {
|
||||||
|
AgentDistribution::Npx { version, .. }
|
||||||
|
| AgentDistribution::Uvx { version, .. }
|
||||||
|
| AgentDistribution::Binary { version, .. } => Some(*version),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn current_platform() -> &'static str {
|
||||||
|
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
|
||||||
|
{
|
||||||
|
"darwin-aarch64"
|
||||||
|
}
|
||||||
|
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
|
||||||
|
{
|
||||||
|
"darwin-x86_64"
|
||||||
|
}
|
||||||
|
#[cfg(all(target_os = "linux", target_arch = "aarch64"))]
|
||||||
|
{
|
||||||
|
"linux-aarch64"
|
||||||
|
}
|
||||||
|
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||||
|
{
|
||||||
|
"linux-x86_64"
|
||||||
|
}
|
||||||
|
#[cfg(all(target_os = "windows", target_arch = "aarch64"))]
|
||||||
|
{
|
||||||
|
"windows-aarch64"
|
||||||
|
}
|
||||||
|
#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
|
||||||
|
{
|
||||||
|
"windows-x86_64"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn all_acp_agents() -> Vec<AgentType> {
|
||||||
|
vec![
|
||||||
|
AgentType::Auggie,
|
||||||
|
AgentType::Autohand,
|
||||||
|
AgentType::ClaudeCode,
|
||||||
|
AgentType::Cline,
|
||||||
|
AgentType::CodebuddyCode,
|
||||||
|
AgentType::Codex,
|
||||||
|
AgentType::CorustAgent,
|
||||||
|
AgentType::FactoryDroid,
|
||||||
|
AgentType::Gemini,
|
||||||
|
AgentType::GithubCopilot,
|
||||||
|
AgentType::Goose,
|
||||||
|
AgentType::Junie,
|
||||||
|
AgentType::Kimi,
|
||||||
|
AgentType::MinionCode,
|
||||||
|
AgentType::MistralVibe,
|
||||||
|
AgentType::OpenClaw,
|
||||||
|
AgentType::OpenCode,
|
||||||
|
AgentType::Qoder,
|
||||||
|
AgentType::QwenCode,
|
||||||
|
AgentType::Stakpak,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn registry_id_for(agent_type: AgentType) -> &'static str {
|
||||||
|
match agent_type {
|
||||||
|
AgentType::Auggie => "auggie",
|
||||||
|
AgentType::Autohand => "autohand",
|
||||||
|
AgentType::ClaudeCode => "claude-acp",
|
||||||
|
AgentType::Cline => "cline",
|
||||||
|
AgentType::CodebuddyCode => "codebuddy-code",
|
||||||
|
AgentType::Codex => "codex-acp",
|
||||||
|
AgentType::CorustAgent => "corust-agent",
|
||||||
|
AgentType::FactoryDroid => "factory-droid",
|
||||||
|
AgentType::Gemini => "gemini",
|
||||||
|
AgentType::GithubCopilot => "github-copilot",
|
||||||
|
AgentType::Goose => "goose",
|
||||||
|
AgentType::Junie => "junie-acp",
|
||||||
|
AgentType::Kimi => "kimi",
|
||||||
|
AgentType::MinionCode => "minion-code",
|
||||||
|
AgentType::MistralVibe => "mistral-vibe",
|
||||||
|
AgentType::OpenClaw => "openclaw-acp",
|
||||||
|
AgentType::OpenCode => "opencode",
|
||||||
|
AgentType::Qoder => "qoder",
|
||||||
|
AgentType::QwenCode => "qwen-code",
|
||||||
|
AgentType::Stakpak => "stakpak",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn from_registry_id(id: &str) -> Option<AgentType> {
|
||||||
|
match id {
|
||||||
|
"auggie" => Some(AgentType::Auggie),
|
||||||
|
"autohand" => Some(AgentType::Autohand),
|
||||||
|
"claude-acp" => Some(AgentType::ClaudeCode),
|
||||||
|
"cline" => Some(AgentType::Cline),
|
||||||
|
"codebuddy-code" => Some(AgentType::CodebuddyCode),
|
||||||
|
"codex-acp" => Some(AgentType::Codex),
|
||||||
|
"corust-agent" => Some(AgentType::CorustAgent),
|
||||||
|
"factory-droid" => Some(AgentType::FactoryDroid),
|
||||||
|
"gemini" => Some(AgentType::Gemini),
|
||||||
|
"github-copilot" => Some(AgentType::GithubCopilot),
|
||||||
|
"goose" => Some(AgentType::Goose),
|
||||||
|
"junie-acp" => Some(AgentType::Junie),
|
||||||
|
"kimi" => Some(AgentType::Kimi),
|
||||||
|
"minion-code" => Some(AgentType::MinionCode),
|
||||||
|
"mistral-vibe" => Some(AgentType::MistralVibe),
|
||||||
|
"openclaw-acp" => Some(AgentType::OpenClaw),
|
||||||
|
"opencode" => Some(AgentType::OpenCode),
|
||||||
|
"qoder" => Some(AgentType::Qoder),
|
||||||
|
"qwen-code" => Some(AgentType::QwenCode),
|
||||||
|
"stakpak" => Some(AgentType::Stakpak),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_agent_meta(agent_type: AgentType) -> AcpAgentMeta {
|
||||||
|
match agent_type {
|
||||||
|
AgentType::Auggie => AcpAgentMeta {
|
||||||
|
agent_type,
|
||||||
|
name: "Auggie CLI",
|
||||||
|
description: "Augment Code's powerful software agent, backed by industry-leading context engine",
|
||||||
|
distribution: AgentDistribution::Npx {
|
||||||
|
version: "0.17.0",
|
||||||
|
package: "@augmentcode/auggie@0.17.0",
|
||||||
|
args: &["--acp"],
|
||||||
|
env: &[("AUGMENT_DISABLE_AUTO_UPDATE", "1")],
|
||||||
|
node_required: None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AgentType::Autohand => AcpAgentMeta {
|
||||||
|
agent_type,
|
||||||
|
name: "Autohand Code",
|
||||||
|
description: "Autohand Code - AI coding agent powered by Autohand AI",
|
||||||
|
distribution: AgentDistribution::Npx {
|
||||||
|
version: "0.2.1",
|
||||||
|
package: "@autohandai/autohand-acp@0.2.1",
|
||||||
|
args: &[],
|
||||||
|
env: &[],
|
||||||
|
node_required: None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AgentType::ClaudeCode => AcpAgentMeta {
|
||||||
|
agent_type,
|
||||||
|
name: "Claude Code",
|
||||||
|
description: "ACP wrapper for Anthropic's Claude",
|
||||||
|
distribution: AgentDistribution::Npx {
|
||||||
|
version: "0.18.0",
|
||||||
|
package: "@zed-industries/claude-agent-acp@0.18.0",
|
||||||
|
args: &[],
|
||||||
|
env: &[],
|
||||||
|
node_required: None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AgentType::Cline => AcpAgentMeta {
|
||||||
|
agent_type,
|
||||||
|
name: "Cline",
|
||||||
|
description: "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||||
|
distribution: AgentDistribution::Npx {
|
||||||
|
version: "2.5.0",
|
||||||
|
package: "cline@2.5.0",
|
||||||
|
args: &["--acp"],
|
||||||
|
env: &[],
|
||||||
|
node_required: None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AgentType::CodebuddyCode => AcpAgentMeta {
|
||||||
|
agent_type,
|
||||||
|
name: "Codebuddy Code",
|
||||||
|
description: "Tencent Cloud's official intelligent coding tool",
|
||||||
|
distribution: AgentDistribution::Npx {
|
||||||
|
version: "2.51.2",
|
||||||
|
package: "@tencent-ai/codebuddy-code@2.51.2",
|
||||||
|
args: &["--acp"],
|
||||||
|
env: &[],
|
||||||
|
node_required: None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AgentType::Codex => AcpAgentMeta {
|
||||||
|
agent_type,
|
||||||
|
name: "Codex CLI",
|
||||||
|
description: "ACP adapter for OpenAI's coding assistant",
|
||||||
|
distribution: AgentDistribution::Binary {
|
||||||
|
version: "0.9.4",
|
||||||
|
cmd: "codex-acp",
|
||||||
|
args: &[],
|
||||||
|
env: &[],
|
||||||
|
platforms: &[
|
||||||
|
PlatformBinary {
|
||||||
|
platform: "darwin-aarch64",
|
||||||
|
url: "https://github.com/zed-industries/codex-acp/releases/download/v0.9.4/codex-acp-0.9.4-aarch64-apple-darwin.tar.gz",
|
||||||
|
},
|
||||||
|
PlatformBinary {
|
||||||
|
platform: "darwin-x86_64",
|
||||||
|
url: "https://github.com/zed-industries/codex-acp/releases/download/v0.9.4/codex-acp-0.9.4-x86_64-apple-darwin.tar.gz",
|
||||||
|
},
|
||||||
|
PlatformBinary {
|
||||||
|
platform: "linux-aarch64",
|
||||||
|
url: "https://github.com/zed-industries/codex-acp/releases/download/v0.9.4/codex-acp-0.9.4-aarch64-unknown-linux-gnu.tar.gz",
|
||||||
|
},
|
||||||
|
PlatformBinary {
|
||||||
|
platform: "linux-x86_64",
|
||||||
|
url: "https://github.com/zed-industries/codex-acp/releases/download/v0.9.4/codex-acp-0.9.4-x86_64-unknown-linux-gnu.tar.gz",
|
||||||
|
},
|
||||||
|
PlatformBinary {
|
||||||
|
platform: "windows-aarch64",
|
||||||
|
url: "https://github.com/zed-industries/codex-acp/releases/download/v0.9.4/codex-acp-0.9.4-aarch64-pc-windows-msvc.zip",
|
||||||
|
},
|
||||||
|
PlatformBinary {
|
||||||
|
platform: "windows-x86_64",
|
||||||
|
url: "https://github.com/zed-industries/codex-acp/releases/download/v0.9.4/codex-acp-0.9.4-x86_64-pc-windows-msvc.zip",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AgentType::CorustAgent => AcpAgentMeta {
|
||||||
|
agent_type,
|
||||||
|
name: "Corust Agent",
|
||||||
|
description: "Co-building with a seasoned Rust partner.",
|
||||||
|
distribution: AgentDistribution::Binary {
|
||||||
|
version: "0.3.4",
|
||||||
|
cmd: "corust-agent-acp",
|
||||||
|
args: &[],
|
||||||
|
env: &[],
|
||||||
|
platforms: &[
|
||||||
|
PlatformBinary {
|
||||||
|
platform: "darwin-aarch64",
|
||||||
|
url: "https://github.com/Corust-ai/corust-agent-release/releases/download/v0.3.4/agent-darwin-arm64.tar.gz",
|
||||||
|
},
|
||||||
|
PlatformBinary {
|
||||||
|
platform: "linux-x86_64",
|
||||||
|
url: "https://github.com/Corust-ai/corust-agent-release/releases/download/v0.3.4/agent-linux-x64.tar.gz",
|
||||||
|
},
|
||||||
|
PlatformBinary {
|
||||||
|
platform: "windows-x86_64",
|
||||||
|
url: "https://github.com/Corust-ai/corust-agent-release/releases/download/v0.3.4/agent-windows-x64.zip",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AgentType::FactoryDroid => AcpAgentMeta {
|
||||||
|
agent_type,
|
||||||
|
name: "Factory Droid",
|
||||||
|
description: "Factory Droid - AI coding agent powered by Factory AI",
|
||||||
|
distribution: AgentDistribution::Npx {
|
||||||
|
version: "0.63.0",
|
||||||
|
package: "droid@0.63.0",
|
||||||
|
args: &["exec", "--output-format", "acp"],
|
||||||
|
env: &[("DROID_DISABLE_AUTO_UPDATE", "true"), ("FACTORY_DROID_AUTO_UPDATE_ENABLED", "false")],
|
||||||
|
node_required: None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AgentType::Gemini => AcpAgentMeta {
|
||||||
|
agent_type,
|
||||||
|
name: "Gemini CLI",
|
||||||
|
description: "Google's official CLI for Gemini",
|
||||||
|
distribution: AgentDistribution::Npx {
|
||||||
|
version: "0.30.0",
|
||||||
|
package: "@google/gemini-cli@0.30.0",
|
||||||
|
args: &["--experimental-acp"],
|
||||||
|
env: &[],
|
||||||
|
node_required: None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AgentType::GithubCopilot => AcpAgentMeta {
|
||||||
|
agent_type,
|
||||||
|
name: "GitHub Copilot",
|
||||||
|
description: "GitHub's AI pair programmer",
|
||||||
|
distribution: AgentDistribution::Npx {
|
||||||
|
version: "1.432.0",
|
||||||
|
package: "@github/copilot-language-server@1.432.0",
|
||||||
|
args: &["--acp"],
|
||||||
|
env: &[],
|
||||||
|
node_required: None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AgentType::Goose => AcpAgentMeta {
|
||||||
|
agent_type,
|
||||||
|
name: "goose",
|
||||||
|
description: "A local, extensible, open source AI agent that automates engineering tasks",
|
||||||
|
distribution: AgentDistribution::Binary {
|
||||||
|
version: "1.25.1",
|
||||||
|
cmd: "goose",
|
||||||
|
args: &[],
|
||||||
|
env: &[],
|
||||||
|
platforms: &[
|
||||||
|
PlatformBinary {
|
||||||
|
platform: "darwin-aarch64",
|
||||||
|
url: "https://github.com/block/goose/releases/download/v1.25.1/goose-aarch64-apple-darwin.tar.bz2",
|
||||||
|
},
|
||||||
|
PlatformBinary {
|
||||||
|
platform: "darwin-x86_64",
|
||||||
|
url: "https://github.com/block/goose/releases/download/v1.25.1/goose-x86_64-apple-darwin.tar.bz2",
|
||||||
|
},
|
||||||
|
PlatformBinary {
|
||||||
|
platform: "linux-aarch64",
|
||||||
|
url: "https://github.com/block/goose/releases/download/v1.25.1/goose-aarch64-unknown-linux-gnu.tar.bz2",
|
||||||
|
},
|
||||||
|
PlatformBinary {
|
||||||
|
platform: "linux-x86_64",
|
||||||
|
url: "https://github.com/block/goose/releases/download/v1.25.1/goose-x86_64-unknown-linux-gnu.tar.bz2",
|
||||||
|
},
|
||||||
|
PlatformBinary {
|
||||||
|
platform: "windows-x86_64",
|
||||||
|
url: "https://github.com/block/goose/releases/download/v1.25.1/goose-x86_64-pc-windows-msvc.zip",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AgentType::Junie => AcpAgentMeta {
|
||||||
|
agent_type,
|
||||||
|
name: "Junie",
|
||||||
|
description: "AI Coding Agent by JetBrains",
|
||||||
|
distribution: AgentDistribution::Npx {
|
||||||
|
version: "849.19.0",
|
||||||
|
package: "@jetbrains/junie-cli@849.19.0",
|
||||||
|
args: &["--acp=true"],
|
||||||
|
env: &[],
|
||||||
|
node_required: None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AgentType::Kimi => AcpAgentMeta {
|
||||||
|
agent_type,
|
||||||
|
name: "Kimi CLI",
|
||||||
|
description: "Moonshot AI's coding assistant",
|
||||||
|
distribution: AgentDistribution::Binary {
|
||||||
|
version: "1.14.0",
|
||||||
|
cmd: "kimi",
|
||||||
|
args: &[],
|
||||||
|
env: &[],
|
||||||
|
platforms: &[
|
||||||
|
PlatformBinary {
|
||||||
|
platform: "darwin-aarch64",
|
||||||
|
url: "https://github.com/MoonshotAI/kimi-cli/releases/download/1.14.0/kimi-1.14.0-aarch64-apple-darwin.tar.gz",
|
||||||
|
},
|
||||||
|
PlatformBinary {
|
||||||
|
platform: "linux-aarch64",
|
||||||
|
url: "https://github.com/MoonshotAI/kimi-cli/releases/download/1.14.0/kimi-1.14.0-aarch64-unknown-linux-gnu.tar.gz",
|
||||||
|
},
|
||||||
|
PlatformBinary {
|
||||||
|
platform: "linux-x86_64",
|
||||||
|
url: "https://github.com/MoonshotAI/kimi-cli/releases/download/1.14.0/kimi-1.14.0-x86_64-unknown-linux-gnu.tar.gz",
|
||||||
|
},
|
||||||
|
PlatformBinary {
|
||||||
|
platform: "windows-x86_64",
|
||||||
|
url: "https://github.com/MoonshotAI/kimi-cli/releases/download/1.14.0/kimi-1.14.0-x86_64-pc-windows-msvc.zip",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AgentType::MinionCode => AcpAgentMeta {
|
||||||
|
agent_type,
|
||||||
|
name: "Minion Code",
|
||||||
|
description: "An enhanced AI code assistant built on the Minion framework with rich development tools",
|
||||||
|
distribution: AgentDistribution::Uvx {
|
||||||
|
version: "0.1.39",
|
||||||
|
package: "minion-code@0.1.39",
|
||||||
|
args: &["acp"],
|
||||||
|
env: &[],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AgentType::MistralVibe => AcpAgentMeta {
|
||||||
|
agent_type,
|
||||||
|
name: "Mistral Vibe",
|
||||||
|
description: "Mistral's open-source coding assistant",
|
||||||
|
distribution: AgentDistribution::Binary {
|
||||||
|
version: "2.2.1",
|
||||||
|
cmd: "vibe-acp",
|
||||||
|
args: &[],
|
||||||
|
env: &[],
|
||||||
|
platforms: &[
|
||||||
|
PlatformBinary {
|
||||||
|
platform: "darwin-aarch64",
|
||||||
|
url: "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.1/vibe-acp-darwin-aarch64-2.2.1.zip",
|
||||||
|
},
|
||||||
|
PlatformBinary {
|
||||||
|
platform: "darwin-x86_64",
|
||||||
|
url: "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.1/vibe-acp-darwin-x86_64-2.2.1.zip",
|
||||||
|
},
|
||||||
|
PlatformBinary {
|
||||||
|
platform: "linux-aarch64",
|
||||||
|
url: "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.1/vibe-acp-linux-aarch64-2.2.1.zip",
|
||||||
|
},
|
||||||
|
PlatformBinary {
|
||||||
|
platform: "linux-x86_64",
|
||||||
|
url: "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.1/vibe-acp-linux-x86_64-2.2.1.zip",
|
||||||
|
},
|
||||||
|
PlatformBinary {
|
||||||
|
platform: "windows-aarch64",
|
||||||
|
url: "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.1/vibe-acp-windows-aarch64-2.2.1.zip",
|
||||||
|
},
|
||||||
|
PlatformBinary {
|
||||||
|
platform: "windows-x86_64",
|
||||||
|
url: "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.1/vibe-acp-windows-x86_64-2.2.1.zip",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AgentType::OpenClaw => AcpAgentMeta {
|
||||||
|
agent_type,
|
||||||
|
name: "OpenClaw",
|
||||||
|
description: "Open-source personal AI assistant with ACP bridge",
|
||||||
|
distribution: AgentDistribution::Npx {
|
||||||
|
version: "2026.2.26",
|
||||||
|
package: "openclaw@2026.2.26",
|
||||||
|
args: &["acp"],
|
||||||
|
env: &[],
|
||||||
|
node_required: Some("22.12.0"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AgentType::OpenCode => AcpAgentMeta {
|
||||||
|
agent_type,
|
||||||
|
name: "OpenCode",
|
||||||
|
description: "The open source coding agent",
|
||||||
|
distribution: AgentDistribution::Binary {
|
||||||
|
version: "1.2.15",
|
||||||
|
cmd: "opencode",
|
||||||
|
args: &["acp"],
|
||||||
|
env: &[],
|
||||||
|
platforms: &[
|
||||||
|
PlatformBinary {
|
||||||
|
platform: "darwin-aarch64",
|
||||||
|
url: "https://github.com/anomalyco/opencode/releases/download/v1.2.15/opencode-darwin-arm64.zip",
|
||||||
|
},
|
||||||
|
PlatformBinary {
|
||||||
|
platform: "darwin-x86_64",
|
||||||
|
url: "https://github.com/anomalyco/opencode/releases/download/v1.2.15/opencode-darwin-x64.zip",
|
||||||
|
},
|
||||||
|
PlatformBinary {
|
||||||
|
platform: "linux-aarch64",
|
||||||
|
url: "https://github.com/anomalyco/opencode/releases/download/v1.2.15/opencode-linux-arm64.tar.gz",
|
||||||
|
},
|
||||||
|
PlatformBinary {
|
||||||
|
platform: "linux-x86_64",
|
||||||
|
url: "https://github.com/anomalyco/opencode/releases/download/v1.2.15/opencode-linux-x64.tar.gz",
|
||||||
|
},
|
||||||
|
PlatformBinary {
|
||||||
|
platform: "windows-x86_64",
|
||||||
|
url: "https://github.com/anomalyco/opencode/releases/download/v1.2.15/opencode-windows-x64.zip",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AgentType::Qoder => AcpAgentMeta {
|
||||||
|
agent_type,
|
||||||
|
name: "Qoder CLI",
|
||||||
|
description: "AI coding assistant with agentic capabilities",
|
||||||
|
distribution: AgentDistribution::Npx {
|
||||||
|
version: "0.1.29",
|
||||||
|
package: "@qoder-ai/qodercli@0.1.29",
|
||||||
|
args: &["--acp"],
|
||||||
|
env: &[],
|
||||||
|
node_required: None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AgentType::QwenCode => AcpAgentMeta {
|
||||||
|
agent_type,
|
||||||
|
name: "Qwen Code",
|
||||||
|
description: "Alibaba's Qwen coding assistant",
|
||||||
|
distribution: AgentDistribution::Npx {
|
||||||
|
version: "0.10.6",
|
||||||
|
package: "@qwen-code/qwen-code@0.10.6",
|
||||||
|
args: &["--acp", "--experimental-skills"],
|
||||||
|
env: &[],
|
||||||
|
node_required: None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AgentType::Stakpak => AcpAgentMeta {
|
||||||
|
agent_type,
|
||||||
|
name: "Stakpak",
|
||||||
|
description: "Open-source DevOps agent in Rust with enterprise-grade security",
|
||||||
|
distribution: AgentDistribution::Binary {
|
||||||
|
version: "0.3.62",
|
||||||
|
cmd: "stakpak",
|
||||||
|
args: &[],
|
||||||
|
env: &[],
|
||||||
|
platforms: &[
|
||||||
|
PlatformBinary {
|
||||||
|
platform: "darwin-aarch64",
|
||||||
|
url: "https://github.com/stakpak/agent/releases/download/v0.3.62/stakpak-darwin-aarch64.tar.gz",
|
||||||
|
},
|
||||||
|
PlatformBinary {
|
||||||
|
platform: "darwin-x86_64",
|
||||||
|
url: "https://github.com/stakpak/agent/releases/download/v0.3.62/stakpak-darwin-x86_64.tar.gz",
|
||||||
|
},
|
||||||
|
PlatformBinary {
|
||||||
|
platform: "linux-aarch64",
|
||||||
|
url: "https://github.com/stakpak/agent/releases/download/v0.3.62/stakpak-linux-aarch64.tar.gz",
|
||||||
|
},
|
||||||
|
PlatformBinary {
|
||||||
|
platform: "linux-x86_64",
|
||||||
|
url: "https://github.com/stakpak/agent/releases/download/v0.3.62/stakpak-linux-x86_64.tar.gz",
|
||||||
|
},
|
||||||
|
PlatformBinary {
|
||||||
|
platform: "windows-x86_64",
|
||||||
|
url: "https://github.com/stakpak/agent/releases/download/v0.3.62/stakpak-windows-x86_64.zip",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::acp::registry;
|
||||||
|
use crate::models::agent::AgentType;
|
||||||
|
|
||||||
|
pub const REGISTRY_URL: &str =
|
||||||
|
"https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RegistryAgent {
|
||||||
|
pub agent_type: AgentType,
|
||||||
|
pub registry_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub version: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RegistryBinaryRelease {
|
||||||
|
pub version: String,
|
||||||
|
pub archive_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct RegistryPayload {
|
||||||
|
agents: Vec<RegistryAgentItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct RegistryAgentItem {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
description: String,
|
||||||
|
#[serde(default)]
|
||||||
|
version: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
distribution: RegistryDistribution,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
struct RegistryDistribution {
|
||||||
|
#[serde(default)]
|
||||||
|
binary: BTreeMap<String, RegistryBinaryPlatformItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
struct RegistryBinaryPlatformItem {
|
||||||
|
#[serde(default, alias = "url")]
|
||||||
|
archive: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_registry_payload() -> Result<RegistryPayload, String> {
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.get(REGISTRY_URL)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("failed to fetch ACP registry: {e}"))?;
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!(
|
||||||
|
"failed to fetch ACP registry: HTTP {}",
|
||||||
|
response.status()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("failed to read ACP registry response: {e}"))
|
||||||
|
.and_then(|text| {
|
||||||
|
serde_json::from_str::<RegistryPayload>(&text)
|
||||||
|
.map_err(|e| format!("failed to parse ACP registry JSON: {e}"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_supported_agents() -> Result<Vec<RegistryAgent>, String> {
|
||||||
|
let payload = fetch_registry_payload().await?;
|
||||||
|
|
||||||
|
let mut supported = Vec::new();
|
||||||
|
for item in payload.agents {
|
||||||
|
if let Some(agent_type) = registry::from_registry_id(&item.id) {
|
||||||
|
supported.push(RegistryAgent {
|
||||||
|
agent_type,
|
||||||
|
registry_id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
description: item.description,
|
||||||
|
version: item.version,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(supported)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_binary_release(
|
||||||
|
agent_type: AgentType,
|
||||||
|
platform: &str,
|
||||||
|
) -> Result<Option<RegistryBinaryRelease>, String> {
|
||||||
|
let payload = fetch_registry_payload().await?;
|
||||||
|
let item = payload.agents.into_iter().find(|item| {
|
||||||
|
registry::from_registry_id(&item.id)
|
||||||
|
.map(|candidate| candidate == agent_type)
|
||||||
|
.unwrap_or(false)
|
||||||
|
});
|
||||||
|
|
||||||
|
let Some(item) = item else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if item.version.as_deref().unwrap_or_default().is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let platform_item = item.distribution.binary.get(platform);
|
||||||
|
let Some(platform_item) = platform_item else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if platform_item.archive.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some(RegistryBinaryRelease {
|
||||||
|
version: item.version.unwrap_or_default(),
|
||||||
|
archive_url: platform_item.archive.clone(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -0,0 +1,517 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::process::Stdio;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use sacp::schema::{
|
||||||
|
CreateTerminalRequest, CreateTerminalResponse, KillTerminalCommandRequest,
|
||||||
|
KillTerminalCommandResponse, ReleaseTerminalRequest, ReleaseTerminalResponse,
|
||||||
|
TerminalExitStatus, TerminalOutputRequest, TerminalOutputResponse, WaitForTerminalExitRequest,
|
||||||
|
WaitForTerminalExitResponse,
|
||||||
|
};
|
||||||
|
use tokio::io::{AsyncRead, AsyncReadExt};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
type TerminalMap = HashMap<String, Arc<TerminalInstance>>;
|
||||||
|
const DEFAULT_OUTPUT_BYTE_LIMIT: u64 = 1_000_000;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum TerminalRuntimeError {
|
||||||
|
InvalidParams(String),
|
||||||
|
Internal(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TerminalRuntimeError {
|
||||||
|
pub fn to_rpc_error(self) -> sacp::Error {
|
||||||
|
match self {
|
||||||
|
Self::InvalidParams(message) => sacp::Error::invalid_params().data(message),
|
||||||
|
Self::Internal(message) => sacp::util::internal_error(message),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
struct TerminalSnapshot {
|
||||||
|
output: String,
|
||||||
|
output_base_offset: u64,
|
||||||
|
truncated: bool,
|
||||||
|
exit_status: Option<TerminalExitStatus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TerminalInstance {
|
||||||
|
session_id: String,
|
||||||
|
output_limit: Option<usize>,
|
||||||
|
child: Mutex<Option<tokio::process::Child>>,
|
||||||
|
snapshot: Mutex<TerminalSnapshot>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TerminalInstance {
|
||||||
|
fn new(session_id: String, output_limit: Option<u64>, child: tokio::process::Child) -> Self {
|
||||||
|
Self {
|
||||||
|
session_id,
|
||||||
|
output_limit: output_limit.and_then(|v| usize::try_from(v).ok()),
|
||||||
|
child: Mutex::new(Some(child)),
|
||||||
|
snapshot: Mutex::new(TerminalSnapshot::default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn append_output(&self, text: &str) {
|
||||||
|
let mut snapshot = self.snapshot.lock().await;
|
||||||
|
snapshot.output.push_str(text);
|
||||||
|
if let Some(limit) = self.output_limit {
|
||||||
|
let removed = enforce_output_limit(&mut snapshot.output, limit);
|
||||||
|
if removed > 0 {
|
||||||
|
snapshot.truncated = true;
|
||||||
|
snapshot.output_base_offset = snapshot
|
||||||
|
.output_base_offset
|
||||||
|
.saturating_add(u64::try_from(removed).unwrap_or(u64::MAX));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn refresh_exit_status(&self) -> Result<(), TerminalRuntimeError> {
|
||||||
|
{
|
||||||
|
let snapshot = self.snapshot.lock().await;
|
||||||
|
if snapshot.exit_status.is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let maybe_status = {
|
||||||
|
let mut child_guard = self.child.lock().await;
|
||||||
|
if let Some(child) = child_guard.as_mut() {
|
||||||
|
match child.try_wait() {
|
||||||
|
Ok(Some(status)) => {
|
||||||
|
*child_guard = None;
|
||||||
|
Some(status)
|
||||||
|
}
|
||||||
|
Ok(None) => None,
|
||||||
|
Err(err) => {
|
||||||
|
return Err(TerminalRuntimeError::Internal(format!(
|
||||||
|
"failed to query terminal exit status: {err}"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(status) = maybe_status {
|
||||||
|
let mut snapshot = self.snapshot.lock().await;
|
||||||
|
snapshot.exit_status = Some(map_exit_status(status));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_exit(&self) -> Result<TerminalExitStatus, TerminalRuntimeError> {
|
||||||
|
self.refresh_exit_status().await?;
|
||||||
|
{
|
||||||
|
let snapshot = self.snapshot.lock().await;
|
||||||
|
if let Some(exit_status) = snapshot.exit_status.clone() {
|
||||||
|
return Ok(exit_status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let exit_status = {
|
||||||
|
let mut child_guard = self.child.lock().await;
|
||||||
|
let Some(child) = child_guard.as_mut() else {
|
||||||
|
return Err(TerminalRuntimeError::Internal(
|
||||||
|
"terminal process missing while waiting for exit".to_string(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let status = child.wait().await.map_err(|err| {
|
||||||
|
TerminalRuntimeError::Internal(format!(
|
||||||
|
"failed waiting for terminal process to exit: {err}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
*child_guard = None;
|
||||||
|
map_exit_status(status)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut snapshot = self.snapshot.lock().await;
|
||||||
|
snapshot.exit_status = Some(exit_status.clone());
|
||||||
|
Ok(exit_status)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn kill_command(&self) -> Result<(), TerminalRuntimeError> {
|
||||||
|
self.refresh_exit_status().await?;
|
||||||
|
{
|
||||||
|
let snapshot = self.snapshot.lock().await;
|
||||||
|
if snapshot.exit_status.is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let exit_status = {
|
||||||
|
let mut child_guard = self.child.lock().await;
|
||||||
|
let Some(child) = child_guard.as_mut() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(err) = child.kill().await {
|
||||||
|
if err.kind() != std::io::ErrorKind::InvalidInput {
|
||||||
|
return Err(TerminalRuntimeError::Internal(format!(
|
||||||
|
"failed to kill terminal process: {err}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = child.wait().await.map_err(|err| {
|
||||||
|
TerminalRuntimeError::Internal(format!(
|
||||||
|
"failed to wait for killed terminal process: {err}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
*child_guard = None;
|
||||||
|
map_exit_status(status)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut snapshot = self.snapshot.lock().await;
|
||||||
|
snapshot.exit_status = Some(exit_status);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn snapshot(&self) -> TerminalSnapshot {
|
||||||
|
self.snapshot.lock().await.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TerminalRuntime {
|
||||||
|
terminals: Mutex<TerminalMap>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TerminalOutputDelta {
|
||||||
|
pub output: String,
|
||||||
|
pub next_offset: u64,
|
||||||
|
pub had_gap: bool,
|
||||||
|
pub truncated: bool,
|
||||||
|
pub exit_status: Option<TerminalExitStatus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TerminalRuntime {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
terminals: Mutex::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_terminal(
|
||||||
|
&self,
|
||||||
|
request: CreateTerminalRequest,
|
||||||
|
) -> Result<CreateTerminalResponse, TerminalRuntimeError> {
|
||||||
|
if let Some(cwd) = request.cwd.as_ref() {
|
||||||
|
if !cwd.is_absolute() {
|
||||||
|
return Err(TerminalRuntimeError::InvalidParams(
|
||||||
|
"terminal/create requires an absolute cwd when provided".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let output_byte_limit = request
|
||||||
|
.output_byte_limit
|
||||||
|
.unwrap_or(DEFAULT_OUTPUT_BYTE_LIMIT);
|
||||||
|
if output_byte_limit == 0 {
|
||||||
|
return Err(TerminalRuntimeError::InvalidParams(
|
||||||
|
"terminal/create outputByteLimit must be greater than 0".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut command = crate::process::tokio_command(&request.command);
|
||||||
|
command
|
||||||
|
.args(&request.args)
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.stdin(Stdio::null());
|
||||||
|
|
||||||
|
if let Some(cwd) = request.cwd.as_ref() {
|
||||||
|
command.current_dir(cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
for env_var in &request.env {
|
||||||
|
command.env(&env_var.name, &env_var.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut child = command.spawn().map_err(|err| {
|
||||||
|
TerminalRuntimeError::Internal(format!(
|
||||||
|
"failed to spawn terminal command {}: {err}",
|
||||||
|
request.command
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let stdout = child.stdout.take();
|
||||||
|
let stderr = child.stderr.take();
|
||||||
|
|
||||||
|
let terminal_id = format!("term_{}", uuid::Uuid::new_v4().simple());
|
||||||
|
let terminal = Arc::new(TerminalInstance::new(
|
||||||
|
request.session_id.to_string(),
|
||||||
|
Some(output_byte_limit),
|
||||||
|
child,
|
||||||
|
));
|
||||||
|
|
||||||
|
if let Some(reader) = stdout {
|
||||||
|
let terminal_ref = terminal.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
read_stream(reader, terminal_ref).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(reader) = stderr {
|
||||||
|
let terminal_ref = terminal.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
read_stream(reader, terminal_ref).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
self.terminals
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert(terminal_id.clone(), terminal);
|
||||||
|
|
||||||
|
Ok(CreateTerminalResponse::new(terminal_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn terminal_output(
|
||||||
|
&self,
|
||||||
|
request: TerminalOutputRequest,
|
||||||
|
) -> Result<TerminalOutputResponse, TerminalRuntimeError> {
|
||||||
|
let terminal = self
|
||||||
|
.find_terminal(
|
||||||
|
&request.terminal_id.to_string(),
|
||||||
|
&request.session_id.to_string(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
terminal.refresh_exit_status().await?;
|
||||||
|
let snapshot = terminal.snapshot().await;
|
||||||
|
|
||||||
|
Ok(
|
||||||
|
TerminalOutputResponse::new(snapshot.output, snapshot.truncated)
|
||||||
|
.exit_status(snapshot.exit_status),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn terminal_output_delta(
|
||||||
|
&self,
|
||||||
|
session_id: &str,
|
||||||
|
terminal_id: &str,
|
||||||
|
from_offset: Option<u64>,
|
||||||
|
) -> Result<TerminalOutputDelta, TerminalRuntimeError> {
|
||||||
|
let terminal = self.find_terminal(terminal_id, session_id).await?;
|
||||||
|
terminal.refresh_exit_status().await?;
|
||||||
|
let snapshot = terminal.snapshot().await;
|
||||||
|
|
||||||
|
let output_len = u64::try_from(snapshot.output.len()).unwrap_or(u64::MAX);
|
||||||
|
let base_offset = snapshot.output_base_offset;
|
||||||
|
let end_offset = base_offset.saturating_add(output_len);
|
||||||
|
let requested_offset = from_offset.unwrap_or(base_offset);
|
||||||
|
let had_gap = from_offset
|
||||||
|
.map(|offset| offset < base_offset)
|
||||||
|
.unwrap_or(false);
|
||||||
|
let start_offset = requested_offset.clamp(base_offset, end_offset);
|
||||||
|
let start_index = usize::try_from(start_offset.saturating_sub(base_offset)).unwrap_or(0);
|
||||||
|
let output = snapshot.output[start_index..].to_string();
|
||||||
|
|
||||||
|
Ok(TerminalOutputDelta {
|
||||||
|
output,
|
||||||
|
next_offset: end_offset,
|
||||||
|
had_gap,
|
||||||
|
truncated: snapshot.truncated,
|
||||||
|
exit_status: snapshot.exit_status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn wait_for_terminal_exit(
|
||||||
|
&self,
|
||||||
|
request: WaitForTerminalExitRequest,
|
||||||
|
) -> Result<WaitForTerminalExitResponse, TerminalRuntimeError> {
|
||||||
|
let terminal = self
|
||||||
|
.find_terminal(
|
||||||
|
&request.terminal_id.to_string(),
|
||||||
|
&request.session_id.to_string(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let exit_status = terminal.wait_for_exit().await?;
|
||||||
|
Ok(WaitForTerminalExitResponse::new(exit_status))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn kill_terminal(
|
||||||
|
&self,
|
||||||
|
request: KillTerminalCommandRequest,
|
||||||
|
) -> Result<KillTerminalCommandResponse, TerminalRuntimeError> {
|
||||||
|
let terminal = self
|
||||||
|
.find_terminal(
|
||||||
|
&request.terminal_id.to_string(),
|
||||||
|
&request.session_id.to_string(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
terminal.kill_command().await?;
|
||||||
|
Ok(KillTerminalCommandResponse::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn release_terminal(
|
||||||
|
&self,
|
||||||
|
request: ReleaseTerminalRequest,
|
||||||
|
) -> Result<ReleaseTerminalResponse, TerminalRuntimeError> {
|
||||||
|
let terminal_id = request.terminal_id.to_string();
|
||||||
|
let session_id = request.session_id.to_string();
|
||||||
|
let terminal = {
|
||||||
|
let mut terminals = self.terminals.lock().await;
|
||||||
|
let Some(existing) = terminals.get(&terminal_id) else {
|
||||||
|
return Err(TerminalRuntimeError::InvalidParams(format!(
|
||||||
|
"terminal {terminal_id} not found"
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
if existing.session_id != session_id {
|
||||||
|
return Err(TerminalRuntimeError::InvalidParams(format!(
|
||||||
|
"terminal {terminal_id} does not belong to session {session_id}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
terminals.remove(&terminal_id).expect("terminal exists")
|
||||||
|
};
|
||||||
|
|
||||||
|
terminal.kill_command().await?;
|
||||||
|
Ok(ReleaseTerminalResponse::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn release_all_for_session(&self, session_id: &str) {
|
||||||
|
let removed = {
|
||||||
|
let mut terminals = self.terminals.lock().await;
|
||||||
|
let ids: Vec<String> = terminals
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, term)| term.session_id == session_id)
|
||||||
|
.map(|(id, _)| id.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut removed = Vec::with_capacity(ids.len());
|
||||||
|
for id in ids {
|
||||||
|
if let Some(term) = terminals.remove(&id) {
|
||||||
|
removed.push(term);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
removed
|
||||||
|
};
|
||||||
|
|
||||||
|
for terminal in removed {
|
||||||
|
if let Err(err) = terminal.kill_command().await {
|
||||||
|
eprintln!("[ACP] Failed to release terminal during cleanup: {err:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_terminal(
|
||||||
|
&self,
|
||||||
|
terminal_id: &str,
|
||||||
|
session_id: &str,
|
||||||
|
) -> Result<Arc<TerminalInstance>, TerminalRuntimeError> {
|
||||||
|
let terminal = {
|
||||||
|
let terminals = self.terminals.lock().await;
|
||||||
|
terminals.get(terminal_id).cloned()
|
||||||
|
}
|
||||||
|
.ok_or_else(|| {
|
||||||
|
TerminalRuntimeError::InvalidParams(format!("terminal {terminal_id} not found"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if terminal.session_id != session_id {
|
||||||
|
return Err(TerminalRuntimeError::InvalidParams(format!(
|
||||||
|
"terminal {terminal_id} does not belong to session {session_id}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(terminal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_stream<R>(mut reader: R, terminal: Arc<TerminalInstance>)
|
||||||
|
where
|
||||||
|
R: AsyncRead + Unpin,
|
||||||
|
{
|
||||||
|
let mut buffer = [0_u8; 4096];
|
||||||
|
let mut pending = Vec::<u8>::new();
|
||||||
|
loop {
|
||||||
|
match reader.read(&mut buffer).await {
|
||||||
|
Ok(0) => {
|
||||||
|
if !pending.is_empty() {
|
||||||
|
let text = String::from_utf8_lossy(&pending).to_string();
|
||||||
|
terminal.append_output(&text).await;
|
||||||
|
pending.clear();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Ok(size) => {
|
||||||
|
pending.extend_from_slice(&buffer[..size]);
|
||||||
|
let decoded = decode_available_utf8(&mut pending);
|
||||||
|
if !decoded.is_empty() {
|
||||||
|
terminal.append_output(&decoded).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_exit_status(status: std::process::ExitStatus) -> TerminalExitStatus {
|
||||||
|
#[cfg(unix)]
|
||||||
|
let signal = std::os::unix::process::ExitStatusExt::signal(&status).map(|s| s.to_string());
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
let signal: Option<String> = None;
|
||||||
|
|
||||||
|
let exit_code = status.code().and_then(|code| u32::try_from(code).ok());
|
||||||
|
TerminalExitStatus::new()
|
||||||
|
.exit_code(exit_code)
|
||||||
|
.signal(signal)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enforce_output_limit(output: &mut String, limit: usize) -> usize {
|
||||||
|
if output.len() <= limit {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut start = output.len().saturating_sub(limit);
|
||||||
|
while start < output.len() && !output.is_char_boundary(start) {
|
||||||
|
start += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
output.drain(..start);
|
||||||
|
start
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_available_utf8(pending: &mut Vec<u8>) -> String {
|
||||||
|
let mut output = String::new();
|
||||||
|
let mut consumed = 0usize;
|
||||||
|
let mut remaining = pending.as_slice();
|
||||||
|
|
||||||
|
while !remaining.is_empty() {
|
||||||
|
match std::str::from_utf8(remaining) {
|
||||||
|
Ok(text) => {
|
||||||
|
output.push_str(text);
|
||||||
|
consumed = consumed.saturating_add(remaining.len());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
let valid_up_to = err.valid_up_to();
|
||||||
|
if valid_up_to > 0 {
|
||||||
|
if let Ok(text) = std::str::from_utf8(&remaining[..valid_up_to]) {
|
||||||
|
output.push_str(text);
|
||||||
|
}
|
||||||
|
consumed = consumed.saturating_add(valid_up_to);
|
||||||
|
remaining = &remaining[valid_up_to..];
|
||||||
|
}
|
||||||
|
|
||||||
|
match err.error_len() {
|
||||||
|
Some(invalid_len) => {
|
||||||
|
output.push_str(&String::from_utf8_lossy(&remaining[..invalid_len]));
|
||||||
|
consumed = consumed.saturating_add(invalid_len);
|
||||||
|
remaining = &remaining[invalid_len..];
|
||||||
|
}
|
||||||
|
None => break, // keep partial UTF-8 sequence for next chunk
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if consumed > 0 {
|
||||||
|
pending.drain(..consumed);
|
||||||
|
}
|
||||||
|
output
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum PromptInputBlock {
|
||||||
|
Text {
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
ResourceLink {
|
||||||
|
uri: String,
|
||||||
|
name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
mime_type: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
description: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Events pushed from Rust backend to frontend via Tauri event system.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum AcpEvent {
|
||||||
|
/// Agent returned text content (streaming delta)
|
||||||
|
ContentDelta { connection_id: String, text: String },
|
||||||
|
/// Agent thinking/reasoning
|
||||||
|
Thinking { connection_id: String, text: String },
|
||||||
|
/// Agent initiated a tool call
|
||||||
|
ToolCall {
|
||||||
|
connection_id: String,
|
||||||
|
tool_call_id: String,
|
||||||
|
title: String,
|
||||||
|
kind: String,
|
||||||
|
status: String,
|
||||||
|
content: Option<String>,
|
||||||
|
raw_input: Option<String>,
|
||||||
|
raw_output: Option<String>,
|
||||||
|
},
|
||||||
|
/// Tool call status/content updated
|
||||||
|
ToolCallUpdate {
|
||||||
|
connection_id: String,
|
||||||
|
tool_call_id: String,
|
||||||
|
title: Option<String>,
|
||||||
|
status: Option<String>,
|
||||||
|
content: Option<String>,
|
||||||
|
raw_input: Option<String>,
|
||||||
|
raw_output: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
raw_output_append: Option<bool>,
|
||||||
|
},
|
||||||
|
/// Agent requests permission
|
||||||
|
PermissionRequest {
|
||||||
|
connection_id: String,
|
||||||
|
request_id: String,
|
||||||
|
tool_call: serde_json::Value,
|
||||||
|
options: Vec<PermissionOptionInfo>,
|
||||||
|
},
|
||||||
|
/// Turn completed
|
||||||
|
TurnComplete {
|
||||||
|
connection_id: String,
|
||||||
|
stop_reason: String,
|
||||||
|
},
|
||||||
|
/// Session established with agent-assigned session ID
|
||||||
|
SessionStarted {
|
||||||
|
connection_id: String,
|
||||||
|
session_id: String,
|
||||||
|
},
|
||||||
|
/// Session modes are available for this connection
|
||||||
|
SessionModes {
|
||||||
|
connection_id: String,
|
||||||
|
modes: SessionModeStateInfo,
|
||||||
|
},
|
||||||
|
/// Session configuration options are available/updated for this connection
|
||||||
|
SessionConfigOptions {
|
||||||
|
connection_id: String,
|
||||||
|
config_options: Vec<SessionConfigOptionInfo>,
|
||||||
|
},
|
||||||
|
/// Initial selector payloads (modes/config options) have been emitted
|
||||||
|
SelectorsReady { connection_id: String },
|
||||||
|
/// Current session mode changed
|
||||||
|
ModeChanged {
|
||||||
|
connection_id: String,
|
||||||
|
mode_id: String,
|
||||||
|
},
|
||||||
|
/// Agent reported plan update for current turn
|
||||||
|
PlanUpdate {
|
||||||
|
connection_id: String,
|
||||||
|
entries: Vec<PlanEntryInfo>,
|
||||||
|
},
|
||||||
|
/// Connection status changed
|
||||||
|
StatusChanged {
|
||||||
|
connection_id: String,
|
||||||
|
status: ConnectionStatus,
|
||||||
|
},
|
||||||
|
/// Error occurred
|
||||||
|
Error {
|
||||||
|
connection_id: String,
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
/// Available slash commands updated
|
||||||
|
AvailableCommands {
|
||||||
|
connection_id: String,
|
||||||
|
commands: Vec<AvailableCommandInfo>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PermissionOptionInfo {
|
||||||
|
pub option_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub kind: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SessionModeInfo {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SessionModeStateInfo {
|
||||||
|
pub current_mode_id: String,
|
||||||
|
pub available_modes: Vec<SessionModeInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SessionConfigSelectOptionInfo {
|
||||||
|
pub value: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SessionConfigSelectGroupInfo {
|
||||||
|
pub group: String,
|
||||||
|
pub name: String,
|
||||||
|
pub options: Vec<SessionConfigSelectOptionInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SessionConfigSelectInfo {
|
||||||
|
pub current_value: String,
|
||||||
|
pub options: Vec<SessionConfigSelectOptionInfo>,
|
||||||
|
pub groups: Vec<SessionConfigSelectGroupInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum SessionConfigKindInfo {
|
||||||
|
Select(SessionConfigSelectInfo),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SessionConfigOptionInfo {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub category: Option<String>,
|
||||||
|
pub kind: SessionConfigKindInfo,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PlanEntryInfo {
|
||||||
|
pub content: String,
|
||||||
|
pub priority: String,
|
||||||
|
pub status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ConnectionStatus {
|
||||||
|
Connecting,
|
||||||
|
Downloading,
|
||||||
|
Connected,
|
||||||
|
Prompting,
|
||||||
|
Disconnected,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct ConnectionInfo {
|
||||||
|
pub id: String,
|
||||||
|
pub agent_type: crate::models::agent::AgentType,
|
||||||
|
pub status: ConnectionStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct AcpAgentInfo {
|
||||||
|
pub agent_type: crate::models::agent::AgentType,
|
||||||
|
pub registry_id: String,
|
||||||
|
pub registry_version: Option<String>,
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub available: bool,
|
||||||
|
pub distribution_type: String,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub sort_order: i32,
|
||||||
|
pub installed_version: Option<String>,
|
||||||
|
pub env: BTreeMap<String, String>,
|
||||||
|
pub config_json: Option<String>,
|
||||||
|
pub config_file_path: Option<String>,
|
||||||
|
pub opencode_auth_json: Option<String>,
|
||||||
|
pub codex_auth_json: Option<String>,
|
||||||
|
pub codex_config_toml: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum AgentSkillScope {
|
||||||
|
Global,
|
||||||
|
Project,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum AgentSkillLayout {
|
||||||
|
MarkdownFile,
|
||||||
|
SkillDirectory,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct AgentSkillLocation {
|
||||||
|
pub scope: AgentSkillScope,
|
||||||
|
pub path: String,
|
||||||
|
pub exists: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct AgentSkillItem {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub scope: AgentSkillScope,
|
||||||
|
pub layout: AgentSkillLayout,
|
||||||
|
pub path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct AgentSkillsListResult {
|
||||||
|
pub supported: bool,
|
||||||
|
pub message: Option<String>,
|
||||||
|
pub locations: Vec<AgentSkillLocation>,
|
||||||
|
pub skills: Vec<AgentSkillItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct AgentSkillContent {
|
||||||
|
pub skill: AgentSkillItem,
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AvailableCommandInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub input_hint: Option<String>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
use crate::db::entities::conversation;
|
||||||
|
use crate::db::service::{conversation_service, folder_service, import_service};
|
||||||
|
use crate::db::AppDatabase;
|
||||||
|
use crate::models::*;
|
||||||
|
use crate::parsers::claude::ClaudeParser;
|
||||||
|
use crate::parsers::codex::CodexParser;
|
||||||
|
use crate::parsers::gemini::GeminiParser;
|
||||||
|
use crate::parsers::opencode::OpenCodeParser;
|
||||||
|
use crate::parsers::{path_eq_for_matching, AgentParser};
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_folder_conversations(
|
||||||
|
db: tauri::State<'_, AppDatabase>,
|
||||||
|
folder_id: i32,
|
||||||
|
agent_type: Option<AgentType>,
|
||||||
|
search: Option<String>,
|
||||||
|
sort_by: Option<String>,
|
||||||
|
status: Option<String>,
|
||||||
|
) -> Result<Vec<DbConversationSummary>, String> {
|
||||||
|
conversation_service::list_by_folder(&db.conn, folder_id, agent_type, search, sort_by, status)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Synchronous implementation shared by list_conversations, list_folders, and get_stats.
|
||||||
|
fn list_conversations_sync(
|
||||||
|
agent_type: Option<AgentType>,
|
||||||
|
search: Option<String>,
|
||||||
|
sort_by: Option<String>,
|
||||||
|
folder_path: Option<String>,
|
||||||
|
) -> Result<Vec<ConversationSummary>, String> {
|
||||||
|
let mut all_conversations = Vec::new();
|
||||||
|
let mut seen_keys = HashSet::new();
|
||||||
|
|
||||||
|
let parsers: Vec<(AgentType, Box<dyn AgentParser>)> = vec![
|
||||||
|
(AgentType::ClaudeCode, Box::new(ClaudeParser::new())),
|
||||||
|
(AgentType::Codex, Box::new(CodexParser::new())),
|
||||||
|
(AgentType::OpenCode, Box::new(OpenCodeParser::new())),
|
||||||
|
(AgentType::Gemini, Box::new(GeminiParser::new())),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (at, parser) in &parsers {
|
||||||
|
if let Some(ref filter) = agent_type {
|
||||||
|
if filter != at {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match parser.list_conversations() {
|
||||||
|
Ok(conversations) => {
|
||||||
|
// Deduplicate conversations based on (agent_type, id) combination
|
||||||
|
for conversation in conversations {
|
||||||
|
let key = format!("{:?}-{}", conversation.agent_type, conversation.id);
|
||||||
|
if seen_keys.insert(key) {
|
||||||
|
all_conversations.push(conversation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Error listing {} conversations: {}", at, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply search filter
|
||||||
|
if let Some(ref query) = search {
|
||||||
|
let query_lower = query.to_lowercase();
|
||||||
|
all_conversations.retain(|s| {
|
||||||
|
s.title
|
||||||
|
.as_ref()
|
||||||
|
.map(|t| t.to_lowercase().contains(&query_lower))
|
||||||
|
.unwrap_or(false)
|
||||||
|
|| s.folder_name
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| p.to_lowercase().contains(&query_lower))
|
||||||
|
.unwrap_or(false)
|
||||||
|
|| s.folder_path
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| p.to_lowercase().contains(&query_lower))
|
||||||
|
.unwrap_or(false)
|
||||||
|
|| s.git_branch
|
||||||
|
.as_ref()
|
||||||
|
.map(|b| b.to_lowercase().contains(&query_lower))
|
||||||
|
.unwrap_or(false)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply folder path filter
|
||||||
|
if let Some(ref fp) = folder_path {
|
||||||
|
all_conversations.retain(|s| {
|
||||||
|
s.folder_path
|
||||||
|
.as_deref()
|
||||||
|
.map(|p| path_eq_for_matching(p, fp.as_str()))
|
||||||
|
.unwrap_or(false)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply sorting
|
||||||
|
match sort_by.as_deref() {
|
||||||
|
Some("oldest") => all_conversations.sort_by(|a, b| a.started_at.cmp(&b.started_at)),
|
||||||
|
Some("messages") => all_conversations.sort_by(|a, b| b.message_count.cmp(&a.message_count)),
|
||||||
|
_ => all_conversations.sort_by(|a, b| b.started_at.cmp(&a.started_at)), // default: newest first
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(all_conversations)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_conversations(
|
||||||
|
agent_type: Option<AgentType>,
|
||||||
|
search: Option<String>,
|
||||||
|
sort_by: Option<String>,
|
||||||
|
folder_path: Option<String>,
|
||||||
|
) -> Result<Vec<ConversationSummary>, String> {
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
list_conversations_sync(agent_type, search, sort_by, folder_path)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_conversation(
|
||||||
|
agent_type: AgentType,
|
||||||
|
conversation_id: String,
|
||||||
|
) -> Result<ConversationDetail, String> {
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let parser: Box<dyn AgentParser> = match agent_type {
|
||||||
|
AgentType::ClaudeCode => Box::new(ClaudeParser::new()),
|
||||||
|
AgentType::Codex => Box::new(CodexParser::new()),
|
||||||
|
AgentType::OpenCode => Box::new(OpenCodeParser::new()),
|
||||||
|
AgentType::Gemini => Box::new(GeminiParser::new()),
|
||||||
|
_ => {
|
||||||
|
return Err(format!(
|
||||||
|
"conversation parsing not supported for {agent_type}"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
parser
|
||||||
|
.get_conversation(&conversation_id)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_folders() -> Result<Vec<FolderInfo>, String> {
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let all_conversations = list_conversations_sync(None, None, None, None)?;
|
||||||
|
Ok(compute_folders(&all_conversations))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_stats() -> Result<AgentStats, String> {
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let all_conversations = list_conversations_sync(None, None, None, None)?;
|
||||||
|
compute_stats(&all_conversations)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_sidebar_data() -> Result<SidebarData, String> {
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let all_conversations = list_conversations_sync(None, None, None, None)?;
|
||||||
|
let folders = compute_folders(&all_conversations);
|
||||||
|
let stats = compute_stats(&all_conversations)?;
|
||||||
|
Ok(SidebarData { folders, stats })
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_folders(all_conversations: &[ConversationSummary]) -> Vec<FolderInfo> {
|
||||||
|
let mut folder_map: HashMap<String, FolderInfo> = HashMap::new();
|
||||||
|
|
||||||
|
for conversation in all_conversations {
|
||||||
|
let path = conversation
|
||||||
|
.folder_path
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
let name = conversation
|
||||||
|
.folder_name
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
|
let entry = folder_map
|
||||||
|
.entry(path.clone())
|
||||||
|
.or_insert_with(|| FolderInfo {
|
||||||
|
path: path.clone(),
|
||||||
|
name,
|
||||||
|
agent_types: Vec::new(),
|
||||||
|
conversation_count: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
entry.conversation_count += 1;
|
||||||
|
if !entry.agent_types.contains(&conversation.agent_type) {
|
||||||
|
entry.agent_types.push(conversation.agent_type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut folders: Vec<FolderInfo> = folder_map.into_values().collect();
|
||||||
|
folders.sort_by(|a, b| b.conversation_count.cmp(&a.conversation_count));
|
||||||
|
folders
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn import_local_conversations(
|
||||||
|
db: tauri::State<'_, AppDatabase>,
|
||||||
|
folder_id: i32,
|
||||||
|
) -> Result<ImportResult, String> {
|
||||||
|
let folder = folder_service::get_folder_by_id(&db.conn, folder_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.ok_or_else(|| format!("Folder not found: {folder_id}"))?;
|
||||||
|
|
||||||
|
import_service::import_local_conversations(&db.conn, folder_id, &folder.path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_folder_conversation(
|
||||||
|
db: tauri::State<'_, AppDatabase>,
|
||||||
|
conversation_id: i32,
|
||||||
|
) -> Result<DbConversationDetail, String> {
|
||||||
|
let summary = conversation_service::get_by_id(&db.conn, conversation_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let (turns, session_stats) = if let Some(ref ext_id) = summary.external_id {
|
||||||
|
let at = summary.agent_type;
|
||||||
|
let eid = ext_id.clone();
|
||||||
|
tokio::task::spawn_blocking(move || -> Result<_, String> {
|
||||||
|
let parser: Box<dyn AgentParser> = match at {
|
||||||
|
AgentType::ClaudeCode => Box::new(ClaudeParser::new()),
|
||||||
|
AgentType::Codex => Box::new(CodexParser::new()),
|
||||||
|
AgentType::OpenCode => Box::new(OpenCodeParser::new()),
|
||||||
|
AgentType::Gemini => Box::new(GeminiParser::new()),
|
||||||
|
_ => return Ok((vec![], None)),
|
||||||
|
};
|
||||||
|
// If the external session file doesn't exist yet (e.g., new ACP session
|
||||||
|
// not yet synced to disk), return empty turns instead of failing.
|
||||||
|
match parser.get_conversation(&eid) {
|
||||||
|
Ok(d) => Ok((d.turns, d.session_stats)),
|
||||||
|
Err(crate::parsers::ParseError::ConversationNotFound(_)) => Ok((vec![], None)),
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.map_err(|e: String| e)?
|
||||||
|
} else {
|
||||||
|
(vec![], None)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut summary = summary;
|
||||||
|
summary.message_count = turns.len() as u32;
|
||||||
|
|
||||||
|
Ok(DbConversationDetail {
|
||||||
|
summary,
|
||||||
|
turns,
|
||||||
|
session_stats,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn create_conversation(
|
||||||
|
db: tauri::State<'_, AppDatabase>,
|
||||||
|
folder_id: i32,
|
||||||
|
agent_type: AgentType,
|
||||||
|
title: Option<String>,
|
||||||
|
) -> Result<i32, String> {
|
||||||
|
// Detect current git branch from the folder path
|
||||||
|
let git_branch = if let Some(folder) = folder_service::get_folder_by_id(&db.conn, folder_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
{
|
||||||
|
detect_git_branch(&folder.path).await
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let model = conversation_service::create(&db.conn, folder_id, agent_type, title, git_branch)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(model.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn detect_git_branch(path: &str) -> Option<String> {
|
||||||
|
let output = crate::process::tokio_command("git")
|
||||||
|
.args(["rev-parse", "--abbrev-ref", "HEAD"])
|
||||||
|
.current_dir(path)
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
if branch.is_empty() || branch == "HEAD" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(branch)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn update_conversation_status(
|
||||||
|
db: tauri::State<'_, AppDatabase>,
|
||||||
|
conversation_id: i32,
|
||||||
|
status: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let status_enum: conversation::ConversationStatus =
|
||||||
|
serde_json::from_value(serde_json::Value::String(status)).map_err(|e| e.to_string())?;
|
||||||
|
conversation_service::update_status(&db.conn, conversation_id, status_enum)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn update_conversation_title(
|
||||||
|
db: tauri::State<'_, AppDatabase>,
|
||||||
|
conversation_id: i32,
|
||||||
|
title: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
conversation_service::update_title(&db.conn, conversation_id, title)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn update_conversation_external_id(
|
||||||
|
db: tauri::State<'_, AppDatabase>,
|
||||||
|
conversation_id: i32,
|
||||||
|
external_id: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
conversation_service::update_external_id(&db.conn, conversation_id, external_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_conversation(
|
||||||
|
db: tauri::State<'_, AppDatabase>,
|
||||||
|
conversation_id: i32,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
conversation_service::soft_delete(&db.conn, conversation_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_stats(all_conversations: &[ConversationSummary]) -> Result<AgentStats, String> {
|
||||||
|
let mut total_messages: u32 = 0;
|
||||||
|
let mut counts: HashMap<AgentType, u32> = HashMap::new();
|
||||||
|
|
||||||
|
for conversation in all_conversations {
|
||||||
|
total_messages += conversation.message_count;
|
||||||
|
*counts.entry(conversation.agent_type).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut by_agent: Vec<AgentConversationCount> = counts
|
||||||
|
.into_iter()
|
||||||
|
.map(|(agent_type, conversation_count)| AgentConversationCount {
|
||||||
|
agent_type,
|
||||||
|
conversation_count,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
by_agent.sort_by(|a, b| b.conversation_count.cmp(&a.conversation_count));
|
||||||
|
|
||||||
|
Ok(AgentStats {
|
||||||
|
total_conversations: all_conversations.len() as u32,
|
||||||
|
total_messages,
|
||||||
|
by_agent,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
use crate::db::error::DbError;
|
||||||
|
use crate::db::service::folder_command_service;
|
||||||
|
use crate::db::AppDatabase;
|
||||||
|
use crate::models::FolderCommandInfo;
|
||||||
|
use std::path::Path;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
static BOOTSTRAP_FOLDER_COMMANDS_LOCK: Mutex<()> = Mutex::const_new(());
|
||||||
|
|
||||||
|
fn load_package_scripts_as_commands(folder_path: &str) -> Vec<(String, String)> {
|
||||||
|
let mut has_package_json = false;
|
||||||
|
let mut has_pnpm_lock = false;
|
||||||
|
let mut has_yarn_lock = false;
|
||||||
|
let mut has_bun_lock = false;
|
||||||
|
|
||||||
|
let entries = match std::fs::read_dir(folder_path) {
|
||||||
|
Ok(entries) => entries,
|
||||||
|
Err(_) => return Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let Some(file_name) = entry.file_name().to_str().map(|s| s.to_string()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
match file_name.as_str() {
|
||||||
|
"package.json" => has_package_json = true,
|
||||||
|
"pnpm-lock.yaml" => has_pnpm_lock = true,
|
||||||
|
"yarn.lock" => has_yarn_lock = true,
|
||||||
|
"bun.lockb" | "bun.lock" => has_bun_lock = true,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !has_package_json {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let package_json_path = Path::new(folder_path).join("package.json");
|
||||||
|
let package_json_content = match std::fs::read_to_string(package_json_path) {
|
||||||
|
Ok(content) => content,
|
||||||
|
Err(_) => return Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let package_json: serde_json::Value = match serde_json::from_str(&package_json_content) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(_) => return Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let package_manager = if has_pnpm_lock {
|
||||||
|
"pnpm"
|
||||||
|
} else if has_yarn_lock {
|
||||||
|
"yarn"
|
||||||
|
} else if has_bun_lock {
|
||||||
|
"bun"
|
||||||
|
} else {
|
||||||
|
"npm"
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut commands = Vec::new();
|
||||||
|
if let Some(scripts) = package_json.get("scripts").and_then(|s| s.as_object()) {
|
||||||
|
for (script_name, script_value) in scripts {
|
||||||
|
if script_name.trim().is_empty() || script_value.as_str().is_none() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
commands.push((
|
||||||
|
script_name.to_string(),
|
||||||
|
format!("{package_manager} run {script_name}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
commands
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_folder_commands(
|
||||||
|
db: tauri::State<'_, AppDatabase>,
|
||||||
|
folder_id: i32,
|
||||||
|
) -> Result<Vec<FolderCommandInfo>, DbError> {
|
||||||
|
folder_command_service::list_by_folder(&db.conn, folder_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn create_folder_command(
|
||||||
|
db: tauri::State<'_, AppDatabase>,
|
||||||
|
folder_id: i32,
|
||||||
|
name: String,
|
||||||
|
command: String,
|
||||||
|
) -> Result<FolderCommandInfo, DbError> {
|
||||||
|
folder_command_service::create(&db.conn, folder_id, &name, &command).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn update_folder_command(
|
||||||
|
db: tauri::State<'_, AppDatabase>,
|
||||||
|
id: i32,
|
||||||
|
name: Option<String>,
|
||||||
|
command: Option<String>,
|
||||||
|
sort_order: Option<i32>,
|
||||||
|
) -> Result<FolderCommandInfo, DbError> {
|
||||||
|
folder_command_service::update(&db.conn, id, name, command, sort_order).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_folder_command(
|
||||||
|
db: tauri::State<'_, AppDatabase>,
|
||||||
|
id: i32,
|
||||||
|
) -> Result<(), DbError> {
|
||||||
|
folder_command_service::delete(&db.conn, id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn reorder_folder_commands(
|
||||||
|
db: tauri::State<'_, AppDatabase>,
|
||||||
|
folder_id: i32,
|
||||||
|
ids: Vec<i32>,
|
||||||
|
) -> Result<(), DbError> {
|
||||||
|
folder_command_service::reorder(&db.conn, folder_id, ids).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn bootstrap_folder_commands_from_package_json(
|
||||||
|
db: tauri::State<'_, AppDatabase>,
|
||||||
|
folder_id: i32,
|
||||||
|
folder_path: String,
|
||||||
|
) -> Result<Vec<FolderCommandInfo>, DbError> {
|
||||||
|
let existing = folder_command_service::list_by_folder(&db.conn, folder_id).await?;
|
||||||
|
if !existing.is_empty() {
|
||||||
|
return Ok(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
let path_for_task = folder_path;
|
||||||
|
let commands_to_create =
|
||||||
|
tokio::task::spawn_blocking(move || load_package_scripts_as_commands(&path_for_task))
|
||||||
|
.await
|
||||||
|
.map_err(|e| DbError::Migration(format!("bootstrap task failed: {e}")))?;
|
||||||
|
|
||||||
|
if commands_to_create.is_empty() {
|
||||||
|
return Ok(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize bootstrap so concurrent calls do not create duplicate commands.
|
||||||
|
let _bootstrap_guard = BOOTSTRAP_FOLDER_COMMANDS_LOCK.lock().await;
|
||||||
|
|
||||||
|
let latest = folder_command_service::list_by_folder(&db.conn, folder_id).await?;
|
||||||
|
if !latest.is_empty() {
|
||||||
|
return Ok(latest);
|
||||||
|
}
|
||||||
|
|
||||||
|
folder_command_service::create_many(&db.conn, folder_id, &commands_to_create).await?;
|
||||||
|
|
||||||
|
folder_command_service::list_by_folder(&db.conn, folder_id).await
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
pub mod acp;
|
||||||
|
pub mod conversations;
|
||||||
|
pub mod folder_commands;
|
||||||
|
pub mod folders;
|
||||||
|
pub mod mcp;
|
||||||
|
pub mod system_settings;
|
||||||
|
pub mod terminal;
|
||||||
|
pub mod windows;
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
use sea_orm::DatabaseConnection;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::db::service::app_metadata_service;
|
||||||
|
use crate::db::AppDatabase;
|
||||||
|
use crate::models::SystemProxySettings;
|
||||||
|
use crate::network::proxy;
|
||||||
|
|
||||||
|
const SYSTEM_PROXY_SETTINGS_KEY: &str = "system_proxy_settings";
|
||||||
|
|
||||||
|
fn normalize_proxy_settings(settings: SystemProxySettings) -> Result<SystemProxySettings, String> {
|
||||||
|
if !settings.enabled {
|
||||||
|
let proxy_url = settings
|
||||||
|
.proxy_url
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(str::to_string);
|
||||||
|
|
||||||
|
return Ok(SystemProxySettings {
|
||||||
|
enabled: false,
|
||||||
|
proxy_url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let proxy_url = settings
|
||||||
|
.proxy_url
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.ok_or_else(|| "proxy url is required when proxy is enabled".to_string())?;
|
||||||
|
|
||||||
|
reqwest::Proxy::all(proxy_url).map_err(|e| format!("invalid proxy url: {e}"))?;
|
||||||
|
|
||||||
|
Ok(SystemProxySettings {
|
||||||
|
enabled: true,
|
||||||
|
proxy_url: Some(proxy_url.to_string()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn load_system_proxy_settings(
|
||||||
|
conn: &DatabaseConnection,
|
||||||
|
) -> Result<SystemProxySettings, String> {
|
||||||
|
let raw = app_metadata_service::get_value(conn, SYSTEM_PROXY_SETTINGS_KEY)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let Some(raw) = raw else {
|
||||||
|
return Ok(SystemProxySettings::default());
|
||||||
|
};
|
||||||
|
|
||||||
|
let parsed = serde_json::from_str::<SystemProxySettings>(&raw)
|
||||||
|
.map_err(|e| format!("failed to parse stored proxy settings: {e}"))?;
|
||||||
|
normalize_proxy_settings(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_system_proxy_settings(
|
||||||
|
db: State<'_, AppDatabase>,
|
||||||
|
) -> Result<SystemProxySettings, String> {
|
||||||
|
load_system_proxy_settings(&db.conn).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn update_system_proxy_settings(
|
||||||
|
settings: SystemProxySettings,
|
||||||
|
db: State<'_, AppDatabase>,
|
||||||
|
) -> Result<SystemProxySettings, String> {
|
||||||
|
let normalized = normalize_proxy_settings(settings)?;
|
||||||
|
let serialized = serde_json::to_string(&normalized).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
app_metadata_service::upsert_value(&db.conn, SYSTEM_PROXY_SETTINGS_KEY, &serialized)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
proxy::apply_system_proxy_settings(&normalized)?;
|
||||||
|
Ok(normalized)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::terminal::error::TerminalError;
|
||||||
|
use crate::terminal::manager::TerminalManager;
|
||||||
|
use crate::terminal::types::TerminalInfo;
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn terminal_spawn(
|
||||||
|
working_dir: String,
|
||||||
|
initial_command: Option<String>,
|
||||||
|
manager: State<'_, TerminalManager>,
|
||||||
|
app_handle: tauri::AppHandle,
|
||||||
|
window: tauri::WebviewWindow,
|
||||||
|
) -> Result<String, TerminalError> {
|
||||||
|
manager.spawn(
|
||||||
|
working_dir,
|
||||||
|
window.label().to_string(),
|
||||||
|
app_handle,
|
||||||
|
initial_command,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn terminal_write(
|
||||||
|
terminal_id: String,
|
||||||
|
data: String,
|
||||||
|
manager: State<'_, TerminalManager>,
|
||||||
|
) -> Result<(), TerminalError> {
|
||||||
|
manager.write(&terminal_id, data.as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn terminal_resize(
|
||||||
|
terminal_id: String,
|
||||||
|
cols: u16,
|
||||||
|
rows: u16,
|
||||||
|
manager: State<'_, TerminalManager>,
|
||||||
|
) -> Result<(), TerminalError> {
|
||||||
|
manager.resize(&terminal_id, cols, rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn terminal_kill(
|
||||||
|
terminal_id: String,
|
||||||
|
manager: State<'_, TerminalManager>,
|
||||||
|
) -> Result<(), TerminalError> {
|
||||||
|
manager.kill(&terminal_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn terminal_list(
|
||||||
|
manager: State<'_, TerminalManager>,
|
||||||
|
app_handle: tauri::AppHandle,
|
||||||
|
) -> Result<Vec<TerminalInfo>, TerminalError> {
|
||||||
|
Ok(manager.list_with_exit_check(Some(&app_handle)))
|
||||||
|
}
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder};
|
||||||
|
|
||||||
|
use crate::db::AppDatabase;
|
||||||
|
use crate::models::FolderHistoryEntry;
|
||||||
|
|
||||||
|
pub struct SettingsWindowState {
|
||||||
|
owner_window_label: Mutex<Option<String>>,
|
||||||
|
disabled_windows: Mutex<HashSet<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CommitWindowState {
|
||||||
|
owner_by_commit_label: Mutex<HashMap<String, String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn apply_platform_window_style<'a, R, M>(
|
||||||
|
builder: WebviewWindowBuilder<'a, R, M>,
|
||||||
|
) -> WebviewWindowBuilder<'a, R, M>
|
||||||
|
where
|
||||||
|
R: tauri::Runtime,
|
||||||
|
M: tauri::Manager<R>,
|
||||||
|
{
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
return builder
|
||||||
|
.hidden_title(true)
|
||||||
|
.title_bar_style(tauri::TitleBarStyle::Overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
return builder.decorations(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||||
|
{
|
||||||
|
builder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn ensure_windows_undecorated(window: &tauri::WebviewWindow) {
|
||||||
|
let _ = window.set_decorations(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
fn ensure_windows_undecorated(_window: &tauri::WebviewWindow) {}
|
||||||
|
|
||||||
|
impl SettingsWindowState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
owner_window_label: Mutex::new(None),
|
||||||
|
disabled_windows: Mutex::new(HashSet::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_owner(&self, label: String) {
|
||||||
|
if let Ok(mut owner) = self.owner_window_label.lock() {
|
||||||
|
*owner = Some(label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn take_owner(&self) -> Option<String> {
|
||||||
|
self.owner_window_label
|
||||||
|
.lock()
|
||||||
|
.ok()
|
||||||
|
.and_then(|mut owner| owner.take())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_disabled_windows(&self, labels: HashSet<String>) {
|
||||||
|
if let Ok(mut disabled) = self.disabled_windows.lock() {
|
||||||
|
*disabled = labels;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn take_disabled_windows(&self) -> HashSet<String> {
|
||||||
|
self.disabled_windows
|
||||||
|
.lock()
|
||||||
|
.map(|mut disabled| std::mem::take(&mut *disabled))
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommitWindowState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
owner_by_commit_label: Mutex::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_owner(&self, commit_label: String, owner_label: String) {
|
||||||
|
if let Ok(mut owners) = self.owner_by_commit_label.lock() {
|
||||||
|
owners.insert(commit_label, owner_label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn take_owner(&self, commit_label: &str) -> Option<String> {
|
||||||
|
self.owner_by_commit_label
|
||||||
|
.lock()
|
||||||
|
.ok()
|
||||||
|
.and_then(|mut owners| owners.remove(commit_label))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_folder_id_from_window(window: &tauri::WebviewWindow) -> Option<i32> {
|
||||||
|
let url = window.url().ok()?;
|
||||||
|
url.query_pairs()
|
||||||
|
.find(|(key, _)| key == "id")
|
||||||
|
.and_then(|(_, value)| value.parse::<i32>().ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_settings_route(section: Option<&str>) -> &'static str {
|
||||||
|
match section {
|
||||||
|
Some("appearance") => "settings/appearance",
|
||||||
|
Some("agents") => "settings/agents",
|
||||||
|
Some("mcp") => "settings/mcp",
|
||||||
|
Some("skills") => "settings/skills",
|
||||||
|
Some("shortcuts") => "settings/shortcuts",
|
||||||
|
Some("system") => "settings/system",
|
||||||
|
_ => "settings/system",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_agent_query(agent_type: Option<&str>) -> Option<String> {
|
||||||
|
let raw = agent_type?.trim();
|
||||||
|
if raw.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if raw
|
||||||
|
.chars()
|
||||||
|
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
|
||||||
|
{
|
||||||
|
return Some(raw.to_string());
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_settings_target(section: Option<&str>, agent_type: Option<&str>) -> String {
|
||||||
|
let route = resolve_settings_route(section);
|
||||||
|
if route == "settings/agents" {
|
||||||
|
if let Some(agent) = normalize_agent_query(agent_type) {
|
||||||
|
return format!("{route}?agent={agent}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
route.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_open_folders(
|
||||||
|
app: AppHandle,
|
||||||
|
db: tauri::State<'_, AppDatabase>,
|
||||||
|
) -> Result<Vec<FolderHistoryEntry>, String> {
|
||||||
|
let windows = app.webview_windows();
|
||||||
|
let mut folder_ids: Vec<i32> = Vec::new();
|
||||||
|
|
||||||
|
for (label, window) in &windows {
|
||||||
|
if label.starts_with("folder-") {
|
||||||
|
if let Some(id) = get_folder_id_from_window(window) {
|
||||||
|
folder_ids.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let all_folders = crate::db::service::folder_service::list_folders(&db.conn)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let open_folders: Vec<FolderHistoryEntry> = all_folders
|
||||||
|
.into_iter()
|
||||||
|
.filter(|f| folder_ids.contains(&f.id))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(open_folders)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn focus_folder_window(app: AppHandle, folder_id: i32) -> Result<(), String> {
|
||||||
|
let windows = app.webview_windows();
|
||||||
|
for (label, window) in &windows {
|
||||||
|
if label.starts_with("folder-") {
|
||||||
|
if let Some(id) = get_folder_id_from_window(window) {
|
||||||
|
if id == folder_id {
|
||||||
|
window.set_focus().map_err(|e| e.to_string())?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(format!("No open window for folder {}", folder_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn open_folder_window(
|
||||||
|
app: AppHandle,
|
||||||
|
db: tauri::State<'_, AppDatabase>,
|
||||||
|
path: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
// Add to history via DB
|
||||||
|
let entry = crate::db::service::folder_service::add_folder(&db.conn, &path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Create folder window with unique label
|
||||||
|
let label = format!("folder-{}", uuid::Uuid::new_v4());
|
||||||
|
let url = WebviewUrl::App(format!("folder?id={}", entry.id).into());
|
||||||
|
let builder = WebviewWindowBuilder::new(&app, &label, url)
|
||||||
|
.title(&entry.name)
|
||||||
|
.inner_size(1260.0, 860.0)
|
||||||
|
.min_inner_size(900.0, 600.0);
|
||||||
|
let folder_window = apply_platform_window_style(builder)
|
||||||
|
.build()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
ensure_windows_undecorated(&folder_window);
|
||||||
|
|
||||||
|
// Close welcome window
|
||||||
|
if let Some(w) = app.get_webview_window("welcome") {
|
||||||
|
w.close().map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn open_commit_window(
|
||||||
|
app: AppHandle,
|
||||||
|
window: tauri::WebviewWindow,
|
||||||
|
db: tauri::State<'_, AppDatabase>,
|
||||||
|
state: tauri::State<'_, CommitWindowState>,
|
||||||
|
folder_id: i32,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let owner_label = window.label().to_string();
|
||||||
|
let label = format!("commit-{folder_id}");
|
||||||
|
|
||||||
|
if let Some(existing) = app.get_webview_window(&label) {
|
||||||
|
if let Some(owner_window) = app.get_webview_window(&owner_label) {
|
||||||
|
owner_window.set_enabled(false).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
state.set_owner(label.clone(), owner_label);
|
||||||
|
let _ = existing.unminimize();
|
||||||
|
existing.set_focus().map_err(|e| e.to_string())?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let folder = crate::db::service::folder_service::get_folder_by_id(&db.conn, folder_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.ok_or_else(|| format!("Folder {} not found", folder_id))?;
|
||||||
|
|
||||||
|
let url = WebviewUrl::App(format!("commit?folderId={folder_id}").into());
|
||||||
|
let builder = WebviewWindowBuilder::new(&app, &label, url)
|
||||||
|
.title(&format!("提交代码 - {}", folder.name))
|
||||||
|
.inner_size(1220.0, 820.0)
|
||||||
|
.min_inner_size(980.0, 620.0)
|
||||||
|
.always_on_top(true)
|
||||||
|
.center();
|
||||||
|
let commit_window = apply_platform_window_style(builder)
|
||||||
|
.build()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
ensure_windows_undecorated(&commit_window);
|
||||||
|
if let Some(owner_window) = app.get_webview_window(&owner_label) {
|
||||||
|
if let Err(err) = owner_window.set_enabled(false) {
|
||||||
|
let _ = commit_window.close();
|
||||||
|
return Err(err.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.set_owner(label, owner_label);
|
||||||
|
commit_window.set_focus().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn open_settings_window(
|
||||||
|
app: AppHandle,
|
||||||
|
window: tauri::WebviewWindow,
|
||||||
|
section: Option<String>,
|
||||||
|
agent_type: Option<String>,
|
||||||
|
state: tauri::State<'_, SettingsWindowState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let target_route = resolve_settings_target(section.as_deref(), agent_type.as_deref());
|
||||||
|
if let Some(existing) = app.get_webview_window("settings") {
|
||||||
|
ensure_windows_undecorated(&existing);
|
||||||
|
if section.is_some() || agent_type.is_some() {
|
||||||
|
let target_path = format!("/{target_route}");
|
||||||
|
let target_json = serde_json::to_string(&target_path).map_err(|e| e.to_string())?;
|
||||||
|
let nav_script = format!("window.location.replace({target_json});");
|
||||||
|
existing.eval(&nav_script).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
let _ = existing.unminimize();
|
||||||
|
existing.set_focus().map_err(|e| e.to_string())?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let owner_label = window.label().to_string();
|
||||||
|
let url = WebviewUrl::App(target_route.into());
|
||||||
|
let builder = WebviewWindowBuilder::new(&app, "settings", url)
|
||||||
|
.title("Settings")
|
||||||
|
.inner_size(1080.0, 700.0)
|
||||||
|
.min_inner_size(1080.0, 600.0)
|
||||||
|
.always_on_top(true)
|
||||||
|
.center();
|
||||||
|
let settings_window = apply_platform_window_style(builder)
|
||||||
|
.build()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
ensure_windows_undecorated(&settings_window);
|
||||||
|
|
||||||
|
let mut disabled = HashSet::new();
|
||||||
|
for (label, webview) in app.webview_windows() {
|
||||||
|
if label != "settings" {
|
||||||
|
webview.set_enabled(false).map_err(|e| e.to_string())?;
|
||||||
|
disabled.insert(label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.set_owner(owner_label);
|
||||||
|
state.set_disabled_windows(disabled);
|
||||||
|
settings_window.set_focus().map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn restore_windows_after_settings(app: &AppHandle, state: &SettingsWindowState) {
|
||||||
|
for label in state.take_disabled_windows() {
|
||||||
|
if let Some(window) = app.get_webview_window(&label) {
|
||||||
|
let _ = window.set_enabled(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(owner_label) = state.take_owner() {
|
||||||
|
if let Some(window) = app.get_webview_window(&owner_label) {
|
||||||
|
let _ = window.set_focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn restore_window_after_commit(
|
||||||
|
app: &AppHandle,
|
||||||
|
state: &CommitWindowState,
|
||||||
|
commit_window_label: &str,
|
||||||
|
) {
|
||||||
|
if let Some(owner_label) = state.take_owner(commit_window_label) {
|
||||||
|
if let Some(window) = app.get_webview_window(&owner_label) {
|
||||||
|
let _ = window.set_enabled(true);
|
||||||
|
let _ = window.set_focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open_welcome_window(app: &AppHandle) -> Result<(), String> {
|
||||||
|
if let Some(existing) = app.get_webview_window("welcome") {
|
||||||
|
ensure_windows_undecorated(&existing);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let url = WebviewUrl::App("welcome".into());
|
||||||
|
let builder = WebviewWindowBuilder::new(app, "welcome", url)
|
||||||
|
.title("Codeg")
|
||||||
|
.inner_size(800.0, 520.0)
|
||||||
|
.min_inner_size(600.0, 400.0)
|
||||||
|
.center();
|
||||||
|
let welcome_window = apply_platform_window_style(builder)
|
||||||
|
.build()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
ensure_windows_undecorated(&welcome_window);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "agent_setting")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i32,
|
||||||
|
pub agent_type: String,
|
||||||
|
pub registry_id: String,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub sort_order: i32,
|
||||||
|
pub installed_version: Option<String>,
|
||||||
|
pub env_json: Option<String>,
|
||||||
|
pub created_at: DateTimeUtc,
|
||||||
|
pub updated_at: DateTimeUtc,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "app_metadata")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i32,
|
||||||
|
#[sea_orm(unique)]
|
||||||
|
pub key: String,
|
||||||
|
pub value: String,
|
||||||
|
pub created_at: DateTimeUtc,
|
||||||
|
pub updated_at: DateTimeUtc,
|
||||||
|
pub deleted_at: Option<DateTimeUtc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ConversationStatus {
|
||||||
|
#[sea_orm(string_value = "in_progress")]
|
||||||
|
InProgress,
|
||||||
|
#[sea_orm(string_value = "pending_review")]
|
||||||
|
PendingReview,
|
||||||
|
#[sea_orm(string_value = "completed")]
|
||||||
|
Completed,
|
||||||
|
#[sea_orm(string_value = "cancelled")]
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "conversation")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i32,
|
||||||
|
pub folder_id: i32,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub agent_type: String,
|
||||||
|
pub status: ConversationStatus,
|
||||||
|
pub model: Option<String>,
|
||||||
|
pub git_branch: Option<String>,
|
||||||
|
pub external_id: Option<String>,
|
||||||
|
pub parent_id: Option<i32>,
|
||||||
|
pub message_count: i32,
|
||||||
|
pub created_at: DateTimeUtc,
|
||||||
|
pub updated_at: DateTimeUtc,
|
||||||
|
pub deleted_at: Option<DateTimeUtc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::folder::Entity",
|
||||||
|
from = "Column::FolderId",
|
||||||
|
to = "super::folder::Column::Id"
|
||||||
|
)]
|
||||||
|
Folder,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::folder::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Folder.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "folder")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i32,
|
||||||
|
pub name: String,
|
||||||
|
#[sea_orm(unique)]
|
||||||
|
pub path: String,
|
||||||
|
pub git_branch: Option<String>,
|
||||||
|
pub default_agent_type: Option<String>,
|
||||||
|
pub last_opened_at: DateTimeUtc,
|
||||||
|
pub created_at: DateTimeUtc,
|
||||||
|
pub updated_at: DateTimeUtc,
|
||||||
|
pub deleted_at: Option<DateTimeUtc>,
|
||||||
|
pub is_open: bool,
|
||||||
|
pub parent_branch: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(has_many = "super::conversation::Entity")]
|
||||||
|
Conversations,
|
||||||
|
|
||||||
|
#[sea_orm(has_many = "super::folder_opened_conversation::Entity")]
|
||||||
|
OpenedConversations,
|
||||||
|
|
||||||
|
#[sea_orm(has_many = "super::folder_command::Entity")]
|
||||||
|
FolderCommands,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::conversation::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Conversations.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::folder_opened_conversation::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::OpenedConversations.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::folder_command::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::FolderCommands.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "folder_command")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i32,
|
||||||
|
pub folder_id: i32,
|
||||||
|
pub name: String,
|
||||||
|
pub command: String,
|
||||||
|
pub sort_order: i32,
|
||||||
|
pub created_at: DateTimeUtc,
|
||||||
|
pub updated_at: DateTimeUtc,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::folder::Entity",
|
||||||
|
from = "Column::FolderId",
|
||||||
|
to = "super::folder::Column::Id"
|
||||||
|
)]
|
||||||
|
Folder,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::folder::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Folder.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "folder_opened_conversation")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i32,
|
||||||
|
pub folder_id: i32,
|
||||||
|
pub conversation_id: i32,
|
||||||
|
pub position: i32,
|
||||||
|
pub is_active: bool,
|
||||||
|
pub is_pinned: bool,
|
||||||
|
pub agent_type: String,
|
||||||
|
pub created_at: DateTimeUtc,
|
||||||
|
pub updated_at: DateTimeUtc,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::folder::Entity",
|
||||||
|
from = "Column::FolderId",
|
||||||
|
to = "super::folder::Column::Id"
|
||||||
|
)]
|
||||||
|
Folder,
|
||||||
|
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::conversation::Entity",
|
||||||
|
from = "Column::ConversationId",
|
||||||
|
to = "super::conversation::Column::Id"
|
||||||
|
)]
|
||||||
|
Conversation,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::folder::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Folder.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::conversation::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Conversation.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
pub mod agent_setting;
|
||||||
|
pub mod app_metadata;
|
||||||
|
pub mod conversation;
|
||||||
|
pub mod folder;
|
||||||
|
pub mod folder_command;
|
||||||
|
pub mod folder_opened_conversation;
|
||||||
|
pub mod prelude;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
#![allow(unused_imports)]
|
||||||
|
|
||||||
|
pub use super::agent_setting::Entity as AgentSetting;
|
||||||
|
pub use super::app_metadata::Entity as AppMetadata;
|
||||||
|
pub use super::conversation::Entity as Conversation;
|
||||||
|
pub use super::folder::Entity as Folder;
|
||||||
|
pub use super::folder_command::Entity as FolderCommand;
|
||||||
|
pub use super::folder_opened_conversation::Entity as FolderOpenedConversation;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum DbError {
|
||||||
|
#[error("database error: {0}")]
|
||||||
|
Database(#[from] sea_orm::DbErr),
|
||||||
|
#[error("migration error: {0}")]
|
||||||
|
Migration(String),
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[error("database not initialized")]
|
||||||
|
NotInitialized,
|
||||||
|
#[error("io error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serialize for DbError {
|
||||||
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: serde::Serializer,
|
||||||
|
{
|
||||||
|
serializer.serialize_str(&self.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
// 1. app_metadata table
|
||||||
|
manager
|
||||||
|
.create_table(
|
||||||
|
Table::create()
|
||||||
|
.table(AppMetadata::Table)
|
||||||
|
.if_not_exists()
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(AppMetadata::Id)
|
||||||
|
.integer()
|
||||||
|
.not_null()
|
||||||
|
.auto_increment()
|
||||||
|
.primary_key(),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(AppMetadata::Key)
|
||||||
|
.string()
|
||||||
|
.not_null()
|
||||||
|
.unique_key(),
|
||||||
|
)
|
||||||
|
.col(ColumnDef::new(AppMetadata::Value).string().not_null())
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(AppMetadata::CreatedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.not_null(),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(AppMetadata::UpdatedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.not_null(),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(AppMetadata::DeletedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.null(),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 2. folder table
|
||||||
|
manager
|
||||||
|
.create_table(
|
||||||
|
Table::create()
|
||||||
|
.table(Folder::Table)
|
||||||
|
.if_not_exists()
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Folder::Id)
|
||||||
|
.integer()
|
||||||
|
.not_null()
|
||||||
|
.auto_increment()
|
||||||
|
.primary_key(),
|
||||||
|
)
|
||||||
|
.col(ColumnDef::new(Folder::Name).string().not_null())
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Folder::Path)
|
||||||
|
.string()
|
||||||
|
.not_null()
|
||||||
|
.unique_key(),
|
||||||
|
)
|
||||||
|
.col(ColumnDef::new(Folder::GitBranch).string().null())
|
||||||
|
.col(ColumnDef::new(Folder::DefaultAgentType).string().null())
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Folder::LastOpenedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.not_null(),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Folder::CreatedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.not_null(),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Folder::UpdatedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.not_null(),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Folder::DeletedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.null(),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 3. conversation table
|
||||||
|
manager
|
||||||
|
.create_table(
|
||||||
|
Table::create()
|
||||||
|
.table(Conversation::Table)
|
||||||
|
.if_not_exists()
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Conversation::Id)
|
||||||
|
.integer()
|
||||||
|
.not_null()
|
||||||
|
.auto_increment()
|
||||||
|
.primary_key(),
|
||||||
|
)
|
||||||
|
.col(ColumnDef::new(Conversation::FolderId).integer().not_null())
|
||||||
|
.col(ColumnDef::new(Conversation::Title).string().null())
|
||||||
|
.col(ColumnDef::new(Conversation::AgentType).string().not_null())
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Conversation::Status)
|
||||||
|
.string()
|
||||||
|
.not_null()
|
||||||
|
.default("in_progress"),
|
||||||
|
)
|
||||||
|
.col(ColumnDef::new(Conversation::Model).string().null())
|
||||||
|
.col(ColumnDef::new(Conversation::GitBranch).string().null())
|
||||||
|
.col(ColumnDef::new(Conversation::ExternalId).string().null())
|
||||||
|
.col(ColumnDef::new(Conversation::ParentId).integer().null())
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Conversation::MessageCount)
|
||||||
|
.integer()
|
||||||
|
.not_null()
|
||||||
|
.default(0),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Conversation::CreatedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.not_null(),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Conversation::UpdatedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.not_null(),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Conversation::DeletedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.null(),
|
||||||
|
)
|
||||||
|
.foreign_key(
|
||||||
|
ForeignKey::create()
|
||||||
|
.name("fk_conversation_folder")
|
||||||
|
.from(Conversation::Table, Conversation::FolderId)
|
||||||
|
.to(Folder::Table, Folder::Id)
|
||||||
|
.on_delete(ForeignKeyAction::Cascade)
|
||||||
|
.on_update(ForeignKeyAction::Cascade),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 4. folder_opened_conversation table
|
||||||
|
manager
|
||||||
|
.create_table(
|
||||||
|
Table::create()
|
||||||
|
.table(FolderOpenedConversation::Table)
|
||||||
|
.if_not_exists()
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(FolderOpenedConversation::Id)
|
||||||
|
.integer()
|
||||||
|
.not_null()
|
||||||
|
.auto_increment()
|
||||||
|
.primary_key(),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(FolderOpenedConversation::FolderId)
|
||||||
|
.integer()
|
||||||
|
.not_null(),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(FolderOpenedConversation::ConversationId)
|
||||||
|
.integer()
|
||||||
|
.not_null(),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(FolderOpenedConversation::Position)
|
||||||
|
.integer()
|
||||||
|
.not_null(),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(FolderOpenedConversation::IsActive)
|
||||||
|
.boolean()
|
||||||
|
.not_null()
|
||||||
|
.default(false),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(FolderOpenedConversation::IsPinned)
|
||||||
|
.boolean()
|
||||||
|
.not_null()
|
||||||
|
.default(true),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(FolderOpenedConversation::AgentType)
|
||||||
|
.string()
|
||||||
|
.not_null(),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(FolderOpenedConversation::CreatedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.not_null(),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(FolderOpenedConversation::UpdatedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.not_null(),
|
||||||
|
)
|
||||||
|
.foreign_key(
|
||||||
|
ForeignKey::create()
|
||||||
|
.name("fk_foc_folder")
|
||||||
|
.from(
|
||||||
|
FolderOpenedConversation::Table,
|
||||||
|
FolderOpenedConversation::FolderId,
|
||||||
|
)
|
||||||
|
.to(Folder::Table, Folder::Id)
|
||||||
|
.on_delete(ForeignKeyAction::Cascade)
|
||||||
|
.on_update(ForeignKeyAction::Cascade),
|
||||||
|
)
|
||||||
|
.foreign_key(
|
||||||
|
ForeignKey::create()
|
||||||
|
.name("fk_foc_conversation")
|
||||||
|
.from(
|
||||||
|
FolderOpenedConversation::Table,
|
||||||
|
FolderOpenedConversation::ConversationId,
|
||||||
|
)
|
||||||
|
.to(Conversation::Table, Conversation::Id)
|
||||||
|
.on_delete(ForeignKeyAction::Cascade)
|
||||||
|
.on_update(ForeignKeyAction::Cascade),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 6. indexes
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_folder_deleted_last_opened")
|
||||||
|
.table(Folder::Table)
|
||||||
|
.col(Folder::DeletedAt)
|
||||||
|
.col(Folder::LastOpenedAt)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_conversation_folder_id")
|
||||||
|
.table(Conversation::Table)
|
||||||
|
.col(Conversation::FolderId)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_conversation_deleted_created")
|
||||||
|
.table(Conversation::Table)
|
||||||
|
.col(Conversation::DeletedAt)
|
||||||
|
.col(Conversation::CreatedAt)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_conversation_external_agent")
|
||||||
|
.table(Conversation::Table)
|
||||||
|
.col(Conversation::ExternalId)
|
||||||
|
.col(Conversation::AgentType)
|
||||||
|
.unique()
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_foc_folder_position")
|
||||||
|
.table(FolderOpenedConversation::Table)
|
||||||
|
.col(FolderOpenedConversation::FolderId)
|
||||||
|
.col(FolderOpenedConversation::Position)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_foc_folder_conversation")
|
||||||
|
.table(FolderOpenedConversation::Table)
|
||||||
|
.col(FolderOpenedConversation::FolderId)
|
||||||
|
.col(FolderOpenedConversation::ConversationId)
|
||||||
|
.unique()
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.drop_table(
|
||||||
|
Table::drop()
|
||||||
|
.table(FolderOpenedConversation::Table)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
manager
|
||||||
|
.drop_table(Table::drop().table(Conversation::Table).to_owned())
|
||||||
|
.await?;
|
||||||
|
manager
|
||||||
|
.drop_table(Table::drop().table(Folder::Table).to_owned())
|
||||||
|
.await?;
|
||||||
|
manager
|
||||||
|
.drop_table(Table::drop().table(AppMetadata::Table).to_owned())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum AppMetadata {
|
||||||
|
Table,
|
||||||
|
Id,
|
||||||
|
Key,
|
||||||
|
Value,
|
||||||
|
CreatedAt,
|
||||||
|
UpdatedAt,
|
||||||
|
DeletedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum Folder {
|
||||||
|
Table,
|
||||||
|
Id,
|
||||||
|
Name,
|
||||||
|
Path,
|
||||||
|
GitBranch,
|
||||||
|
DefaultAgentType,
|
||||||
|
LastOpenedAt,
|
||||||
|
CreatedAt,
|
||||||
|
UpdatedAt,
|
||||||
|
DeletedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum Conversation {
|
||||||
|
Table,
|
||||||
|
Id,
|
||||||
|
FolderId,
|
||||||
|
Title,
|
||||||
|
AgentType,
|
||||||
|
Status,
|
||||||
|
Model,
|
||||||
|
GitBranch,
|
||||||
|
ExternalId,
|
||||||
|
ParentId,
|
||||||
|
MessageCount,
|
||||||
|
CreatedAt,
|
||||||
|
UpdatedAt,
|
||||||
|
DeletedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum FolderOpenedConversation {
|
||||||
|
Table,
|
||||||
|
Id,
|
||||||
|
FolderId,
|
||||||
|
ConversationId,
|
||||||
|
Position,
|
||||||
|
IsActive,
|
||||||
|
IsPinned,
|
||||||
|
AgentType,
|
||||||
|
CreatedAt,
|
||||||
|
UpdatedAt,
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.create_table(
|
||||||
|
Table::create()
|
||||||
|
.table(FolderCommand::Table)
|
||||||
|
.if_not_exists()
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(FolderCommand::Id)
|
||||||
|
.integer()
|
||||||
|
.not_null()
|
||||||
|
.auto_increment()
|
||||||
|
.primary_key(),
|
||||||
|
)
|
||||||
|
.col(ColumnDef::new(FolderCommand::FolderId).integer().not_null())
|
||||||
|
.col(ColumnDef::new(FolderCommand::Name).string().not_null())
|
||||||
|
.col(ColumnDef::new(FolderCommand::Command).string().not_null())
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(FolderCommand::SortOrder)
|
||||||
|
.integer()
|
||||||
|
.not_null()
|
||||||
|
.default(0),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(FolderCommand::CreatedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.not_null(),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(FolderCommand::UpdatedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.not_null(),
|
||||||
|
)
|
||||||
|
.foreign_key(
|
||||||
|
ForeignKey::create()
|
||||||
|
.name("fk_folder_command_folder")
|
||||||
|
.from(FolderCommand::Table, FolderCommand::FolderId)
|
||||||
|
.to(Folder::Table, Folder::Id)
|
||||||
|
.on_delete(ForeignKeyAction::Cascade)
|
||||||
|
.on_update(ForeignKeyAction::Cascade),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_folder_command_folder_id")
|
||||||
|
.table(FolderCommand::Table)
|
||||||
|
.col(FolderCommand::FolderId)
|
||||||
|
.col(FolderCommand::SortOrder)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.drop_table(Table::drop().table(FolderCommand::Table).to_owned())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum FolderCommand {
|
||||||
|
Table,
|
||||||
|
Id,
|
||||||
|
FolderId,
|
||||||
|
Name,
|
||||||
|
Command,
|
||||||
|
SortOrder,
|
||||||
|
CreatedAt,
|
||||||
|
UpdatedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum Folder {
|
||||||
|
Table,
|
||||||
|
Id,
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Folder::Table)
|
||||||
|
.add_column(
|
||||||
|
ColumnDef::new(Folder::IsOpen)
|
||||||
|
.boolean()
|
||||||
|
.not_null()
|
||||||
|
.default(false),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Folder::Table)
|
||||||
|
.drop_column(Folder::IsOpen)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum Folder {
|
||||||
|
Table,
|
||||||
|
IsOpen,
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.create_table(
|
||||||
|
Table::create()
|
||||||
|
.table(AgentSetting::Table)
|
||||||
|
.if_not_exists()
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(AgentSetting::Id)
|
||||||
|
.integer()
|
||||||
|
.not_null()
|
||||||
|
.auto_increment()
|
||||||
|
.primary_key(),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(AgentSetting::AgentType)
|
||||||
|
.string()
|
||||||
|
.not_null()
|
||||||
|
.unique_key(),
|
||||||
|
)
|
||||||
|
.col(ColumnDef::new(AgentSetting::RegistryId).string().not_null())
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(AgentSetting::Enabled)
|
||||||
|
.boolean()
|
||||||
|
.not_null()
|
||||||
|
.default(true),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(AgentSetting::SortOrder)
|
||||||
|
.integer()
|
||||||
|
.not_null()
|
||||||
|
.default(0),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(AgentSetting::InstalledVersion)
|
||||||
|
.string()
|
||||||
|
.null(),
|
||||||
|
)
|
||||||
|
.col(ColumnDef::new(AgentSetting::EnvJson).text().null())
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(AgentSetting::CreatedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.not_null(),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(AgentSetting::UpdatedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.not_null(),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_agent_setting_sort_order")
|
||||||
|
.table(AgentSetting::Table)
|
||||||
|
.col(AgentSetting::SortOrder)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.drop_table(Table::drop().table(AgentSetting::Table).to_owned())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum AgentSetting {
|
||||||
|
Table,
|
||||||
|
Id,
|
||||||
|
AgentType,
|
||||||
|
RegistryId,
|
||||||
|
Enabled,
|
||||||
|
SortOrder,
|
||||||
|
InstalledVersion,
|
||||||
|
EnvJson,
|
||||||
|
CreatedAt,
|
||||||
|
UpdatedAt,
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Folder::Table)
|
||||||
|
.add_column(ColumnDef::new(Folder::ParentBranch).string().null())
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Folder::Table)
|
||||||
|
.drop_column(Folder::ParentBranch)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum Folder {
|
||||||
|
Table,
|
||||||
|
ParentBranch,
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
mod m20260211_000001_init;
|
||||||
|
mod m20260219_000001_folder_command;
|
||||||
|
mod m20260221_000001_folder_is_open;
|
||||||
|
mod m20260226_000001_agent_setting;
|
||||||
|
mod m20260227_000001_folder_parent_branch;
|
||||||
|
pub struct Migrator;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigratorTrait for Migrator {
|
||||||
|
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
|
||||||
|
vec![
|
||||||
|
Box::new(m20260211_000001_init::Migration),
|
||||||
|
Box::new(m20260219_000001_folder_command::Migration),
|
||||||
|
Box::new(m20260221_000001_folder_is_open::Migration),
|
||||||
|
Box::new(m20260226_000001_agent_setting::Migration),
|
||||||
|
Box::new(m20260227_000001_folder_parent_branch::Migration),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
pub mod entities;
|
||||||
|
pub mod error;
|
||||||
|
pub mod migration;
|
||||||
|
pub mod service;
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use sea_orm::{
|
||||||
|
ConnectOptions, ConnectionTrait, Database, DatabaseConnection, DbBackend, Statement,
|
||||||
|
};
|
||||||
|
use sea_orm_migration::MigratorTrait;
|
||||||
|
|
||||||
|
use error::DbError;
|
||||||
|
use migration::Migrator;
|
||||||
|
|
||||||
|
pub struct AppDatabase {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub conn: DatabaseConnection,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn init_database(
|
||||||
|
app_data_dir: impl AsRef<Path>,
|
||||||
|
app_version: &str,
|
||||||
|
) -> Result<AppDatabase, DbError> {
|
||||||
|
let app_data_dir = app_data_dir.as_ref();
|
||||||
|
std::fs::create_dir_all(app_data_dir)?;
|
||||||
|
|
||||||
|
let db_path = app_data_dir.join("codeg.db");
|
||||||
|
let db_url = format!(
|
||||||
|
"sqlite:{}?mode=rwc",
|
||||||
|
urlencoding::encode(&db_path.to_string_lossy())
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut opts = ConnectOptions::new(db_url);
|
||||||
|
opts.max_connections(5)
|
||||||
|
.min_connections(1)
|
||||||
|
.connect_timeout(Duration::from_secs(10))
|
||||||
|
.idle_timeout(Duration::from_secs(300))
|
||||||
|
.sqlx_logging(false);
|
||||||
|
|
||||||
|
let conn = Database::connect(opts).await?;
|
||||||
|
|
||||||
|
// SQLite performance and reliability pragmas
|
||||||
|
conn.execute(Statement::from_string(
|
||||||
|
DbBackend::Sqlite,
|
||||||
|
"PRAGMA journal_mode=WAL;".to_owned(),
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
conn.execute(Statement::from_string(
|
||||||
|
DbBackend::Sqlite,
|
||||||
|
"PRAGMA busy_timeout=5000;".to_owned(),
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
conn.execute(Statement::from_string(
|
||||||
|
DbBackend::Sqlite,
|
||||||
|
"PRAGMA synchronous=NORMAL;".to_owned(),
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
conn.execute(Statement::from_string(
|
||||||
|
DbBackend::Sqlite,
|
||||||
|
"PRAGMA foreign_keys=ON;".to_owned(),
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
conn.execute(Statement::from_string(
|
||||||
|
DbBackend::Sqlite,
|
||||||
|
"PRAGMA cache_size=-8000;".to_owned(),
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Migrator::up(&conn, None)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DbError::Migration(e.to_string()))?;
|
||||||
|
|
||||||
|
service::app_metadata_service::update_app_version(&conn, app_version).await?;
|
||||||
|
|
||||||
|
Ok(AppDatabase { conn })
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use sea_orm::DatabaseConnection;
|
||||||
|
use sea_orm::{
|
||||||
|
ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, ConnectionTrait, DbBackend, EntityTrait,
|
||||||
|
IntoActiveModel, QueryFilter, QueryOrder, Set, Statement,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::db::entities::agent_setting;
|
||||||
|
use crate::db::error::DbError;
|
||||||
|
use crate::models::agent::AgentType;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AgentDefaultInput {
|
||||||
|
pub agent_type: AgentType,
|
||||||
|
pub registry_id: String,
|
||||||
|
pub default_sort_order: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AgentSettingsUpdate {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub env_json: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_enabled(agent_type: AgentType) -> bool {
|
||||||
|
matches!(
|
||||||
|
agent_type,
|
||||||
|
AgentType::ClaudeCode
|
||||||
|
| AgentType::Codex
|
||||||
|
| AgentType::Gemini
|
||||||
|
| AgentType::OpenCode
|
||||||
|
| AgentType::OpenClaw
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ensure_defaults(
|
||||||
|
conn: &DatabaseConnection,
|
||||||
|
defaults: &[AgentDefaultInput],
|
||||||
|
) -> Result<(), DbError> {
|
||||||
|
for default in defaults {
|
||||||
|
let agent_type = serde_json::to_string(&default.agent_type)
|
||||||
|
.map_err(|e| DbError::Migration(format!("agent_type serialize failed: {e}")))?;
|
||||||
|
let existing = agent_setting::Entity::find()
|
||||||
|
.filter(agent_setting::Column::AgentType.eq(agent_type.clone()))
|
||||||
|
.one(conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some(model) = existing {
|
||||||
|
if model.registry_id != default.registry_id {
|
||||||
|
let mut active = model.into_active_model();
|
||||||
|
active.registry_id = Set(default.registry_id.clone());
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.update(conn).await?;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
let active = agent_setting::ActiveModel {
|
||||||
|
id: NotSet,
|
||||||
|
agent_type: Set(agent_type),
|
||||||
|
registry_id: Set(default.registry_id.clone()),
|
||||||
|
enabled: Set(default_enabled(default.agent_type)),
|
||||||
|
sort_order: Set(default.default_sort_order),
|
||||||
|
installed_version: Set(None),
|
||||||
|
env_json: Set(None),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
};
|
||||||
|
match active.insert(conn).await {
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) if e.to_string().contains("UNIQUE constraint failed") => {
|
||||||
|
// Another concurrent call already inserted this row — safe to ignore.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list(conn: &DatabaseConnection) -> Result<Vec<agent_setting::Model>, DbError> {
|
||||||
|
let rows = agent_setting::Entity::find()
|
||||||
|
.order_by_asc(agent_setting::Column::SortOrder)
|
||||||
|
.all(conn)
|
||||||
|
.await?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_map_by_agent_type(
|
||||||
|
conn: &DatabaseConnection,
|
||||||
|
) -> Result<HashMap<AgentType, agent_setting::Model>, DbError> {
|
||||||
|
let rows = list(conn).await?;
|
||||||
|
let mut map = HashMap::new();
|
||||||
|
for row in rows {
|
||||||
|
if let Ok(agent_type) = serde_json::from_str::<AgentType>(&row.agent_type) {
|
||||||
|
map.insert(agent_type, row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_by_agent_type(
|
||||||
|
conn: &DatabaseConnection,
|
||||||
|
agent_type: AgentType,
|
||||||
|
) -> Result<Option<agent_setting::Model>, DbError> {
|
||||||
|
let agent_type_str = serde_json::to_string(&agent_type)
|
||||||
|
.map_err(|e| DbError::Migration(format!("agent_type serialize failed: {e}")))?;
|
||||||
|
let model = agent_setting::Entity::find()
|
||||||
|
.filter(agent_setting::Column::AgentType.eq(agent_type_str))
|
||||||
|
.one(conn)
|
||||||
|
.await?;
|
||||||
|
Ok(model)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update(
|
||||||
|
conn: &DatabaseConnection,
|
||||||
|
agent_type: AgentType,
|
||||||
|
patch: AgentSettingsUpdate,
|
||||||
|
) -> Result<(), DbError> {
|
||||||
|
let agent_type_str = serde_json::to_string(&agent_type)
|
||||||
|
.map_err(|e| DbError::Migration(format!("agent_type serialize failed: {e}")))?;
|
||||||
|
let model = agent_setting::Entity::find()
|
||||||
|
.filter(agent_setting::Column::AgentType.eq(agent_type_str.clone()))
|
||||||
|
.one(conn)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| DbError::Migration(format!("agent setting not found: {agent_type_str}")))?;
|
||||||
|
|
||||||
|
let mut active = model.into_active_model();
|
||||||
|
active.enabled = Set(patch.enabled);
|
||||||
|
active.env_json = Set(patch.env_json);
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.update(conn).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_installed_version(
|
||||||
|
conn: &DatabaseConnection,
|
||||||
|
agent_type: AgentType,
|
||||||
|
installed_version: Option<String>,
|
||||||
|
) -> Result<(), DbError> {
|
||||||
|
let agent_type_str = serde_json::to_string(&agent_type)
|
||||||
|
.map_err(|e| DbError::Migration(format!("agent_type serialize failed: {e}")))?;
|
||||||
|
if let Some(model) = agent_setting::Entity::find()
|
||||||
|
.filter(agent_setting::Column::AgentType.eq(agent_type_str))
|
||||||
|
.one(conn)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
let mut active = model.into_active_model();
|
||||||
|
active.installed_version = Set(installed_version);
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.update(conn).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn reorder(conn: &DatabaseConnection, agent_types: &[AgentType]) -> Result<(), DbError> {
|
||||||
|
if agent_types.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
match reorder_once(conn, agent_types).await {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(err) if is_sqlite_full_error(&err) => {
|
||||||
|
// Try truncating WAL once to reclaim space and retry.
|
||||||
|
conn.execute(Statement::from_string(
|
||||||
|
DbBackend::Sqlite,
|
||||||
|
"PRAGMA wal_checkpoint(TRUNCATE);".to_owned(),
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
reorder_once(conn, agent_types).await
|
||||||
|
}
|
||||||
|
Err(err) => Err(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reorder_once(conn: &DatabaseConnection, agent_types: &[AgentType]) -> Result<(), DbError> {
|
||||||
|
let now = Utc::now();
|
||||||
|
for (index, agent_type) in agent_types.iter().enumerate() {
|
||||||
|
let agent_type_str = serde_json::to_string(agent_type)
|
||||||
|
.map_err(|e| DbError::Migration(format!("agent_type serialize failed: {e}")))?;
|
||||||
|
|
||||||
|
if let Some(model) = agent_setting::Entity::find()
|
||||||
|
.filter(agent_setting::Column::AgentType.eq(agent_type_str))
|
||||||
|
.one(conn)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
// Skip unchanged rows to reduce write pressure when repeatedly dragging.
|
||||||
|
if model.sort_order == index as i32 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut active = model.into_active_model();
|
||||||
|
active.sort_order = Set(index as i32);
|
||||||
|
active.updated_at = Set(now.clone());
|
||||||
|
active.update(conn).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_sqlite_full_error(err: &DbError) -> bool {
|
||||||
|
let message = err.to_string();
|
||||||
|
message.contains("database or disk is full") || message.contains("(code: 13)")
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
use chrono::Utc;
|
||||||
|
use sea_orm::sea_query::OnConflict;
|
||||||
|
use sea_orm::DatabaseConnection;
|
||||||
|
use sea_orm::{ActiveValue::NotSet, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||||
|
|
||||||
|
use crate::db::entities::app_metadata;
|
||||||
|
use crate::db::error::DbError;
|
||||||
|
|
||||||
|
pub async fn upsert_value(
|
||||||
|
conn: &DatabaseConnection,
|
||||||
|
key: &str,
|
||||||
|
value: &str,
|
||||||
|
) -> Result<(), DbError> {
|
||||||
|
let now = Utc::now();
|
||||||
|
|
||||||
|
app_metadata::Entity::insert(app_metadata::ActiveModel {
|
||||||
|
id: NotSet,
|
||||||
|
key: Set(key.to_string()),
|
||||||
|
value: Set(value.to_string()),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
deleted_at: NotSet,
|
||||||
|
})
|
||||||
|
.on_conflict(
|
||||||
|
OnConflict::column(app_metadata::Column::Key)
|
||||||
|
.update_columns([app_metadata::Column::Value, app_metadata::Column::UpdatedAt])
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.exec(conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_value(conn: &DatabaseConnection, key: &str) -> Result<Option<String>, DbError> {
|
||||||
|
let model = app_metadata::Entity::find()
|
||||||
|
.filter(app_metadata::Column::Key.eq(key))
|
||||||
|
.filter(app_metadata::Column::DeletedAt.is_null())
|
||||||
|
.one(conn)
|
||||||
|
.await?;
|
||||||
|
Ok(model.map(|m| m.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_app_version(
|
||||||
|
conn: &DatabaseConnection,
|
||||||
|
app_version: &str,
|
||||||
|
) -> Result<(), DbError> {
|
||||||
|
let now = Utc::now();
|
||||||
|
|
||||||
|
app_metadata::Entity::insert(app_metadata::ActiveModel {
|
||||||
|
id: NotSet,
|
||||||
|
key: Set("app_version".to_string()),
|
||||||
|
value: Set(app_version.to_string()),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
deleted_at: NotSet,
|
||||||
|
})
|
||||||
|
.on_conflict(
|
||||||
|
OnConflict::column(app_metadata::Column::Key)
|
||||||
|
.update_columns([app_metadata::Column::Value, app_metadata::Column::UpdatedAt])
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.exec(conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
app_metadata::Entity::insert(app_metadata::ActiveModel {
|
||||||
|
id: NotSet,
|
||||||
|
key: Set("db_initialized_at".to_string()),
|
||||||
|
value: Set(now.to_rfc3339()),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
deleted_at: NotSet,
|
||||||
|
})
|
||||||
|
.on_conflict(
|
||||||
|
OnConflict::column(app_metadata::Column::Key)
|
||||||
|
.do_nothing()
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.do_nothing()
|
||||||
|
.exec(conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
use chrono::Utc;
|
||||||
|
use sea_orm::{
|
||||||
|
ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, DatabaseConnection, EntityTrait,
|
||||||
|
QueryFilter, QueryOrder, Set,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::db::entities::conversation;
|
||||||
|
use crate::db::error::DbError;
|
||||||
|
use crate::models::{AgentType, DbConversationSummary};
|
||||||
|
|
||||||
|
pub async fn create(
|
||||||
|
conn: &DatabaseConnection,
|
||||||
|
folder_id: i32,
|
||||||
|
agent_type: AgentType,
|
||||||
|
title: Option<String>,
|
||||||
|
git_branch: Option<String>,
|
||||||
|
) -> Result<conversation::Model, DbError> {
|
||||||
|
let at_str = serde_json::to_value(agent_type)
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.as_str().map(String::from))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let now = Utc::now();
|
||||||
|
let model = conversation::ActiveModel {
|
||||||
|
id: NotSet,
|
||||||
|
folder_id: Set(folder_id),
|
||||||
|
title: Set(title),
|
||||||
|
agent_type: Set(at_str),
|
||||||
|
status: Set(conversation::ConversationStatus::InProgress),
|
||||||
|
model: Set(None),
|
||||||
|
git_branch: Set(git_branch),
|
||||||
|
external_id: Set(None),
|
||||||
|
parent_id: Set(None),
|
||||||
|
message_count: Set(0),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
deleted_at: Set(None),
|
||||||
|
};
|
||||||
|
Ok(model.insert(conn).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_status(
|
||||||
|
conn: &DatabaseConnection,
|
||||||
|
conversation_id: i32,
|
||||||
|
status: conversation::ConversationStatus,
|
||||||
|
) -> Result<(), DbError> {
|
||||||
|
let conv = conversation::Entity::find_by_id(conversation_id)
|
||||||
|
.one(conn)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| DbError::Migration(format!("Conversation not found: {conversation_id}")))?;
|
||||||
|
let mut active: conversation::ActiveModel = conv.into();
|
||||||
|
active.status = Set(status);
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.update(conn).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_title(
|
||||||
|
conn: &DatabaseConnection,
|
||||||
|
conversation_id: i32,
|
||||||
|
title: String,
|
||||||
|
) -> Result<(), DbError> {
|
||||||
|
let conv = conversation::Entity::find_by_id(conversation_id)
|
||||||
|
.one(conn)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| DbError::Migration(format!("Conversation not found: {conversation_id}")))?;
|
||||||
|
let mut active: conversation::ActiveModel = conv.into();
|
||||||
|
active.title = Set(Some(title));
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.update(conn).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_external_id(
|
||||||
|
conn: &DatabaseConnection,
|
||||||
|
conversation_id: i32,
|
||||||
|
external_id: String,
|
||||||
|
) -> Result<(), DbError> {
|
||||||
|
let conv = conversation::Entity::find_by_id(conversation_id)
|
||||||
|
.one(conn)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| DbError::Migration(format!("Conversation not found: {conversation_id}")))?;
|
||||||
|
let mut active: conversation::ActiveModel = conv.into();
|
||||||
|
active.external_id = Set(Some(external_id));
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.update(conn).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn soft_delete(conn: &DatabaseConnection, conversation_id: i32) -> Result<(), DbError> {
|
||||||
|
let conv = conversation::Entity::find_by_id(conversation_id)
|
||||||
|
.filter(conversation::Column::DeletedAt.is_null())
|
||||||
|
.one(conn)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| DbError::Migration(format!("Conversation not found: {conversation_id}")))?;
|
||||||
|
let mut active: conversation::ActiveModel = conv.into();
|
||||||
|
active.deleted_at = Set(Some(Utc::now()));
|
||||||
|
active.update(conn).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_agent_type(s: &str) -> AgentType {
|
||||||
|
serde_json::from_value(serde_json::Value::String(s.to_string()))
|
||||||
|
.unwrap_or(AgentType::ClaudeCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn conv_to_summary(r: conversation::Model) -> DbConversationSummary {
|
||||||
|
let status = serde_json::to_value(&r.status)
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.as_str().map(String::from))
|
||||||
|
.unwrap_or_else(|| format!("{:?}", r.status));
|
||||||
|
DbConversationSummary {
|
||||||
|
id: r.id,
|
||||||
|
folder_id: r.folder_id,
|
||||||
|
title: r.title,
|
||||||
|
agent_type: parse_agent_type(&r.agent_type),
|
||||||
|
status,
|
||||||
|
model: r.model,
|
||||||
|
git_branch: r.git_branch,
|
||||||
|
external_id: r.external_id,
|
||||||
|
message_count: r.message_count as u32,
|
||||||
|
created_at: r.created_at,
|
||||||
|
updated_at: r.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_by_id(
|
||||||
|
conn: &DatabaseConnection,
|
||||||
|
conversation_id: i32,
|
||||||
|
) -> Result<DbConversationSummary, DbError> {
|
||||||
|
let conv = conversation::Entity::find_by_id(conversation_id)
|
||||||
|
.filter(conversation::Column::DeletedAt.is_null())
|
||||||
|
.one(conn)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| DbError::Migration(format!("Conversation not found: {conversation_id}")))?;
|
||||||
|
|
||||||
|
Ok(conv_to_summary(conv))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_by_folder(
|
||||||
|
conn: &DatabaseConnection,
|
||||||
|
folder_id: i32,
|
||||||
|
agent_type: Option<AgentType>,
|
||||||
|
search: Option<String>,
|
||||||
|
sort_by: Option<String>,
|
||||||
|
status: Option<String>,
|
||||||
|
) -> Result<Vec<DbConversationSummary>, DbError> {
|
||||||
|
let mut query = conversation::Entity::find()
|
||||||
|
.filter(conversation::Column::FolderId.eq(folder_id))
|
||||||
|
.filter(conversation::Column::DeletedAt.is_null());
|
||||||
|
|
||||||
|
// Filter by agent_type
|
||||||
|
if let Some(ref at) = agent_type {
|
||||||
|
let at_str = serde_json::to_value(at)
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.as_str().map(String::from))
|
||||||
|
.unwrap_or_default();
|
||||||
|
query = query.filter(conversation::Column::AgentType.eq(at_str));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search by title
|
||||||
|
if let Some(ref s) = search {
|
||||||
|
if !s.is_empty() {
|
||||||
|
query = query.filter(conversation::Column::Title.contains(s));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by status
|
||||||
|
if let Some(ref st) = status {
|
||||||
|
if let Ok(status_enum) = serde_json::from_value::<conversation::ConversationStatus>(
|
||||||
|
serde_json::Value::String(st.clone()),
|
||||||
|
) {
|
||||||
|
query = query.filter(conversation::Column::Status.eq(status_enum));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort
|
||||||
|
query = match sort_by.as_deref() {
|
||||||
|
Some("oldest") => query.order_by_asc(conversation::Column::CreatedAt),
|
||||||
|
_ => query.order_by_desc(conversation::Column::CreatedAt),
|
||||||
|
};
|
||||||
|
|
||||||
|
let rows = query.all(conn).await?;
|
||||||
|
|
||||||
|
let summaries: Vec<DbConversationSummary> = rows.into_iter().map(conv_to_summary).collect();
|
||||||
|
|
||||||
|
Ok(summaries)
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
use chrono::Utc;
|
||||||
|
use sea_orm::DatabaseConnection;
|
||||||
|
use sea_orm::{
|
||||||
|
ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, ConnectionTrait, DbBackend, EntityTrait,
|
||||||
|
IntoActiveModel, QueryFilter, QueryOrder, Set, Statement,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::db::entities::folder_command;
|
||||||
|
use crate::db::error::DbError;
|
||||||
|
use crate::models::FolderCommandInfo;
|
||||||
|
|
||||||
|
fn to_info(m: folder_command::Model) -> FolderCommandInfo {
|
||||||
|
FolderCommandInfo {
|
||||||
|
id: m.id,
|
||||||
|
folder_id: m.folder_id,
|
||||||
|
name: m.name,
|
||||||
|
command: m.command,
|
||||||
|
sort_order: m.sort_order,
|
||||||
|
created_at: m.created_at,
|
||||||
|
updated_at: m.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_by_folder(
|
||||||
|
conn: &DatabaseConnection,
|
||||||
|
folder_id: i32,
|
||||||
|
) -> Result<Vec<FolderCommandInfo>, DbError> {
|
||||||
|
let rows = folder_command::Entity::find()
|
||||||
|
.filter(folder_command::Column::FolderId.eq(folder_id))
|
||||||
|
.order_by_asc(folder_command::Column::SortOrder)
|
||||||
|
.all(conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(rows.into_iter().map(to_info).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create(
|
||||||
|
conn: &DatabaseConnection,
|
||||||
|
folder_id: i32,
|
||||||
|
name: &str,
|
||||||
|
command: &str,
|
||||||
|
) -> Result<FolderCommandInfo, DbError> {
|
||||||
|
let now = Utc::now();
|
||||||
|
|
||||||
|
// Get next sort_order
|
||||||
|
let max_order = folder_command::Entity::find()
|
||||||
|
.filter(folder_command::Column::FolderId.eq(folder_id))
|
||||||
|
.order_by_desc(folder_command::Column::SortOrder)
|
||||||
|
.one(conn)
|
||||||
|
.await?
|
||||||
|
.map(|m| m.sort_order)
|
||||||
|
.unwrap_or(-1);
|
||||||
|
|
||||||
|
let active = folder_command::ActiveModel {
|
||||||
|
id: NotSet,
|
||||||
|
folder_id: Set(folder_id),
|
||||||
|
name: Set(name.to_string()),
|
||||||
|
command: Set(command.to_string()),
|
||||||
|
sort_order: Set(max_order + 1),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
};
|
||||||
|
|
||||||
|
let model = active.insert(conn).await?;
|
||||||
|
Ok(to_info(model))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_many(
|
||||||
|
conn: &DatabaseConnection,
|
||||||
|
folder_id: i32,
|
||||||
|
items: &[(String, String)],
|
||||||
|
) -> Result<(), DbError> {
|
||||||
|
if items.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
|
||||||
|
let max_order = folder_command::Entity::find()
|
||||||
|
.filter(folder_command::Column::FolderId.eq(folder_id))
|
||||||
|
.order_by_desc(folder_command::Column::SortOrder)
|
||||||
|
.one(conn)
|
||||||
|
.await?
|
||||||
|
.map(|m| m.sort_order)
|
||||||
|
.unwrap_or(-1);
|
||||||
|
|
||||||
|
let active_models = items
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(idx, (name, command))| folder_command::ActiveModel {
|
||||||
|
id: NotSet,
|
||||||
|
folder_id: Set(folder_id),
|
||||||
|
name: Set(name.clone()),
|
||||||
|
command: Set(command.clone()),
|
||||||
|
sort_order: Set(max_order + idx as i32 + 1),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
folder_command::Entity::insert_many(active_models)
|
||||||
|
.exec(conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update(
|
||||||
|
conn: &DatabaseConnection,
|
||||||
|
id: i32,
|
||||||
|
name: Option<String>,
|
||||||
|
command: Option<String>,
|
||||||
|
sort_order: Option<i32>,
|
||||||
|
) -> Result<FolderCommandInfo, DbError> {
|
||||||
|
let row = folder_command::Entity::find_by_id(id)
|
||||||
|
.one(conn)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| DbError::Migration(format!("FolderCommand {} not found", id)))?;
|
||||||
|
|
||||||
|
let mut active = row.into_active_model();
|
||||||
|
if let Some(n) = name {
|
||||||
|
active.name = Set(n);
|
||||||
|
}
|
||||||
|
if let Some(c) = command {
|
||||||
|
active.command = Set(c);
|
||||||
|
}
|
||||||
|
if let Some(s) = sort_order {
|
||||||
|
active.sort_order = Set(s);
|
||||||
|
}
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
|
||||||
|
let model = active.update(conn).await?;
|
||||||
|
Ok(to_info(model))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete(conn: &DatabaseConnection, id: i32) -> Result<(), DbError> {
|
||||||
|
folder_command::Entity::delete_by_id(id).exec(conn).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn reorder(
|
||||||
|
conn: &DatabaseConnection,
|
||||||
|
folder_id: i32,
|
||||||
|
ids: Vec<i32>,
|
||||||
|
) -> Result<(), DbError> {
|
||||||
|
if ids.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
let now_str = now.format("%Y-%m-%d %H:%M:%S %:z").to_string();
|
||||||
|
let case_expr = ids
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(idx, id)| format!("WHEN {} THEN {}", id, idx))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ");
|
||||||
|
let id_list = ids
|
||||||
|
.iter()
|
||||||
|
.map(|id| id.to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
let sql = format!(
|
||||||
|
"UPDATE folder_command SET sort_order = CASE id {case_expr} END, updated_at = '{now_str}' WHERE folder_id = {folder_id} AND id IN ({id_list})"
|
||||||
|
);
|
||||||
|
conn.execute(Statement::from_string(DbBackend::Sqlite, sql))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
use chrono::Utc;
|
||||||
|
use sea_orm::DatabaseConnection;
|
||||||
|
use sea_orm::{
|
||||||
|
ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, ConnectionTrait, DbBackend, EntityTrait,
|
||||||
|
IntoActiveModel, QueryFilter, QueryOrder, Set, Statement,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::db::entities::{folder, folder_opened_conversation};
|
||||||
|
use crate::db::error::DbError;
|
||||||
|
use crate::models::agent::AgentType;
|
||||||
|
use crate::models::{FolderDetail, FolderHistoryEntry, OpenedConversation};
|
||||||
|
|
||||||
|
fn to_entry(m: folder::Model) -> FolderHistoryEntry {
|
||||||
|
FolderHistoryEntry {
|
||||||
|
id: m.id,
|
||||||
|
path: m.path,
|
||||||
|
name: m.name,
|
||||||
|
last_opened_at: m.last_opened_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_agent_type(s: &Option<String>) -> Option<AgentType> {
|
||||||
|
s.as_deref()
|
||||||
|
.and_then(|v| serde_json::from_value(serde_json::Value::String(v.to_string())).ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_detail(m: folder::Model, opened: Vec<OpenedConversation>) -> FolderDetail {
|
||||||
|
let default_agent_type = parse_agent_type(&m.default_agent_type);
|
||||||
|
FolderDetail {
|
||||||
|
id: m.id,
|
||||||
|
name: m.name,
|
||||||
|
path: m.path,
|
||||||
|
git_branch: m.git_branch,
|
||||||
|
parent_branch: m.parent_branch,
|
||||||
|
default_agent_type,
|
||||||
|
last_opened_at: m.last_opened_at,
|
||||||
|
opened_conversations: opened,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_folder_by_id(
|
||||||
|
conn: &DatabaseConnection,
|
||||||
|
folder_id: i32,
|
||||||
|
) -> Result<Option<FolderDetail>, DbError> {
|
||||||
|
let row = folder::Entity::find_by_id(folder_id)
|
||||||
|
.filter(folder::Column::DeletedAt.is_null())
|
||||||
|
.one(conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
match row {
|
||||||
|
None => Ok(None),
|
||||||
|
Some(folder_model) => {
|
||||||
|
let opened = load_opened_conversations(conn, folder_model.id).await?;
|
||||||
|
Ok(Some(to_detail(folder_model, opened)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_folder(
|
||||||
|
conn: &DatabaseConnection,
|
||||||
|
path: &str,
|
||||||
|
) -> Result<FolderHistoryEntry, DbError> {
|
||||||
|
let now = Utc::now();
|
||||||
|
let name = std::path::Path::new(path)
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| path.to_string());
|
||||||
|
|
||||||
|
let existing = folder::Entity::find()
|
||||||
|
.filter(folder::Column::Path.eq(path))
|
||||||
|
.one(conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let model = if let Some(row) = existing {
|
||||||
|
let mut active = row.into_active_model();
|
||||||
|
active.name = Set(name);
|
||||||
|
active.last_opened_at = Set(now);
|
||||||
|
active.updated_at = Set(now);
|
||||||
|
active.deleted_at = Set(None);
|
||||||
|
active.is_open = Set(true);
|
||||||
|
active.update(conn).await?
|
||||||
|
} else {
|
||||||
|
let active = folder::ActiveModel {
|
||||||
|
id: NotSet,
|
||||||
|
name: Set(name),
|
||||||
|
path: Set(path.to_string()),
|
||||||
|
git_branch: Set(None),
|
||||||
|
parent_branch: Set(None),
|
||||||
|
default_agent_type: Set(None),
|
||||||
|
last_opened_at: Set(now),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
deleted_at: Set(None),
|
||||||
|
is_open: Set(true),
|
||||||
|
};
|
||||||
|
active.insert(conn).await?
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(to_entry(model))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_folders(conn: &DatabaseConnection) -> Result<Vec<FolderHistoryEntry>, DbError> {
|
||||||
|
let rows = folder::Entity::find()
|
||||||
|
.filter(folder::Column::DeletedAt.is_null())
|
||||||
|
.order_by_desc(folder::Column::LastOpenedAt)
|
||||||
|
.all(conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(rows.into_iter().map(to_entry).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_folder(conn: &DatabaseConnection, path: &str) -> Result<(), DbError> {
|
||||||
|
let now = Utc::now();
|
||||||
|
let row = folder::Entity::find()
|
||||||
|
.filter(folder::Column::Path.eq(path))
|
||||||
|
.filter(folder::Column::DeletedAt.is_null())
|
||||||
|
.one(conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some(row) = row {
|
||||||
|
let mut active = row.into_active_model();
|
||||||
|
active.deleted_at = Set(Some(now));
|
||||||
|
active.updated_at = Set(now);
|
||||||
|
active.update(conn).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save_opened_conversations(
|
||||||
|
conn: &DatabaseConnection,
|
||||||
|
folder_id: i32,
|
||||||
|
items: Vec<OpenedConversation>,
|
||||||
|
) -> Result<(), DbError> {
|
||||||
|
// Delete all existing opened conversations for this folder
|
||||||
|
folder_opened_conversation::Entity::delete_many()
|
||||||
|
.filter(folder_opened_conversation::Column::FolderId.eq(folder_id))
|
||||||
|
.exec(conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if items.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch insert with raw SQL for efficiency
|
||||||
|
let now = Utc::now();
|
||||||
|
let now_str = now.format("%Y-%m-%d %H:%M:%S %:z").to_string();
|
||||||
|
|
||||||
|
let mut values = Vec::with_capacity(items.len());
|
||||||
|
for item in &items {
|
||||||
|
let agent_str = serde_json::to_value(item.agent_type)
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
values.push(format!(
|
||||||
|
"({}, {}, {}, {}, {}, '{}', '{}', '{}')",
|
||||||
|
folder_id,
|
||||||
|
item.conversation_id,
|
||||||
|
item.position,
|
||||||
|
item.is_active as i32,
|
||||||
|
item.is_pinned as i32,
|
||||||
|
agent_str,
|
||||||
|
now_str,
|
||||||
|
now_str,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let sql = format!(
|
||||||
|
"INSERT INTO folder_opened_conversation (folder_id, conversation_id, position, is_active, is_pinned, agent_type, created_at, updated_at) VALUES {}",
|
||||||
|
values.join(", ")
|
||||||
|
);
|
||||||
|
|
||||||
|
conn.execute(Statement::from_string(DbBackend::Sqlite, sql))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_opened_conversations(
|
||||||
|
conn: &DatabaseConnection,
|
||||||
|
folder_id: i32,
|
||||||
|
) -> Result<Vec<OpenedConversation>, DbError> {
|
||||||
|
let rows = folder_opened_conversation::Entity::find()
|
||||||
|
.filter(folder_opened_conversation::Column::FolderId.eq(folder_id))
|
||||||
|
.order_by_asc(folder_opened_conversation::Column::Position)
|
||||||
|
.all(conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|r| {
|
||||||
|
let agent_type = parse_agent_type(&Some(r.agent_type))?;
|
||||||
|
Some(OpenedConversation {
|
||||||
|
conversation_id: r.conversation_id,
|
||||||
|
agent_type,
|
||||||
|
position: r.position,
|
||||||
|
is_active: r.is_active,
|
||||||
|
is_pinned: r.is_pinned,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_folder_parent_branch(
|
||||||
|
conn: &DatabaseConnection,
|
||||||
|
folder_id: i32,
|
||||||
|
parent_branch: Option<String>,
|
||||||
|
) -> Result<(), DbError> {
|
||||||
|
let row = folder::Entity::find_by_id(folder_id).one(conn).await?;
|
||||||
|
|
||||||
|
if let Some(row) = row {
|
||||||
|
let mut active = row.into_active_model();
|
||||||
|
active.parent_branch = Set(parent_branch);
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.update(conn).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_folder_open(
|
||||||
|
conn: &DatabaseConnection,
|
||||||
|
folder_id: i32,
|
||||||
|
is_open: bool,
|
||||||
|
) -> Result<(), DbError> {
|
||||||
|
let row = folder::Entity::find_by_id(folder_id).one(conn).await?;
|
||||||
|
|
||||||
|
if let Some(row) = row {
|
||||||
|
let mut active = row.into_active_model();
|
||||||
|
active.is_open = Set(is_open);
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.update(conn).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_open_folders(
|
||||||
|
conn: &DatabaseConnection,
|
||||||
|
) -> Result<Vec<FolderHistoryEntry>, DbError> {
|
||||||
|
let rows = folder::Entity::find()
|
||||||
|
.filter(folder::Column::DeletedAt.is_null())
|
||||||
|
.filter(folder::Column::IsOpen.eq(true))
|
||||||
|
.order_by_desc(folder::Column::LastOpenedAt)
|
||||||
|
.all(conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(rows.into_iter().map(to_entry).collect())
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
use chrono::Utc;
|
||||||
|
use sea_orm::{
|
||||||
|
ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, DatabaseConnection, EntityTrait,
|
||||||
|
QueryFilter, Set,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::db::entities::conversation;
|
||||||
|
use crate::db::error::DbError;
|
||||||
|
use crate::models::{AgentType, ImportResult};
|
||||||
|
use crate::parsers::claude::ClaudeParser;
|
||||||
|
use crate::parsers::codex::CodexParser;
|
||||||
|
use crate::parsers::gemini::GeminiParser;
|
||||||
|
use crate::parsers::opencode::OpenCodeParser;
|
||||||
|
use crate::parsers::{path_eq_for_matching, AgentParser};
|
||||||
|
|
||||||
|
pub async fn import_local_conversations(
|
||||||
|
conn: &DatabaseConnection,
|
||||||
|
folder_id: i32,
|
||||||
|
folder_path: &str,
|
||||||
|
) -> Result<ImportResult, DbError> {
|
||||||
|
let path = folder_path.to_string();
|
||||||
|
|
||||||
|
// Run parsers in blocking task since they do filesystem I/O
|
||||||
|
let summaries = tokio::task::spawn_blocking(move || {
|
||||||
|
let parsers: Vec<(AgentType, Box<dyn AgentParser>)> = vec![
|
||||||
|
(AgentType::ClaudeCode, Box::new(ClaudeParser::new())),
|
||||||
|
(AgentType::Codex, Box::new(CodexParser::new())),
|
||||||
|
(AgentType::OpenCode, Box::new(OpenCodeParser::new())),
|
||||||
|
(AgentType::Gemini, Box::new(GeminiParser::new())),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut matched = Vec::new();
|
||||||
|
for (at, parser) in &parsers {
|
||||||
|
match parser.list_conversations() {
|
||||||
|
Ok(convs) => {
|
||||||
|
for c in convs {
|
||||||
|
if c.folder_path
|
||||||
|
.as_deref()
|
||||||
|
.map(|p| path_eq_for_matching(p, path.as_str()))
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
matched.push((*at, c));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Error listing {} conversations: {}", at, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
matched
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| DbError::Migration(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut imported = 0u32;
|
||||||
|
let mut skipped = 0u32;
|
||||||
|
|
||||||
|
for (agent_type, summary) in &summaries {
|
||||||
|
let at_str = serde_json::to_value(agent_type)
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.as_str().map(String::from))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Check if already imported
|
||||||
|
let exists = conversation::Entity::find()
|
||||||
|
.filter(conversation::Column::ExternalId.eq(&summary.id))
|
||||||
|
.filter(conversation::Column::AgentType.eq(&at_str))
|
||||||
|
.one(conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if exists.is_some() {
|
||||||
|
skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
let conv = conversation::ActiveModel {
|
||||||
|
id: NotSet,
|
||||||
|
folder_id: Set(folder_id),
|
||||||
|
title: Set(summary.title.clone()),
|
||||||
|
agent_type: Set(at_str.clone()),
|
||||||
|
status: Set(conversation::ConversationStatus::Completed),
|
||||||
|
model: Set(summary.model.clone()),
|
||||||
|
git_branch: Set(summary.git_branch.clone()),
|
||||||
|
external_id: Set(Some(summary.id.clone())),
|
||||||
|
parent_id: Set(None),
|
||||||
|
message_count: Set(summary.message_count as i32),
|
||||||
|
created_at: Set(summary.started_at),
|
||||||
|
updated_at: Set(now),
|
||||||
|
deleted_at: Set(None),
|
||||||
|
};
|
||||||
|
conv.insert(conn).await?;
|
||||||
|
|
||||||
|
imported += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ImportResult { imported, skipped })
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
pub mod agent_setting_service;
|
||||||
|
pub mod app_metadata_service;
|
||||||
|
pub mod conversation_service;
|
||||||
|
pub mod folder_command_service;
|
||||||
|
pub mod folder_service;
|
||||||
|
pub mod import_service;
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
mod acp;
|
||||||
|
mod commands;
|
||||||
|
mod db;
|
||||||
|
mod models;
|
||||||
|
mod network;
|
||||||
|
mod parsers;
|
||||||
|
mod process;
|
||||||
|
mod terminal;
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
|
use acp::manager::ConnectionManager;
|
||||||
|
use commands::{
|
||||||
|
acp as acp_commands, conversations, folder_commands, folders, mcp as mcp_commands,
|
||||||
|
system_settings, terminal as terminal_commands, windows,
|
||||||
|
};
|
||||||
|
use tauri::Manager;
|
||||||
|
use terminal::manager::TerminalManager;
|
||||||
|
|
||||||
|
static APP_QUITTING: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
fn get_folder_id_from_url(window: &tauri::Window) -> Option<i32> {
|
||||||
|
let webview = window.get_webview_window(window.label())?;
|
||||||
|
let url = webview.url().ok()?;
|
||||||
|
url.query_pairs()
|
||||||
|
.find(|(key, _)| key == "id")
|
||||||
|
.and_then(|(_, value)| value.parse::<i32>().ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
pub fn run() {
|
||||||
|
let _ = fix_path_env::fix();
|
||||||
|
|
||||||
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_window_state::Builder::new().build())
|
||||||
|
.plugin(tauri_plugin_opener::init())
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||||
|
.plugin(tauri_plugin_process::init())
|
||||||
|
.manage(ConnectionManager::new())
|
||||||
|
.manage(TerminalManager::new())
|
||||||
|
.manage(windows::SettingsWindowState::new())
|
||||||
|
.manage(windows::CommitWindowState::new())
|
||||||
|
.setup(|app| {
|
||||||
|
let app_data_dir = app.path().app_data_dir()?;
|
||||||
|
let app_version = env!("CARGO_PKG_VERSION");
|
||||||
|
let database =
|
||||||
|
tauri::async_runtime::block_on(db::init_database(&app_data_dir, app_version))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
app.manage(database);
|
||||||
|
|
||||||
|
// Restore and apply saved system proxy settings before any network operation.
|
||||||
|
let db = app.state::<db::AppDatabase>();
|
||||||
|
match tauri::async_runtime::block_on(system_settings::load_system_proxy_settings(
|
||||||
|
&db.conn,
|
||||||
|
)) {
|
||||||
|
Ok(settings) => {
|
||||||
|
let _ = network::proxy::apply_system_proxy_settings(&settings);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("[Settings] failed to load system proxy settings: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore previously open folders or show welcome
|
||||||
|
let db = app.state::<db::AppDatabase>();
|
||||||
|
let open_folders = tauri::async_runtime::block_on(
|
||||||
|
db::service::folder_service::list_open_folders(&db.conn),
|
||||||
|
)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if open_folders.is_empty() {
|
||||||
|
let _ = windows::open_welcome_window(app.handle());
|
||||||
|
} else {
|
||||||
|
for entry in &open_folders {
|
||||||
|
let label = format!("folder-{}", uuid::Uuid::new_v4());
|
||||||
|
let url = tauri::WebviewUrl::App(format!("folder?id={}", entry.id).into());
|
||||||
|
let builder = tauri::WebviewWindowBuilder::new(app, &label, url)
|
||||||
|
.title(&entry.name)
|
||||||
|
.inner_size(1260.0, 860.0)
|
||||||
|
.min_inner_size(900.0, 600.0);
|
||||||
|
let _ = windows::apply_platform_window_style(builder).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.on_window_event(|window, event| {
|
||||||
|
let label = window.label().to_string();
|
||||||
|
|
||||||
|
if label == "settings"
|
||||||
|
&& matches!(
|
||||||
|
event,
|
||||||
|
tauri::WindowEvent::CloseRequested { .. } | tauri::WindowEvent::Destroyed
|
||||||
|
)
|
||||||
|
{
|
||||||
|
let app = window.app_handle();
|
||||||
|
if let Some(state) = app.try_state::<windows::SettingsWindowState>() {
|
||||||
|
windows::restore_windows_after_settings(&app, &state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if label.starts_with("commit-")
|
||||||
|
&& matches!(
|
||||||
|
event,
|
||||||
|
tauri::WindowEvent::CloseRequested { .. } | tauri::WindowEvent::Destroyed
|
||||||
|
)
|
||||||
|
{
|
||||||
|
let app = window.app_handle();
|
||||||
|
if let Some(state) = app.try_state::<windows::CommitWindowState>() {
|
||||||
|
windows::restore_window_after_commit(&app, &state, &label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let tauri::WindowEvent::CloseRequested { .. } = event {
|
||||||
|
if label.starts_with("folder-") {
|
||||||
|
let app = window.app_handle();
|
||||||
|
if let Some(cm) = app.try_state::<ConnectionManager>() {
|
||||||
|
let disconnected =
|
||||||
|
tauri::async_runtime::block_on(cm.disconnect_by_owner_window(&label));
|
||||||
|
eprintln!(
|
||||||
|
"[ACP] folder window closing label={} disconnected_connections={}",
|
||||||
|
label, disconnected
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only mark folder as closed if user is closing individual window,
|
||||||
|
// not when the entire app is quitting (so folders reopen on next launch)
|
||||||
|
if !APP_QUITTING.load(Ordering::Relaxed) {
|
||||||
|
if let Some(folder_id) = get_folder_id_from_url(window) {
|
||||||
|
if let Some(db) = app.try_state::<db::AppDatabase>() {
|
||||||
|
let _ = tauri::async_runtime::block_on(
|
||||||
|
db::service::folder_service::set_folder_open(
|
||||||
|
&db.conn, folder_id, false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kill terminal sessions owned by this folder window.
|
||||||
|
if let Some(tm) = app.try_state::<TerminalManager>() {
|
||||||
|
let killed = tm.kill_by_owner_window(&label);
|
||||||
|
eprintln!(
|
||||||
|
"[TERM] folder window closing label={} killed_terminals={}",
|
||||||
|
label, killed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let has_other_folder = app
|
||||||
|
.webview_windows()
|
||||||
|
.keys()
|
||||||
|
.any(|l| l.starts_with("folder-") && *l != label);
|
||||||
|
if !has_other_folder && !APP_QUITTING.load(Ordering::Relaxed) {
|
||||||
|
let _ = windows::open_welcome_window(app);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
conversations::list_conversations,
|
||||||
|
conversations::get_conversation,
|
||||||
|
conversations::list_folder_conversations,
|
||||||
|
conversations::import_local_conversations,
|
||||||
|
conversations::get_folder_conversation,
|
||||||
|
conversations::list_folders,
|
||||||
|
conversations::get_stats,
|
||||||
|
conversations::get_sidebar_data,
|
||||||
|
conversations::create_conversation,
|
||||||
|
conversations::update_conversation_status,
|
||||||
|
conversations::update_conversation_title,
|
||||||
|
conversations::update_conversation_external_id,
|
||||||
|
conversations::delete_conversation,
|
||||||
|
folders::load_folder_history,
|
||||||
|
folders::get_folder,
|
||||||
|
folders::add_folder_to_history,
|
||||||
|
folders::set_folder_parent_branch,
|
||||||
|
folders::remove_folder_from_history,
|
||||||
|
folders::create_folder_directory,
|
||||||
|
folders::clone_repository,
|
||||||
|
folders::get_git_branch,
|
||||||
|
folders::git_init,
|
||||||
|
folders::git_pull,
|
||||||
|
folders::git_fetch,
|
||||||
|
folders::git_push,
|
||||||
|
folders::git_new_branch,
|
||||||
|
folders::git_worktree_add,
|
||||||
|
folders::git_checkout,
|
||||||
|
folders::git_list_branches,
|
||||||
|
folders::git_stash,
|
||||||
|
folders::git_stash_pop,
|
||||||
|
folders::git_status,
|
||||||
|
folders::git_is_tracked,
|
||||||
|
folders::git_diff,
|
||||||
|
folders::git_diff_with_branch,
|
||||||
|
folders::git_show_diff,
|
||||||
|
folders::git_show_file,
|
||||||
|
folders::git_commit,
|
||||||
|
folders::git_rollback_file,
|
||||||
|
folders::git_add_files,
|
||||||
|
folders::git_list_all_branches,
|
||||||
|
folders::git_merge,
|
||||||
|
folders::git_rebase,
|
||||||
|
folders::git_delete_branch,
|
||||||
|
folders::save_folder_opened_conversations,
|
||||||
|
folders::start_file_tree_watch,
|
||||||
|
folders::stop_file_tree_watch,
|
||||||
|
folders::get_file_tree,
|
||||||
|
folders::read_file_preview,
|
||||||
|
folders::read_file_for_edit,
|
||||||
|
folders::save_file_content,
|
||||||
|
folders::save_file_copy,
|
||||||
|
folders::rename_file_tree_entry,
|
||||||
|
folders::delete_file_tree_entry,
|
||||||
|
folders::git_log,
|
||||||
|
folders::git_commit_branches,
|
||||||
|
windows::open_folder_window,
|
||||||
|
windows::open_commit_window,
|
||||||
|
windows::open_settings_window,
|
||||||
|
windows::list_open_folders,
|
||||||
|
windows::focus_folder_window,
|
||||||
|
system_settings::get_system_proxy_settings,
|
||||||
|
system_settings::update_system_proxy_settings,
|
||||||
|
acp_commands::acp_preflight,
|
||||||
|
acp_commands::acp_connect,
|
||||||
|
acp_commands::acp_prompt,
|
||||||
|
acp_commands::acp_set_mode,
|
||||||
|
acp_commands::acp_set_config_option,
|
||||||
|
acp_commands::acp_cancel,
|
||||||
|
acp_commands::acp_respond_permission,
|
||||||
|
acp_commands::acp_disconnect,
|
||||||
|
acp_commands::acp_list_connections,
|
||||||
|
acp_commands::acp_list_agents,
|
||||||
|
acp_commands::acp_clear_binary_cache,
|
||||||
|
acp_commands::acp_download_agent_binary,
|
||||||
|
acp_commands::acp_detect_agent_local_version,
|
||||||
|
acp_commands::acp_prepare_npx_agent,
|
||||||
|
acp_commands::acp_prepare_uvx_agent,
|
||||||
|
acp_commands::acp_uninstall_agent,
|
||||||
|
acp_commands::acp_update_agent_preferences,
|
||||||
|
acp_commands::acp_reorder_agents,
|
||||||
|
acp_commands::acp_list_agent_skills,
|
||||||
|
acp_commands::acp_read_agent_skill,
|
||||||
|
acp_commands::acp_save_agent_skill,
|
||||||
|
acp_commands::acp_delete_agent_skill,
|
||||||
|
folder_commands::list_folder_commands,
|
||||||
|
folder_commands::create_folder_command,
|
||||||
|
folder_commands::update_folder_command,
|
||||||
|
folder_commands::delete_folder_command,
|
||||||
|
folder_commands::reorder_folder_commands,
|
||||||
|
folder_commands::bootstrap_folder_commands_from_package_json,
|
||||||
|
terminal_commands::terminal_spawn,
|
||||||
|
terminal_commands::terminal_write,
|
||||||
|
terminal_commands::terminal_resize,
|
||||||
|
terminal_commands::terminal_kill,
|
||||||
|
terminal_commands::terminal_list,
|
||||||
|
mcp_commands::mcp_scan_local,
|
||||||
|
mcp_commands::mcp_list_marketplaces,
|
||||||
|
mcp_commands::mcp_search_marketplace,
|
||||||
|
mcp_commands::mcp_get_marketplace_server_detail,
|
||||||
|
mcp_commands::mcp_install_from_marketplace,
|
||||||
|
mcp_commands::mcp_upsert_local_server,
|
||||||
|
mcp_commands::mcp_set_server_apps,
|
||||||
|
mcp_commands::mcp_remove_server,
|
||||||
|
])
|
||||||
|
.build(tauri::generate_context!())
|
||||||
|
.expect("error while building tauri application")
|
||||||
|
.run(|_app, event| {
|
||||||
|
if let tauri::RunEvent::ExitRequested { .. } = event {
|
||||||
|
APP_QUITTING.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
codeg_lib::run()
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum AgentType {
|
||||||
|
ClaudeCode,
|
||||||
|
Codex,
|
||||||
|
OpenCode,
|
||||||
|
Auggie,
|
||||||
|
Autohand,
|
||||||
|
Cline,
|
||||||
|
CodebuddyCode,
|
||||||
|
CorustAgent,
|
||||||
|
Gemini,
|
||||||
|
GithubCopilot,
|
||||||
|
Goose,
|
||||||
|
Junie,
|
||||||
|
Qoder,
|
||||||
|
QwenCode,
|
||||||
|
FactoryDroid,
|
||||||
|
Kimi,
|
||||||
|
MinionCode,
|
||||||
|
MistralVibe,
|
||||||
|
OpenClaw,
|
||||||
|
Stakpak,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for AgentType {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
AgentType::ClaudeCode => write!(f, "Claude Code"),
|
||||||
|
AgentType::Codex => write!(f, "Codex CLI"),
|
||||||
|
AgentType::OpenCode => write!(f, "OpenCode"),
|
||||||
|
AgentType::Auggie => write!(f, "Auggie"),
|
||||||
|
AgentType::Autohand => write!(f, "Autohand"),
|
||||||
|
AgentType::Cline => write!(f, "Cline"),
|
||||||
|
AgentType::CodebuddyCode => write!(f, "Codebuddy Code"),
|
||||||
|
AgentType::CorustAgent => write!(f, "Corust Agent"),
|
||||||
|
AgentType::Gemini => write!(f, "Gemini CLI"),
|
||||||
|
AgentType::GithubCopilot => write!(f, "GitHub Copilot"),
|
||||||
|
AgentType::Goose => write!(f, "goose"),
|
||||||
|
AgentType::Junie => write!(f, "Junie"),
|
||||||
|
AgentType::Qoder => write!(f, "Qoder CLI"),
|
||||||
|
AgentType::QwenCode => write!(f, "Qwen Code"),
|
||||||
|
AgentType::FactoryDroid => write!(f, "Factory Droid"),
|
||||||
|
AgentType::Kimi => write!(f, "Kimi CLI"),
|
||||||
|
AgentType::MinionCode => write!(f, "Minion Code"),
|
||||||
|
AgentType::MistralVibe => write!(f, "Mistral Vibe"),
|
||||||
|
AgentType::OpenClaw => write!(f, "OpenClaw"),
|
||||||
|
AgentType::Stakpak => write!(f, "Stakpak"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::agent::AgentType;
|
||||||
|
use super::message::{MessageTurn, TurnUsage};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ConversationSummary {
|
||||||
|
pub id: String,
|
||||||
|
pub agent_type: AgentType,
|
||||||
|
pub folder_path: Option<String>,
|
||||||
|
pub folder_name: Option<String>,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub started_at: DateTime<Utc>,
|
||||||
|
pub ended_at: Option<DateTime<Utc>>,
|
||||||
|
pub message_count: u32,
|
||||||
|
pub model: Option<String>,
|
||||||
|
pub git_branch: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct DbConversationSummary {
|
||||||
|
pub id: i32,
|
||||||
|
pub folder_id: i32,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub agent_type: AgentType,
|
||||||
|
pub status: String,
|
||||||
|
pub model: Option<String>,
|
||||||
|
pub git_branch: Option<String>,
|
||||||
|
pub external_id: Option<String>,
|
||||||
|
pub message_count: u32,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SessionStats {
|
||||||
|
pub total_usage: Option<TurnUsage>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub total_tokens: Option<u64>,
|
||||||
|
pub total_duration_ms: u64,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub context_window_used_tokens: Option<u64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub context_window_max_tokens: Option<u64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub context_window_usage_percent: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ConversationDetail {
|
||||||
|
pub summary: ConversationSummary,
|
||||||
|
pub turns: Vec<MessageTurn>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub session_stats: Option<SessionStats>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct DbConversationDetail {
|
||||||
|
pub summary: DbConversationSummary,
|
||||||
|
pub turns: Vec<MessageTurn>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub session_stats: Option<SessionStats>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct FolderInfo {
|
||||||
|
pub path: String,
|
||||||
|
pub name: String,
|
||||||
|
pub agent_types: Vec<AgentType>,
|
||||||
|
pub conversation_count: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AgentStats {
|
||||||
|
pub total_conversations: u32,
|
||||||
|
pub total_messages: u32,
|
||||||
|
pub by_agent: Vec<AgentConversationCount>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AgentConversationCount {
|
||||||
|
pub agent_type: AgentType,
|
||||||
|
pub conversation_count: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SidebarData {
|
||||||
|
pub folders: Vec<FolderInfo>,
|
||||||
|
pub stats: AgentStats,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ImportResult {
|
||||||
|
pub imported: u32,
|
||||||
|
pub skipped: u32,
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::agent::AgentType;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct FolderHistoryEntry {
|
||||||
|
pub id: i32,
|
||||||
|
pub path: String,
|
||||||
|
pub name: String,
|
||||||
|
pub last_opened_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct FolderDetail {
|
||||||
|
pub id: i32,
|
||||||
|
pub name: String,
|
||||||
|
pub path: String,
|
||||||
|
pub git_branch: Option<String>,
|
||||||
|
pub parent_branch: Option<String>,
|
||||||
|
pub default_agent_type: Option<AgentType>,
|
||||||
|
pub last_opened_at: DateTime<Utc>,
|
||||||
|
pub opened_conversations: Vec<OpenedConversation>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OpenedConversation {
|
||||||
|
pub conversation_id: i32,
|
||||||
|
pub agent_type: AgentType,
|
||||||
|
pub position: i32,
|
||||||
|
pub is_active: bool,
|
||||||
|
pub is_pinned: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct FolderCommandInfo {
|
||||||
|
pub id: i32,
|
||||||
|
pub folder_id: i32,
|
||||||
|
pub name: String,
|
||||||
|
pub command: String,
|
||||||
|
pub sort_order: i32,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum MessageRole {
|
||||||
|
User,
|
||||||
|
Assistant,
|
||||||
|
System,
|
||||||
|
Tool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum ContentBlock {
|
||||||
|
Text {
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
ToolUse {
|
||||||
|
tool_use_id: Option<String>,
|
||||||
|
tool_name: String,
|
||||||
|
input_preview: Option<String>,
|
||||||
|
},
|
||||||
|
ToolResult {
|
||||||
|
tool_use_id: Option<String>,
|
||||||
|
output_preview: Option<String>,
|
||||||
|
is_error: bool,
|
||||||
|
},
|
||||||
|
Thinking {
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TurnUsage {
|
||||||
|
pub input_tokens: u64,
|
||||||
|
pub output_tokens: u64,
|
||||||
|
pub cache_creation_input_tokens: u64,
|
||||||
|
pub cache_read_input_tokens: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct UnifiedMessage {
|
||||||
|
pub id: String,
|
||||||
|
pub role: MessageRole,
|
||||||
|
pub content: Vec<ContentBlock>,
|
||||||
|
pub timestamp: DateTime<Utc>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub usage: Option<TurnUsage>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub duration_ms: Option<u64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub model: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum TurnRole {
|
||||||
|
User,
|
||||||
|
Assistant,
|
||||||
|
System,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MessageTurn {
|
||||||
|
pub id: String,
|
||||||
|
pub role: TurnRole,
|
||||||
|
pub blocks: Vec<ContentBlock>,
|
||||||
|
pub timestamp: DateTime<Utc>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub usage: Option<TurnUsage>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub duration_ms: Option<u64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub model: Option<String>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
pub mod agent;
|
||||||
|
pub mod conversation;
|
||||||
|
pub mod folder;
|
||||||
|
pub mod message;
|
||||||
|
pub mod system;
|
||||||
|
|
||||||
|
pub use agent::AgentType;
|
||||||
|
pub use conversation::{
|
||||||
|
AgentConversationCount, AgentStats, ConversationDetail, ConversationSummary,
|
||||||
|
DbConversationDetail, DbConversationSummary, FolderInfo, ImportResult, SessionStats,
|
||||||
|
SidebarData,
|
||||||
|
};
|
||||||
|
pub use folder::{FolderCommandInfo, FolderDetail, FolderHistoryEntry, OpenedConversation};
|
||||||
|
pub use message::{ContentBlock, MessageRole, MessageTurn, TurnRole, TurnUsage, UnifiedMessage};
|
||||||
|
pub use system::SystemProxySettings;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct SystemProxySettings {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub proxy_url: Option<String>,
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
pub mod proxy;
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
use crate::models::SystemProxySettings;
|
||||||
|
|
||||||
|
const PROXY_ENV_KEYS: [&str; 6] = [
|
||||||
|
"HTTP_PROXY",
|
||||||
|
"HTTPS_PROXY",
|
||||||
|
"ALL_PROXY",
|
||||||
|
"http_proxy",
|
||||||
|
"https_proxy",
|
||||||
|
"all_proxy",
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn apply_system_proxy_settings(settings: &SystemProxySettings) -> Result<(), String> {
|
||||||
|
if settings.enabled {
|
||||||
|
let proxy_url = settings
|
||||||
|
.proxy_url
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.ok_or_else(|| "proxy url is required when proxy is enabled".to_string())?;
|
||||||
|
|
||||||
|
for key in PROXY_ENV_KEYS {
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var(key, proxy_url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
clear_proxy_env();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_proxy_env() {
|
||||||
|
for key in PROXY_ENV_KEYS {
|
||||||
|
unsafe {
|
||||||
|
std::env::remove_var(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn current_proxy_env_vars() -> Vec<(String, String)> {
|
||||||
|
PROXY_ENV_KEYS
|
||||||
|
.iter()
|
||||||
|
.filter_map(|key| {
|
||||||
|
std::env::var(key).ok().and_then(|value| {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(((*key).to_string(), trimmed.to_string()))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
@@ -0,0 +1,983 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::io::{BufRead, BufReader};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use regex::Regex;
|
||||||
|
|
||||||
|
use crate::models::*;
|
||||||
|
use crate::parsers::{folder_name_from_path, truncate_str, AgentParser, ParseError};
|
||||||
|
|
||||||
|
/// Regex that matches Claude Code system-injected XML tags and their content.
|
||||||
|
/// These tags are internal metadata and should not be displayed to users.
|
||||||
|
/// Note: Rust regex doesn't support backreferences, so each tag is listed explicitly.
|
||||||
|
fn system_tag_regex() -> &'static Regex {
|
||||||
|
static RE: OnceLock<Regex> = OnceLock::new();
|
||||||
|
RE.get_or_init(|| {
|
||||||
|
Regex::new(concat!(
|
||||||
|
r"(?s)",
|
||||||
|
r"<system-reminder>.*?</system-reminder>",
|
||||||
|
r"|<local-command-caveat>.*?</local-command-caveat>",
|
||||||
|
r"|<command-name>.*?</command-name>",
|
||||||
|
r"|<command-message>.*?</command-message>",
|
||||||
|
r"|<command-args>.*?</command-args>",
|
||||||
|
r"|<local-command-stdout>.*?</local-command-stdout>",
|
||||||
|
r"|<user-prompt-submit-hook>.*?</user-prompt-submit-hook>",
|
||||||
|
))
|
||||||
|
.unwrap()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Regex that matches an optional model capacity suffix like `[1M]` / `[500k]`.
|
||||||
|
fn model_capacity_suffix_regex() -> &'static Regex {
|
||||||
|
static RE: OnceLock<Regex> = OnceLock::new();
|
||||||
|
RE.get_or_init(|| {
|
||||||
|
Regex::new(r"(?i)\[\s*([0-9]+(?:\.[0-9]+)?)\s*([km])\s*\]\s*$")
|
||||||
|
.expect("valid model capacity regex")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip system-injected XML tags from text content.
|
||||||
|
/// Returns None if the text becomes empty after stripping.
|
||||||
|
fn strip_system_tags(text: &str) -> Option<String> {
|
||||||
|
let cleaned = system_tag_regex().replace_all(text, "");
|
||||||
|
let trimmed = cleaned.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(trimmed.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a JSONL entry is a system meta message (isMeta: true).
|
||||||
|
fn is_meta_message(value: &serde_json::Value) -> bool {
|
||||||
|
value
|
||||||
|
.get("isMeta")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_model_capacity_suffix(model: &str) -> Option<u64> {
|
||||||
|
let captures = model_capacity_suffix_regex().captures(model.trim())?;
|
||||||
|
let value = captures.get(1)?.as_str().parse::<f64>().ok()?;
|
||||||
|
if !value.is_finite() || value <= 0.0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let unit = captures
|
||||||
|
.get(2)
|
||||||
|
.map(|m| m.as_str().to_ascii_lowercase())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let multiplier = match unit.as_str() {
|
||||||
|
"m" => 1_000_000.0,
|
||||||
|
"k" => 1_000.0,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
|
||||||
|
Some((value * multiplier) as u64)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn claude_context_window_max_tokens_for_model(model: Option<&str>) -> Option<u64> {
|
||||||
|
let model = model?.trim();
|
||||||
|
if model.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user/model config contains an explicit capacity suffix, prefer it.
|
||||||
|
if let Some(suffixed_limit) = parse_model_capacity_suffix(model) {
|
||||||
|
return Some(suffixed_limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claude models default to 200k when no explicit capacity is provided.
|
||||||
|
if model.to_ascii_lowercase().starts_with("claude") {
|
||||||
|
return Some(200_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn claude_context_window_used_tokens_from_usage(usage: &TurnUsage) -> Option<u64> {
|
||||||
|
let used_tokens = usage
|
||||||
|
.input_tokens
|
||||||
|
.saturating_add(usage.cache_creation_input_tokens)
|
||||||
|
.saturating_add(usage.cache_read_input_tokens);
|
||||||
|
if used_tokens > 0 {
|
||||||
|
Some(used_tokens)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn latest_claude_context_window_used_tokens(turns: &[MessageTurn]) -> Option<u64> {
|
||||||
|
turns.iter().rev().find_map(|turn| {
|
||||||
|
turn.usage
|
||||||
|
.as_ref()
|
||||||
|
.and_then(claude_context_window_used_tokens_from_usage)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_claude_context_window_stats(
|
||||||
|
stats: Option<SessionStats>,
|
||||||
|
used_tokens: Option<u64>,
|
||||||
|
max_tokens: Option<u64>,
|
||||||
|
) -> Option<SessionStats> {
|
||||||
|
if used_tokens.is_none() && max_tokens.is_none() {
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
let usage_percent = match (used_tokens, max_tokens) {
|
||||||
|
(Some(used), Some(max)) if max > 0 => Some((used as f64 / max as f64) * 100.0),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
match stats {
|
||||||
|
Some(mut s) => {
|
||||||
|
s.context_window_used_tokens = used_tokens;
|
||||||
|
s.context_window_max_tokens = max_tokens;
|
||||||
|
s.context_window_usage_percent = usage_percent;
|
||||||
|
Some(s)
|
||||||
|
}
|
||||||
|
None => Some(SessionStats {
|
||||||
|
total_usage: None,
|
||||||
|
total_tokens: None,
|
||||||
|
total_duration_ms: 0,
|
||||||
|
context_window_used_tokens: used_tokens,
|
||||||
|
context_window_max_tokens: max_tokens,
|
||||||
|
context_window_usage_percent: usage_percent,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ClaudeParser {
|
||||||
|
base_dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClaudeParser {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let base_dir = resolve_claude_config_dir().join("projects");
|
||||||
|
Self { base_dir }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_folder_path(encoded: &str) -> String {
|
||||||
|
encoded.replace('-', "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_jsonl_summary(
|
||||||
|
&self,
|
||||||
|
path: &PathBuf,
|
||||||
|
) -> Result<Option<ConversationSummary>, ParseError> {
|
||||||
|
let file = fs::File::open(path)?;
|
||||||
|
let reader = BufReader::new(file);
|
||||||
|
|
||||||
|
let mut conversation_id: Option<String> = None;
|
||||||
|
let mut cwd: Option<String> = None;
|
||||||
|
let mut git_branch: Option<String> = None;
|
||||||
|
let mut model: Option<String> = None;
|
||||||
|
let mut title: Option<String> = None;
|
||||||
|
let mut first_timestamp: Option<DateTime<Utc>> = None;
|
||||||
|
let mut last_timestamp: Option<DateTime<Utc>> = None;
|
||||||
|
let mut message_count: u32 = 0;
|
||||||
|
|
||||||
|
for line in reader.lines() {
|
||||||
|
let line = match line {
|
||||||
|
Ok(l) => l,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if line.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let value: serde_json::Value = match serde_json::from_str(&line) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let msg_type = value.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||||
|
|
||||||
|
// Skip non-conversation entries
|
||||||
|
if msg_type == "file-history-snapshot" || msg_type == "progress" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip system meta messages (e.g. local-command-caveat injections)
|
||||||
|
if is_meta_message(&value) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if conversation_id.is_none() {
|
||||||
|
conversation_id = value
|
||||||
|
.get("sessionId")
|
||||||
|
.and_then(|s| s.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if cwd.is_none() {
|
||||||
|
cwd = value
|
||||||
|
.get("cwd")
|
||||||
|
.and_then(|s| s.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if git_branch.is_none() {
|
||||||
|
git_branch = value
|
||||||
|
.get("gitBranch")
|
||||||
|
.and_then(|s| s.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ts_str) = value.get("timestamp").and_then(|t| t.as_str()) {
|
||||||
|
if let Ok(ts) = ts_str.parse::<DateTime<Utc>>() {
|
||||||
|
if first_timestamp.is_none() {
|
||||||
|
first_timestamp = Some(ts);
|
||||||
|
}
|
||||||
|
last_timestamp = Some(ts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg_type == "user" || msg_type == "assistant" {
|
||||||
|
message_count += 1;
|
||||||
|
|
||||||
|
// Extract model from assistant messages
|
||||||
|
if msg_type == "assistant" && model.is_none() {
|
||||||
|
model = value
|
||||||
|
.get("message")
|
||||||
|
.and_then(|m| m.get("model"))
|
||||||
|
.and_then(|m| m.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract title from first user message
|
||||||
|
if msg_type == "user" && title.is_none() {
|
||||||
|
title = extract_user_text(&value).map(|t| truncate_str(&t, 100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let started_at = match first_timestamp {
|
||||||
|
Some(ts) => ts,
|
||||||
|
None => return Ok(None),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use filename (without .jsonl) as ID fallback
|
||||||
|
let id = conversation_id.unwrap_or_else(|| {
|
||||||
|
path.file_stem()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string()
|
||||||
|
});
|
||||||
|
|
||||||
|
let folder_path = cwd.clone();
|
||||||
|
let folder_name = folder_path.as_ref().map(|p| folder_name_from_path(p));
|
||||||
|
|
||||||
|
Ok(Some(ConversationSummary {
|
||||||
|
id,
|
||||||
|
agent_type: AgentType::ClaudeCode,
|
||||||
|
folder_path,
|
||||||
|
folder_name,
|
||||||
|
title,
|
||||||
|
started_at,
|
||||||
|
ended_at: last_timestamp,
|
||||||
|
message_count,
|
||||||
|
model,
|
||||||
|
git_branch,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_claude_config_dir() -> PathBuf {
|
||||||
|
resolve_claude_config_dir_from(
|
||||||
|
std::env::var_os("CLAUDE_CONFIG_DIR"),
|
||||||
|
dirs::home_dir(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_claude_config_dir_from(
|
||||||
|
claude_config_dir_env: Option<std::ffi::OsString>,
|
||||||
|
home_dir: Option<PathBuf>,
|
||||||
|
) -> PathBuf {
|
||||||
|
claude_config_dir_env
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| home_dir.unwrap_or_default().join(".claude"))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AgentParser for ClaudeParser {
|
||||||
|
fn list_conversations(&self) -> Result<Vec<ConversationSummary>, ParseError> {
|
||||||
|
let mut conversations = Vec::new();
|
||||||
|
|
||||||
|
if !self.base_dir.exists() {
|
||||||
|
return Ok(conversations);
|
||||||
|
}
|
||||||
|
|
||||||
|
let entries = fs::read_dir(&self.base_dir)?;
|
||||||
|
for entry in entries {
|
||||||
|
let entry = match entry {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let project_dir = entry.path();
|
||||||
|
if !project_dir.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let jsonl_files = fs::read_dir(&project_dir)?;
|
||||||
|
for file_entry in jsonl_files {
|
||||||
|
let file_entry = match file_entry {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let file_path = file_entry.path();
|
||||||
|
if file_path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.parse_jsonl_summary(&file_path) {
|
||||||
|
Ok(Some(mut summary)) => {
|
||||||
|
// If folder_path is still None, derive from directory name
|
||||||
|
if summary.folder_path.is_none() {
|
||||||
|
let dir_name = project_dir
|
||||||
|
.file_name()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
let decoded = Self::decode_folder_path(&dir_name);
|
||||||
|
summary.folder_path = Some(decoded.clone());
|
||||||
|
summary.folder_name = Some(folder_name_from_path(&decoded));
|
||||||
|
}
|
||||||
|
conversations.push(summary);
|
||||||
|
}
|
||||||
|
Ok(None) => continue,
|
||||||
|
Err(_) => continue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
conversations.sort_by(|a, b| b.started_at.cmp(&a.started_at));
|
||||||
|
Ok(conversations)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_conversation(&self, conversation_id: &str) -> Result<ConversationDetail, ParseError> {
|
||||||
|
// Find the conversation file by searching all directories
|
||||||
|
if !self.base_dir.exists() {
|
||||||
|
return Err(ParseError::ConversationNotFound(
|
||||||
|
conversation_id.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
for entry in fs::read_dir(&self.base_dir)? {
|
||||||
|
let entry = match entry {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let project_dir = entry.path();
|
||||||
|
if !project_dir.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_path = project_dir.join(format!("{}.jsonl", conversation_id));
|
||||||
|
if file_path.exists() {
|
||||||
|
return self.parse_conversation_detail(&file_path, conversation_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(ParseError::ConversationNotFound(
|
||||||
|
conversation_id.to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClaudeParser {
|
||||||
|
fn parse_conversation_detail(
|
||||||
|
&self,
|
||||||
|
path: &PathBuf,
|
||||||
|
conversation_id: &str,
|
||||||
|
) -> Result<ConversationDetail, ParseError> {
|
||||||
|
let file = fs::File::open(path)?;
|
||||||
|
let reader = BufReader::new(file);
|
||||||
|
|
||||||
|
let mut messages = Vec::new();
|
||||||
|
let mut cwd: Option<String> = None;
|
||||||
|
let mut git_branch: Option<String> = None;
|
||||||
|
let mut model: Option<String> = None;
|
||||||
|
let mut title: Option<String> = None;
|
||||||
|
let mut first_timestamp: Option<DateTime<Utc>> = None;
|
||||||
|
let mut last_timestamp: Option<DateTime<Utc>> = None;
|
||||||
|
|
||||||
|
for line in reader.lines() {
|
||||||
|
let line = match line {
|
||||||
|
Ok(l) => l,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if line.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let value: serde_json::Value = match serde_json::from_str(&line) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let msg_type = value.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||||
|
|
||||||
|
if msg_type == "file-history-snapshot" || msg_type == "progress" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip system meta messages
|
||||||
|
if is_meta_message(&value) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if cwd.is_none() {
|
||||||
|
cwd = value
|
||||||
|
.get("cwd")
|
||||||
|
.and_then(|s| s.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
}
|
||||||
|
if git_branch.is_none() {
|
||||||
|
git_branch = value
|
||||||
|
.get("gitBranch")
|
||||||
|
.and_then(|s| s.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ts_str) = value.get("timestamp").and_then(|t| t.as_str()) {
|
||||||
|
if let Ok(ts) = ts_str.parse::<DateTime<Utc>>() {
|
||||||
|
if first_timestamp.is_none() {
|
||||||
|
first_timestamp = Some(ts);
|
||||||
|
}
|
||||||
|
last_timestamp = Some(ts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match msg_type {
|
||||||
|
"user" => {
|
||||||
|
let content = extract_user_content(&value);
|
||||||
|
|
||||||
|
// Skip user messages that are empty after system tag stripping
|
||||||
|
if content.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let timestamp = parse_timestamp(&value).unwrap_or_else(Utc::now);
|
||||||
|
let uuid = value
|
||||||
|
.get("uuid")
|
||||||
|
.and_then(|u| u.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
if title.is_none() {
|
||||||
|
if let Some(first_text) = content.iter().find_map(|c| match c {
|
||||||
|
ContentBlock::Text { text } => Some(text.clone()),
|
||||||
|
_ => None,
|
||||||
|
}) {
|
||||||
|
title = Some(truncate_str(&first_text, 100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.push(UnifiedMessage {
|
||||||
|
id: uuid,
|
||||||
|
role: MessageRole::User,
|
||||||
|
content,
|
||||||
|
timestamp,
|
||||||
|
usage: None,
|
||||||
|
duration_ms: None,
|
||||||
|
model: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
"assistant" => {
|
||||||
|
let timestamp = parse_timestamp(&value).unwrap_or_else(Utc::now);
|
||||||
|
let uuid = value
|
||||||
|
.get("uuid")
|
||||||
|
.and_then(|u| u.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let msg_model = value
|
||||||
|
.get("message")
|
||||||
|
.and_then(|m| m.get("model"))
|
||||||
|
.and_then(|m| m.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
if model.is_none() {
|
||||||
|
model = msg_model.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = extract_assistant_content(&value);
|
||||||
|
let usage = extract_usage(&value);
|
||||||
|
|
||||||
|
messages.push(UnifiedMessage {
|
||||||
|
id: uuid,
|
||||||
|
role: MessageRole::Assistant,
|
||||||
|
content,
|
||||||
|
timestamp,
|
||||||
|
usage,
|
||||||
|
duration_ms: None,
|
||||||
|
model: msg_model,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
"system" => {
|
||||||
|
let subtype = value.get("subtype").and_then(|s| s.as_str()).unwrap_or("");
|
||||||
|
if subtype == "turn_duration" {
|
||||||
|
if let Some(duration) = value.get("durationMs").and_then(|d| d.as_u64()) {
|
||||||
|
// Attach to the last assistant message
|
||||||
|
if let Some(last) = messages
|
||||||
|
.iter_mut()
|
||||||
|
.rev()
|
||||||
|
.find(|m| matches!(m.role, MessageRole::Assistant))
|
||||||
|
{
|
||||||
|
last.duration_ms = Some(duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let folder_path = cwd.clone();
|
||||||
|
let folder_name = folder_path.as_ref().map(|p| folder_name_from_path(p));
|
||||||
|
|
||||||
|
let turns = group_into_turns(messages);
|
||||||
|
let context_window_used_tokens = latest_claude_context_window_used_tokens(&turns);
|
||||||
|
let context_window_max_tokens =
|
||||||
|
claude_context_window_max_tokens_for_model(model.as_deref());
|
||||||
|
let session_stats = merge_claude_context_window_stats(
|
||||||
|
super::compute_session_stats(&turns),
|
||||||
|
context_window_used_tokens,
|
||||||
|
context_window_max_tokens,
|
||||||
|
);
|
||||||
|
|
||||||
|
let summary = ConversationSummary {
|
||||||
|
id: conversation_id.to_string(),
|
||||||
|
agent_type: AgentType::ClaudeCode,
|
||||||
|
folder_path,
|
||||||
|
folder_name,
|
||||||
|
title,
|
||||||
|
started_at: first_timestamp.unwrap_or_else(Utc::now),
|
||||||
|
ended_at: last_timestamp,
|
||||||
|
message_count: turns.len() as u32,
|
||||||
|
model,
|
||||||
|
git_branch,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(ConversationDetail {
|
||||||
|
summary,
|
||||||
|
turns,
|
||||||
|
session_stats,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_timestamp(value: &serde_json::Value) -> Option<DateTime<Utc>> {
|
||||||
|
value
|
||||||
|
.get("timestamp")
|
||||||
|
.and_then(|t| t.as_str())
|
||||||
|
.and_then(|s| s.parse::<DateTime<Utc>>().ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_user_text(value: &serde_json::Value) -> Option<String> {
|
||||||
|
let message = value.get("message")?;
|
||||||
|
let content = message.get("content")?;
|
||||||
|
|
||||||
|
if let Some(text) = content.as_str() {
|
||||||
|
return strip_system_tags(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(arr) = content.as_array() {
|
||||||
|
for item in arr {
|
||||||
|
if item.get("type").and_then(|t| t.as_str()) == Some("text") {
|
||||||
|
if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
|
||||||
|
if let Some(cleaned) = strip_system_tags(text) {
|
||||||
|
return Some(cleaned);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_user_content(value: &serde_json::Value) -> Vec<ContentBlock> {
|
||||||
|
let mut blocks = Vec::new();
|
||||||
|
let message = match value.get("message") {
|
||||||
|
Some(m) => m,
|
||||||
|
None => return blocks,
|
||||||
|
};
|
||||||
|
let content = match message.get("content") {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return blocks,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(text) = content.as_str() {
|
||||||
|
if let Some(cleaned) = strip_system_tags(text) {
|
||||||
|
blocks.push(ContentBlock::Text { text: cleaned });
|
||||||
|
}
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(arr) = content.as_array() {
|
||||||
|
for item in arr {
|
||||||
|
let block_type = item.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||||
|
match block_type {
|
||||||
|
"text" => {
|
||||||
|
if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
|
||||||
|
if let Some(cleaned) = strip_system_tags(text) {
|
||||||
|
blocks.push(ContentBlock::Text { text: cleaned });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"tool_result" | "server_tool_result" => {
|
||||||
|
let tool_use_id = item
|
||||||
|
.get("tool_use_id")
|
||||||
|
.and_then(|n| n.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
let output = extract_tool_result_text(item);
|
||||||
|
let is_error = item
|
||||||
|
.get("is_error")
|
||||||
|
.and_then(|e| e.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
blocks.push(ContentBlock::ToolResult {
|
||||||
|
tool_use_id,
|
||||||
|
output_preview: output,
|
||||||
|
is_error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_assistant_content(value: &serde_json::Value) -> Vec<ContentBlock> {
|
||||||
|
let mut blocks = Vec::new();
|
||||||
|
let message = match value.get("message") {
|
||||||
|
Some(m) => m,
|
||||||
|
None => return blocks,
|
||||||
|
};
|
||||||
|
let content = match message.get("content") {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return blocks,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(arr) = content.as_array() {
|
||||||
|
for item in arr {
|
||||||
|
let block_type = item.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||||
|
match block_type {
|
||||||
|
"text" => {
|
||||||
|
if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
|
||||||
|
blocks.push(ContentBlock::Text {
|
||||||
|
text: text.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"thinking" => {
|
||||||
|
if let Some(text) = item.get("thinking").and_then(|t| t.as_str()) {
|
||||||
|
blocks.push(ContentBlock::Thinking {
|
||||||
|
text: text.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"tool_use" | "server_tool_use" => {
|
||||||
|
let tool_use_id = item
|
||||||
|
.get("id")
|
||||||
|
.and_then(|n| n.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
let tool_name = item
|
||||||
|
.get("name")
|
||||||
|
.and_then(|n| n.as_str())
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string();
|
||||||
|
let input_preview = item.get("input").map(|i| i.to_string());
|
||||||
|
blocks.push(ContentBlock::ToolUse {
|
||||||
|
tool_use_id,
|
||||||
|
tool_name,
|
||||||
|
input_preview,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_usage(value: &serde_json::Value) -> Option<TurnUsage> {
|
||||||
|
let usage = value.get("message")?.get("usage")?;
|
||||||
|
Some(TurnUsage {
|
||||||
|
input_tokens: usage
|
||||||
|
.get("input_tokens")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(0),
|
||||||
|
output_tokens: usage
|
||||||
|
.get("output_tokens")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(0),
|
||||||
|
cache_creation_input_tokens: usage
|
||||||
|
.get("cache_creation_input_tokens")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(0),
|
||||||
|
cache_read_input_tokens: usage
|
||||||
|
.get("cache_read_input_tokens")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_tool_result_text(item: &serde_json::Value) -> Option<String> {
|
||||||
|
let content = item.get("content")?;
|
||||||
|
if let Some(text) = content.as_str() {
|
||||||
|
return Some(text.to_string());
|
||||||
|
}
|
||||||
|
if let Some(arr) = content.as_array() {
|
||||||
|
let texts: Vec<String> = arr
|
||||||
|
.iter()
|
||||||
|
.filter_map(|c| {
|
||||||
|
if c.get("type").and_then(|t| t.as_str()) == Some("text") {
|
||||||
|
c.get("text")
|
||||||
|
.and_then(|t| t.as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if !texts.is_empty() {
|
||||||
|
return Some(texts.join("\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a user message contains ONLY tool_result blocks (no text).
|
||||||
|
/// In Claude Code, tool results come back as "user" messages.
|
||||||
|
fn is_tool_result_only(msg: &UnifiedMessage) -> bool {
|
||||||
|
matches!(msg.role, MessageRole::User)
|
||||||
|
&& !msg.content.is_empty()
|
||||||
|
&& msg
|
||||||
|
.content
|
||||||
|
.iter()
|
||||||
|
.all(|b| matches!(b, ContentBlock::ToolResult { .. }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Group flat messages into conversation turns.
|
||||||
|
/// Claude Code rule: assistant msg + following tool-result-only user msgs
|
||||||
|
/// merge into one Assistant turn.
|
||||||
|
fn group_into_turns(messages: Vec<UnifiedMessage>) -> Vec<MessageTurn> {
|
||||||
|
let mut turns = Vec::new();
|
||||||
|
let mut i = 0;
|
||||||
|
|
||||||
|
while i < messages.len() {
|
||||||
|
let msg = &messages[i];
|
||||||
|
|
||||||
|
if matches!(msg.role, MessageRole::Assistant) {
|
||||||
|
let mut blocks: Vec<ContentBlock> = msg.content.clone();
|
||||||
|
let timestamp = msg.timestamp;
|
||||||
|
let id = format!("turn-{}", turns.len());
|
||||||
|
let usage = msg.usage.clone();
|
||||||
|
let duration_ms = msg.duration_ms;
|
||||||
|
let turn_model = msg.model.clone();
|
||||||
|
i += 1;
|
||||||
|
|
||||||
|
// Absorb consecutive assistant msgs AND tool-result-only user msgs
|
||||||
|
while i < messages.len()
|
||||||
|
&& (matches!(messages[i].role, MessageRole::Assistant)
|
||||||
|
|| is_tool_result_only(&messages[i]))
|
||||||
|
{
|
||||||
|
blocks.extend(messages[i].content.clone());
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
turns.push(MessageTurn {
|
||||||
|
id,
|
||||||
|
role: TurnRole::Assistant,
|
||||||
|
blocks,
|
||||||
|
timestamp,
|
||||||
|
usage,
|
||||||
|
duration_ms,
|
||||||
|
model: turn_model,
|
||||||
|
});
|
||||||
|
} else if matches!(msg.role, MessageRole::System) {
|
||||||
|
turns.push(MessageTurn {
|
||||||
|
id: format!("turn-{}", turns.len()),
|
||||||
|
role: TurnRole::System,
|
||||||
|
blocks: msg.content.clone(),
|
||||||
|
timestamp: msg.timestamp,
|
||||||
|
usage: None,
|
||||||
|
duration_ms: None,
|
||||||
|
model: None,
|
||||||
|
});
|
||||||
|
i += 1;
|
||||||
|
} else {
|
||||||
|
turns.push(MessageTurn {
|
||||||
|
id: format!("turn-{}", turns.len()),
|
||||||
|
role: TurnRole::User,
|
||||||
|
blocks: msg.content.clone(),
|
||||||
|
timestamp: msg.timestamp,
|
||||||
|
usage: None,
|
||||||
|
duration_ms: None,
|
||||||
|
model: None,
|
||||||
|
});
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
turns
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_model_capacity_suffix() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_model_capacity_suffix("claude-sonnet-4-6[1.5M]"),
|
||||||
|
Some(1_500_000)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_model_capacity_suffix("claude-opus-4-6 [500k]"),
|
||||||
|
Some(500_000)
|
||||||
|
);
|
||||||
|
assert_eq!(parse_model_capacity_suffix("claude-sonnet-4-6"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defaults_context_limit_for_claude_models() {
|
||||||
|
assert_eq!(
|
||||||
|
claude_context_window_max_tokens_for_model(Some("claude-sonnet-4-6")),
|
||||||
|
Some(200_000)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
claude_context_window_max_tokens_for_model(Some("custom-model-x")),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn uses_latest_assistant_usage_for_context_tokens() {
|
||||||
|
let timestamp = Utc::now();
|
||||||
|
let turns = vec![
|
||||||
|
MessageTurn {
|
||||||
|
id: "turn-0".to_string(),
|
||||||
|
role: TurnRole::Assistant,
|
||||||
|
blocks: vec![],
|
||||||
|
timestamp,
|
||||||
|
usage: Some(TurnUsage {
|
||||||
|
input_tokens: 100,
|
||||||
|
output_tokens: 20,
|
||||||
|
cache_creation_input_tokens: 30,
|
||||||
|
cache_read_input_tokens: 40,
|
||||||
|
}),
|
||||||
|
duration_ms: None,
|
||||||
|
model: None,
|
||||||
|
},
|
||||||
|
MessageTurn {
|
||||||
|
id: "turn-1".to_string(),
|
||||||
|
role: TurnRole::Assistant,
|
||||||
|
blocks: vec![],
|
||||||
|
timestamp,
|
||||||
|
usage: Some(TurnUsage {
|
||||||
|
input_tokens: 250,
|
||||||
|
output_tokens: 60,
|
||||||
|
cache_creation_input_tokens: 70,
|
||||||
|
cache_read_input_tokens: 80,
|
||||||
|
}),
|
||||||
|
duration_ms: None,
|
||||||
|
model: None,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
latest_claude_context_window_used_tokens(&turns),
|
||||||
|
Some(250 + 70 + 80)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_detail_sets_claude_context_window_stats() {
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"codeg-claude-parser-{}.jsonl",
|
||||||
|
uuid::Uuid::new_v4()
|
||||||
|
));
|
||||||
|
let mut file = fs::File::create(&path).expect("create temp jsonl");
|
||||||
|
writeln!(
|
||||||
|
file,
|
||||||
|
"{}",
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "user",
|
||||||
|
"sessionId": "session-test",
|
||||||
|
"timestamp": "2026-03-01T10:00:00Z",
|
||||||
|
"uuid": "u1",
|
||||||
|
"cwd": "/tmp/demo",
|
||||||
|
"gitBranch": "main",
|
||||||
|
"message": {
|
||||||
|
"content": [{"type": "text", "text": "hello"}]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.expect("write user line");
|
||||||
|
writeln!(
|
||||||
|
file,
|
||||||
|
"{}",
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "assistant",
|
||||||
|
"sessionId": "session-test",
|
||||||
|
"timestamp": "2026-03-01T10:00:02Z",
|
||||||
|
"uuid": "a1",
|
||||||
|
"message": {
|
||||||
|
"model": "claude-sonnet-4-6",
|
||||||
|
"content": [{"type": "text", "text": "world"}],
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": 1000,
|
||||||
|
"output_tokens": 200,
|
||||||
|
"cache_creation_input_tokens": 300,
|
||||||
|
"cache_read_input_tokens": 400
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.expect("write assistant line");
|
||||||
|
|
||||||
|
let parser = ClaudeParser {
|
||||||
|
base_dir: PathBuf::new(),
|
||||||
|
};
|
||||||
|
let detail = parser
|
||||||
|
.parse_conversation_detail(&path, "session-test")
|
||||||
|
.expect("parse conversation detail");
|
||||||
|
fs::remove_file(&path).expect("cleanup temp jsonl");
|
||||||
|
|
||||||
|
let stats = detail.session_stats.expect("session stats");
|
||||||
|
assert_eq!(stats.context_window_used_tokens, Some(1700));
|
||||||
|
assert_eq!(stats.context_window_max_tokens, Some(200_000));
|
||||||
|
let percent = stats
|
||||||
|
.context_window_usage_percent
|
||||||
|
.expect("context window usage percent");
|
||||||
|
assert!((percent - 0.85).abs() < f64::EPSILON);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn claude_config_dir_env_overrides_home() {
|
||||||
|
let resolved = resolve_claude_config_dir_from(
|
||||||
|
Some(std::ffi::OsString::from("/tmp/claude-config")),
|
||||||
|
Some(PathBuf::from("/Users/default")),
|
||||||
|
);
|
||||||
|
assert_eq!(resolved, PathBuf::from("/tmp/claude-config"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn claude_config_dir_defaults_to_home_dot_claude() {
|
||||||
|
let resolved = resolve_claude_config_dir_from(
|
||||||
|
None,
|
||||||
|
Some(PathBuf::from("/Users/default")),
|
||||||
|
);
|
||||||
|
assert_eq!(resolved, PathBuf::from("/Users/default/.claude"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,715 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
|
use crate::models::*;
|
||||||
|
use crate::parsers::{folder_name_from_path, truncate_str, AgentParser, ParseError};
|
||||||
|
|
||||||
|
pub struct GeminiParser {
|
||||||
|
base_dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GeminiParser {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let base_dir = resolve_gemini_base_dir();
|
||||||
|
Self { base_dir }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn with_base_dir(base_dir: PathBuf) -> Self {
|
||||||
|
Self { base_dir }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tmp_dir(&self) -> PathBuf {
|
||||||
|
self.base_dir.join("tmp")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn history_dir(&self) -> PathBuf {
|
||||||
|
self.base_dir.join("history")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn projects_json_path(&self) -> PathBuf {
|
||||||
|
self.base_dir.join("projects.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_chat_file(path: &Path) -> bool {
|
||||||
|
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||||
|
if !file_name.starts_with("session-") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
path.parent()
|
||||||
|
.and_then(|p| p.file_name())
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
== Some("chats")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_chat_files(&self) -> Vec<PathBuf> {
|
||||||
|
let tmp_dir = self.tmp_dir();
|
||||||
|
if !tmp_dir.exists() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut files: Vec<PathBuf> = WalkDir::new(&tmp_dir)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.map(|e| e.path().to_path_buf())
|
||||||
|
.filter(|p| p.is_file() && Self::is_chat_file(p))
|
||||||
|
.collect();
|
||||||
|
files.sort();
|
||||||
|
files
|
||||||
|
}
|
||||||
|
|
||||||
|
fn project_alias_from_chat_path(path: &Path) -> Option<String> {
|
||||||
|
path.parent()?
|
||||||
|
.parent()?
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_project_root_file(path: PathBuf) -> Option<String> {
|
||||||
|
let raw = fs::read_to_string(path).ok()?;
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(trimmed.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_project_root(&self, alias: &str) -> Option<String> {
|
||||||
|
let tmp_root = self.tmp_dir().join(alias).join(".project_root");
|
||||||
|
if let Some(path) = Self::read_project_root_file(tmp_root) {
|
||||||
|
return Some(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let history_root = self.history_dir().join(alias).join(".project_root");
|
||||||
|
if let Some(path) = Self::read_project_root_file(history_root) {
|
||||||
|
return Some(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.resolve_project_root_from_projects_json(alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_project_root_from_projects_json(&self, alias: &str) -> Option<String> {
|
||||||
|
let raw = fs::read_to_string(self.projects_json_path()).ok()?;
|
||||||
|
let value: serde_json::Value = serde_json::from_str(&raw).ok()?;
|
||||||
|
let projects = value.get("projects")?.as_object()?;
|
||||||
|
projects
|
||||||
|
.iter()
|
||||||
|
.find_map(|(path, mapped_alias)| (mapped_alias.as_str() == Some(alias)).then(|| path))
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_timestamp(value: Option<&serde_json::Value>) -> Option<DateTime<Utc>> {
|
||||||
|
value.and_then(|v| v.as_str()?.parse::<DateTime<Utc>>().ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_text(value: &serde_json::Value) -> Option<String> {
|
||||||
|
match value {
|
||||||
|
serde_json::Value::String(text) => {
|
||||||
|
let trimmed = text.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(trimmed.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serde_json::Value::Array(items) => {
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
for item in items {
|
||||||
|
if let Some(text) = item.get("text").and_then(Self::extract_text) {
|
||||||
|
parts.push(text);
|
||||||
|
} else if let Some(text) = Self::extract_text(item) {
|
||||||
|
parts.push(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if parts.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(parts.join("\n"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serde_json::Value::Object(map) => {
|
||||||
|
if let Some(text) = map.get("text").and_then(Self::extract_text) {
|
||||||
|
return Some(text);
|
||||||
|
}
|
||||||
|
if let Some(text) = map.get("message").and_then(Self::extract_text) {
|
||||||
|
return Some(text);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_message_text(message: &serde_json::Value) -> Option<String> {
|
||||||
|
message
|
||||||
|
.get("content")
|
||||||
|
.and_then(Self::extract_text)
|
||||||
|
.or_else(|| message.get("message").and_then(Self::extract_text))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_summary_from_value(
|
||||||
|
&self,
|
||||||
|
path: &Path,
|
||||||
|
value: &serde_json::Value,
|
||||||
|
) -> Option<ConversationSummary> {
|
||||||
|
let id = value.get("sessionId").and_then(|v| v.as_str())?.to_string();
|
||||||
|
let messages = value
|
||||||
|
.get("messages")
|
||||||
|
.and_then(|m| m.as_array())
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let first_message_ts = messages
|
||||||
|
.first()
|
||||||
|
.and_then(|m| Self::parse_timestamp(m.get("timestamp")));
|
||||||
|
let last_message_ts = messages
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find_map(|m| Self::parse_timestamp(m.get("timestamp")));
|
||||||
|
|
||||||
|
let started_at = Self::parse_timestamp(value.get("startTime"))
|
||||||
|
.or(first_message_ts)
|
||||||
|
.unwrap_or_else(Utc::now);
|
||||||
|
let ended_at = Self::parse_timestamp(value.get("lastUpdated")).or(last_message_ts);
|
||||||
|
|
||||||
|
let title = messages
|
||||||
|
.iter()
|
||||||
|
.filter(|m| m.get("type").and_then(|t| t.as_str()) == Some("user"))
|
||||||
|
.find_map(Self::extract_message_text)
|
||||||
|
.map(|t| truncate_str(&t, 100));
|
||||||
|
|
||||||
|
let model = messages.iter().rev().find_map(|m| {
|
||||||
|
m.get("model")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
});
|
||||||
|
|
||||||
|
let folder_alias = Self::project_alias_from_chat_path(path);
|
||||||
|
let folder_path = folder_alias
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|alias| self.resolve_project_root(alias));
|
||||||
|
let folder_name = folder_path
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| folder_name_from_path(p))
|
||||||
|
.or(folder_alias);
|
||||||
|
|
||||||
|
Some(ConversationSummary {
|
||||||
|
id,
|
||||||
|
agent_type: AgentType::Gemini,
|
||||||
|
folder_path,
|
||||||
|
folder_name,
|
||||||
|
title,
|
||||||
|
started_at,
|
||||||
|
ended_at,
|
||||||
|
message_count: messages.len() as u32,
|
||||||
|
model,
|
||||||
|
git_branch: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn result_preview(result: Option<&serde_json::Value>) -> Option<String> {
|
||||||
|
let v = result?;
|
||||||
|
if let Some(s) = v.as_str() {
|
||||||
|
let trimmed = s.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
return Some(trimmed.to_string());
|
||||||
|
}
|
||||||
|
serde_json::to_string(v).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool_call_is_error(call: &serde_json::Value, output_preview: Option<&str>) -> bool {
|
||||||
|
if call
|
||||||
|
.get("status")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| {
|
||||||
|
matches!(
|
||||||
|
s.to_ascii_lowercase().as_str(),
|
||||||
|
"error" | "failed" | "failure" | "cancelled" | "canceled"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if call
|
||||||
|
.get("result")
|
||||||
|
.and_then(|r| r.as_array())
|
||||||
|
.map(|items| {
|
||||||
|
items.iter().any(|item| {
|
||||||
|
item.get("functionResponse")
|
||||||
|
.and_then(|fr| fr.get("response"))
|
||||||
|
.and_then(|resp| resp.get("error"))
|
||||||
|
.is_some()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
output_preview
|
||||||
|
.map(|s| s.trim_start().to_ascii_lowercase().starts_with("error"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_assistant_blocks(message: &serde_json::Value) -> Vec<ContentBlock> {
|
||||||
|
let mut blocks: Vec<ContentBlock> = Vec::new();
|
||||||
|
|
||||||
|
if let Some(thoughts) = message.get("thoughts").and_then(|v| v.as_array()) {
|
||||||
|
for thought in thoughts {
|
||||||
|
let subject = thought
|
||||||
|
.get("subject")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
let description = thought
|
||||||
|
.get("description")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
let text = match (subject, description) {
|
||||||
|
(Some(sub), Some(desc)) => format!("{sub}: {desc}"),
|
||||||
|
(Some(sub), None) => sub.to_string(),
|
||||||
|
(None, Some(desc)) => desc.to_string(),
|
||||||
|
(None, None) => continue,
|
||||||
|
};
|
||||||
|
blocks.push(ContentBlock::Thinking { text });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(tool_calls) = message.get("toolCalls").and_then(|v| v.as_array()) {
|
||||||
|
for call in tool_calls {
|
||||||
|
let tool_use_id = call
|
||||||
|
.get("id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
let tool_name = call
|
||||||
|
.get("displayName")
|
||||||
|
.or_else(|| call.get("name"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string();
|
||||||
|
let input_preview = call
|
||||||
|
.get("args")
|
||||||
|
.and_then(|v| serde_json::to_string(v).ok())
|
||||||
|
.or_else(|| {
|
||||||
|
call.get("input")
|
||||||
|
.and_then(|v| Self::result_preview(Some(v)))
|
||||||
|
});
|
||||||
|
|
||||||
|
blocks.push(ContentBlock::ToolUse {
|
||||||
|
tool_use_id: tool_use_id.clone(),
|
||||||
|
tool_name,
|
||||||
|
input_preview,
|
||||||
|
});
|
||||||
|
|
||||||
|
let output_preview = call
|
||||||
|
.get("resultDisplay")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.or_else(|| Self::result_preview(call.get("result")));
|
||||||
|
let is_error = Self::tool_call_is_error(call, output_preview.as_deref());
|
||||||
|
|
||||||
|
blocks.push(ContentBlock::ToolResult {
|
||||||
|
tool_use_id,
|
||||||
|
output_preview,
|
||||||
|
is_error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(text) = Self::extract_message_text(message) {
|
||||||
|
blocks.push(ContentBlock::Text { text });
|
||||||
|
}
|
||||||
|
|
||||||
|
blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_usage(message: &serde_json::Value) -> Option<TurnUsage> {
|
||||||
|
let tokens = message.get("tokens")?;
|
||||||
|
let input_tokens = tokens.get("input").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||||
|
let output_tokens = tokens.get("output").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||||
|
let cached_tokens = tokens.get("cached").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||||
|
Some(TurnUsage {
|
||||||
|
input_tokens,
|
||||||
|
output_tokens,
|
||||||
|
cache_creation_input_tokens: 0,
|
||||||
|
cache_read_input_tokens: cached_tokens,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_conversation_detail(
|
||||||
|
&self,
|
||||||
|
path: &Path,
|
||||||
|
value: &serde_json::Value,
|
||||||
|
conversation_id: &str,
|
||||||
|
) -> Result<ConversationDetail, ParseError> {
|
||||||
|
let mut summary = self
|
||||||
|
.parse_summary_from_value(path, value)
|
||||||
|
.ok_or_else(|| ParseError::ConversationNotFound(conversation_id.to_string()))?;
|
||||||
|
let messages_raw = value
|
||||||
|
.get("messages")
|
||||||
|
.and_then(|m| m.as_array())
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let mut messages: Vec<UnifiedMessage> = Vec::new();
|
||||||
|
for raw in messages_raw {
|
||||||
|
let msg_id = raw
|
||||||
|
.get("id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(|| format!("msg-{}", messages.len()));
|
||||||
|
let timestamp =
|
||||||
|
Self::parse_timestamp(raw.get("timestamp")).unwrap_or(summary.started_at);
|
||||||
|
let msg_type = raw
|
||||||
|
.get("type")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
|
||||||
|
match msg_type.as_str() {
|
||||||
|
"user" => {
|
||||||
|
let Some(text) = Self::extract_message_text(&raw) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
messages.push(UnifiedMessage {
|
||||||
|
id: msg_id,
|
||||||
|
role: MessageRole::User,
|
||||||
|
content: vec![ContentBlock::Text { text }],
|
||||||
|
timestamp,
|
||||||
|
usage: None,
|
||||||
|
duration_ms: None,
|
||||||
|
model: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
"gemini" | "assistant" | "model" => {
|
||||||
|
let blocks = Self::parse_assistant_blocks(&raw);
|
||||||
|
if blocks.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
messages.push(UnifiedMessage {
|
||||||
|
id: msg_id,
|
||||||
|
role: MessageRole::Assistant,
|
||||||
|
content: blocks,
|
||||||
|
timestamp,
|
||||||
|
usage: Self::parse_usage(&raw),
|
||||||
|
duration_ms: None,
|
||||||
|
model: raw
|
||||||
|
.get("model")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
"system" => {
|
||||||
|
let Some(text) = Self::extract_message_text(&raw) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
messages.push(UnifiedMessage {
|
||||||
|
id: msg_id,
|
||||||
|
role: MessageRole::System,
|
||||||
|
content: vec![ContentBlock::Text { text }],
|
||||||
|
timestamp,
|
||||||
|
usage: None,
|
||||||
|
duration_ms: None,
|
||||||
|
model: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approximate duration for assistant messages from adjacent timestamps
|
||||||
|
for i in 0..messages.len() {
|
||||||
|
if matches!(messages[i].role, MessageRole::Assistant)
|
||||||
|
&& messages[i].duration_ms.is_none()
|
||||||
|
{
|
||||||
|
if let Some(next) = messages.get(i + 1) {
|
||||||
|
let dur = (next.timestamp - messages[i].timestamp).num_milliseconds();
|
||||||
|
if dur > 0 && dur < 300_000 {
|
||||||
|
messages[i].duration_ms = Some(dur as u64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let turns = group_into_turns(messages);
|
||||||
|
summary.message_count = turns.len() as u32;
|
||||||
|
summary.id = conversation_id.to_string();
|
||||||
|
let context_window_used_tokens = super::latest_turn_total_usage_tokens(&turns);
|
||||||
|
let context_window_max_tokens =
|
||||||
|
super::infer_context_window_max_tokens(summary.model.as_deref());
|
||||||
|
let session_stats = super::merge_context_window_stats(
|
||||||
|
super::compute_session_stats(&turns),
|
||||||
|
context_window_used_tokens,
|
||||||
|
context_window_max_tokens,
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(ConversationDetail {
|
||||||
|
summary,
|
||||||
|
turns,
|
||||||
|
session_stats,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_gemini_base_dir() -> PathBuf {
|
||||||
|
resolve_gemini_base_dir_from(
|
||||||
|
std::env::var_os("GEMINI_CLI_HOME"),
|
||||||
|
dirs::home_dir(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_gemini_base_dir_from(
|
||||||
|
gemini_cli_home_env: Option<std::ffi::OsString>,
|
||||||
|
home_dir: Option<PathBuf>,
|
||||||
|
) -> PathBuf {
|
||||||
|
gemini_cli_home_env
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| home_dir.unwrap_or_default())
|
||||||
|
.join(".gemini")
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AgentParser for GeminiParser {
|
||||||
|
fn list_conversations(&self) -> Result<Vec<ConversationSummary>, ParseError> {
|
||||||
|
let mut conversations = Vec::new();
|
||||||
|
|
||||||
|
for chat_file in self.list_chat_files() {
|
||||||
|
let raw = match fs::read_to_string(&chat_file) {
|
||||||
|
Ok(raw) => raw,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let value: serde_json::Value = match serde_json::from_str(&raw) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if let Some(summary) = self.parse_summary_from_value(&chat_file, &value) {
|
||||||
|
conversations.push(summary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
conversations.sort_by(|a, b| b.started_at.cmp(&a.started_at));
|
||||||
|
Ok(conversations)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_conversation(&self, conversation_id: &str) -> Result<ConversationDetail, ParseError> {
|
||||||
|
for chat_file in self.list_chat_files() {
|
||||||
|
let raw = match fs::read_to_string(&chat_file) {
|
||||||
|
Ok(raw) => raw,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if !raw.contains(conversation_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let value: serde_json::Value = match serde_json::from_str(&raw) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let session_id = value.get("sessionId").and_then(|v| v.as_str());
|
||||||
|
if session_id != Some(conversation_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self.parse_conversation_detail(&chat_file, &value, conversation_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(ParseError::ConversationNotFound(
|
||||||
|
conversation_id.to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn group_into_turns(messages: Vec<UnifiedMessage>) -> Vec<MessageTurn> {
|
||||||
|
let mut turns = Vec::new();
|
||||||
|
let mut i = 0;
|
||||||
|
|
||||||
|
while i < messages.len() {
|
||||||
|
let msg = &messages[i];
|
||||||
|
|
||||||
|
if matches!(msg.role, MessageRole::User) {
|
||||||
|
turns.push(MessageTurn {
|
||||||
|
id: format!("turn-{}", turns.len()),
|
||||||
|
role: TurnRole::User,
|
||||||
|
blocks: msg.content.clone(),
|
||||||
|
timestamp: msg.timestamp,
|
||||||
|
usage: None,
|
||||||
|
duration_ms: None,
|
||||||
|
model: None,
|
||||||
|
});
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches!(msg.role, MessageRole::System) {
|
||||||
|
turns.push(MessageTurn {
|
||||||
|
id: format!("turn-{}", turns.len()),
|
||||||
|
role: TurnRole::System,
|
||||||
|
blocks: msg.content.clone(),
|
||||||
|
timestamp: msg.timestamp,
|
||||||
|
usage: None,
|
||||||
|
duration_ms: None,
|
||||||
|
model: None,
|
||||||
|
});
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut blocks = msg.content.clone();
|
||||||
|
let mut usage = msg.usage.clone();
|
||||||
|
let mut duration_ms = msg.duration_ms;
|
||||||
|
let mut models: Vec<String> = msg.model.iter().cloned().collect();
|
||||||
|
let timestamp = msg.timestamp;
|
||||||
|
i += 1;
|
||||||
|
|
||||||
|
while i < messages.len()
|
||||||
|
&& (matches!(messages[i].role, MessageRole::Assistant)
|
||||||
|
|| matches!(messages[i].role, MessageRole::Tool))
|
||||||
|
{
|
||||||
|
blocks.extend(messages[i].content.clone());
|
||||||
|
if usage.is_none() {
|
||||||
|
usage = messages[i].usage.clone();
|
||||||
|
}
|
||||||
|
if duration_ms.is_none() {
|
||||||
|
duration_ms = messages[i].duration_ms;
|
||||||
|
}
|
||||||
|
if let Some(model) = &messages[i].model {
|
||||||
|
models.push(model.clone());
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let model = models.pop();
|
||||||
|
|
||||||
|
turns.push(MessageTurn {
|
||||||
|
id: format!("turn-{}", turns.len()),
|
||||||
|
role: TurnRole::Assistant,
|
||||||
|
blocks,
|
||||||
|
timestamp,
|
||||||
|
usage,
|
||||||
|
duration_ms,
|
||||||
|
model,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
turns
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::GeminiParser;
|
||||||
|
use super::resolve_gemini_base_dir_from;
|
||||||
|
use crate::parsers::AgentParser;
|
||||||
|
use std::env;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_gemini_session_detail_from_chat_json() {
|
||||||
|
let nanos = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.expect("system time ok")
|
||||||
|
.as_nanos();
|
||||||
|
let base: PathBuf = env::temp_dir().join(format!("codeg-gemini-test-{nanos}"));
|
||||||
|
let chats_dir = base.join("tmp").join("codeg").join("chats");
|
||||||
|
fs::create_dir_all(&chats_dir).expect("create chat dir");
|
||||||
|
fs::write(
|
||||||
|
base.join("tmp").join("codeg").join(".project_root"),
|
||||||
|
"/Users/test/workspace/demo",
|
||||||
|
)
|
||||||
|
.expect("write project root");
|
||||||
|
|
||||||
|
let file_path = chats_dir.join("session-2026-03-02T04-30-32c7d221.json");
|
||||||
|
let content = r#"{
|
||||||
|
"sessionId": "32c7d221-0553-46c8-ba50-e664719cae7f",
|
||||||
|
"projectHash": "abc",
|
||||||
|
"startTime": "2026-03-02T04:30:20.796Z",
|
||||||
|
"lastUpdated": "2026-03-02T04:33:13.631Z",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"id": "u1",
|
||||||
|
"timestamp": "2026-03-02T04:30:20.796Z",
|
||||||
|
"type": "user",
|
||||||
|
"content": [{"text": "你会做什么"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "a1",
|
||||||
|
"timestamp": "2026-03-02T04:33:13.631Z",
|
||||||
|
"type": "gemini",
|
||||||
|
"content": "我是一个助手",
|
||||||
|
"toolCalls": [
|
||||||
|
{
|
||||||
|
"id": "cli_help-1",
|
||||||
|
"name": "cli_help",
|
||||||
|
"args": {"question": "你会做什么"},
|
||||||
|
"resultDisplay": "ok",
|
||||||
|
"status": "success"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tokens": {"input": 12, "output": 34, "cached": 5},
|
||||||
|
"model": "gemini-3.1-pro-preview"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}"#;
|
||||||
|
fs::write(&file_path, content).expect("write chat file");
|
||||||
|
|
||||||
|
let parser = GeminiParser::with_base_dir(base.clone());
|
||||||
|
let summaries = parser.list_conversations().expect("list conversations");
|
||||||
|
assert_eq!(summaries.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
summaries[0].id,
|
||||||
|
"32c7d221-0553-46c8-ba50-e664719cae7f".to_string()
|
||||||
|
);
|
||||||
|
|
||||||
|
let detail = parser
|
||||||
|
.get_conversation("32c7d221-0553-46c8-ba50-e664719cae7f")
|
||||||
|
.expect("get conversation");
|
||||||
|
assert_eq!(detail.turns.len(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
detail.summary.folder_path.as_deref(),
|
||||||
|
Some("/Users/test/workspace/demo")
|
||||||
|
);
|
||||||
|
assert!(detail.session_stats.is_some());
|
||||||
|
let stats = detail.session_stats.expect("session stats");
|
||||||
|
assert_eq!(stats.context_window_used_tokens, Some(51));
|
||||||
|
assert_eq!(stats.context_window_max_tokens, Some(1_000_000));
|
||||||
|
let percent = stats
|
||||||
|
.context_window_usage_percent
|
||||||
|
.expect("context window percent");
|
||||||
|
assert!((percent - 0.0051).abs() < 1e-9);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(base);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gemini_cli_home_env_overrides_user_home() {
|
||||||
|
let resolved = resolve_gemini_base_dir_from(
|
||||||
|
Some(std::ffi::OsString::from("/tmp/gemini-home")),
|
||||||
|
Some(PathBuf::from("/Users/default")),
|
||||||
|
);
|
||||||
|
assert_eq!(resolved, PathBuf::from("/tmp/gemini-home/.gemini"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gemini_defaults_to_home_dot_gemini() {
|
||||||
|
let resolved = resolve_gemini_base_dir_from(
|
||||||
|
None,
|
||||||
|
Some(PathBuf::from("/Users/default")),
|
||||||
|
);
|
||||||
|
assert_eq!(resolved, PathBuf::from("/Users/default/.gemini"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
pub mod claude;
|
||||||
|
pub mod codex;
|
||||||
|
pub mod gemini;
|
||||||
|
pub mod opencode;
|
||||||
|
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
use regex::Regex;
|
||||||
|
|
||||||
|
use crate::models::{
|
||||||
|
ConversationDetail, ConversationSummary, MessageTurn, SessionStats, TurnUsage,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum ParseError {
|
||||||
|
#[error("IO error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
#[error("JSON parse error: {0}")]
|
||||||
|
Json(#[from] serde_json::Error),
|
||||||
|
#[error("Database error: {0}")]
|
||||||
|
Db(#[from] sea_orm::DbErr),
|
||||||
|
#[error("Conversation not found: {0}")]
|
||||||
|
ConversationNotFound(String),
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[error("Invalid data: {0}")]
|
||||||
|
InvalidData(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait AgentParser {
|
||||||
|
fn list_conversations(&self) -> Result<Vec<ConversationSummary>, ParseError>;
|
||||||
|
fn get_conversation(&self, conversation_id: &str) -> Result<ConversationDetail, ParseError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Truncate a string to `max_len` characters, appending "..." if truncated.
|
||||||
|
pub fn truncate_str(s: &str, max_len: usize) -> String {
|
||||||
|
if s.chars().count() <= max_len {
|
||||||
|
s.to_string()
|
||||||
|
} else {
|
||||||
|
let truncated: String = s.chars().take(max_len).collect();
|
||||||
|
format!("{}...", truncated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aggregate turn-level usage and duration into a single `SessionStats`.
|
||||||
|
pub fn compute_session_stats(turns: &[MessageTurn]) -> Option<SessionStats> {
|
||||||
|
let mut total_in = 0u64;
|
||||||
|
let mut total_out = 0u64;
|
||||||
|
let mut total_cache_create = 0u64;
|
||||||
|
let mut total_cache_read = 0u64;
|
||||||
|
let mut total_duration = 0u64;
|
||||||
|
let mut has_data = false;
|
||||||
|
|
||||||
|
for turn in turns {
|
||||||
|
if let Some(ref u) = turn.usage {
|
||||||
|
total_in += u.input_tokens;
|
||||||
|
total_out += u.output_tokens;
|
||||||
|
total_cache_create += u.cache_creation_input_tokens;
|
||||||
|
total_cache_read += u.cache_read_input_tokens;
|
||||||
|
has_data = true;
|
||||||
|
}
|
||||||
|
if let Some(d) = turn.duration_ms {
|
||||||
|
total_duration += d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !has_data {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(SessionStats {
|
||||||
|
total_usage: Some(TurnUsage {
|
||||||
|
input_tokens: total_in,
|
||||||
|
output_tokens: total_out,
|
||||||
|
cache_creation_input_tokens: total_cache_create,
|
||||||
|
cache_read_input_tokens: total_cache_read,
|
||||||
|
}),
|
||||||
|
total_tokens: Some(total_in + total_out + total_cache_create + total_cache_read),
|
||||||
|
total_duration_ms: total_duration,
|
||||||
|
context_window_used_tokens: None,
|
||||||
|
context_window_max_tokens: None,
|
||||||
|
context_window_usage_percent: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn model_capacity_suffix_regex() -> &'static Regex {
|
||||||
|
static RE: OnceLock<Regex> = OnceLock::new();
|
||||||
|
RE.get_or_init(|| {
|
||||||
|
Regex::new(r"(?i)\[\s*([0-9]+(?:\.[0-9]+)?)\s*([km])\s*\]\s*$")
|
||||||
|
.expect("valid model capacity regex")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_model_capacity_suffix(model: &str) -> Option<u64> {
|
||||||
|
let captures = model_capacity_suffix_regex().captures(model.trim())?;
|
||||||
|
let value = captures.get(1)?.as_str().parse::<f64>().ok()?;
|
||||||
|
if !value.is_finite() || value <= 0.0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let unit = captures
|
||||||
|
.get(2)
|
||||||
|
.map(|m| m.as_str().to_ascii_lowercase())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let multiplier = match unit.as_str() {
|
||||||
|
"m" => 1_000_000.0,
|
||||||
|
"k" => 1_000.0,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
|
||||||
|
Some((value * multiplier) as u64)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn infer_context_window_max_tokens(model: Option<&str>) -> Option<u64> {
|
||||||
|
let raw = model?.trim();
|
||||||
|
if raw.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(suffixed_limit) = parse_model_capacity_suffix(raw) {
|
||||||
|
return Some(suffixed_limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized = raw
|
||||||
|
.rsplit('/')
|
||||||
|
.next()
|
||||||
|
.unwrap_or(raw)
|
||||||
|
.split(':')
|
||||||
|
.next()
|
||||||
|
.unwrap_or(raw)
|
||||||
|
.trim()
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
|
||||||
|
if normalized.starts_with("claude") {
|
||||||
|
return Some(200_000);
|
||||||
|
}
|
||||||
|
if normalized.starts_with("gemini") {
|
||||||
|
return Some(1_000_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
match normalized.as_str() {
|
||||||
|
"gpt-5.2-codex" | "gpt-5.1-codex-max" | "gpt-5.1-codex-mini" | "gpt-5.2" => Some(258_000),
|
||||||
|
"gpt-5.1" | "gpt-5.1-codex" | "gpt-4o" | "gpt-4o-mini" | "gpt-4-turbo" | "o1-mini"
|
||||||
|
| "o1-preview" => Some(128_000),
|
||||||
|
"gpt-4" => Some(8_192),
|
||||||
|
"o3" | "o3-mini" | "o1" => Some(200_000),
|
||||||
|
_ => {
|
||||||
|
if normalized.starts_with("gpt-5") {
|
||||||
|
Some(258_000)
|
||||||
|
} else if normalized.starts_with("gpt-4o")
|
||||||
|
|| normalized.starts_with("gpt-4.1")
|
||||||
|
|| normalized.starts_with("gpt-4-turbo")
|
||||||
|
{
|
||||||
|
Some(128_000)
|
||||||
|
} else if normalized.starts_with("o3") || normalized == "o1" {
|
||||||
|
Some(200_000)
|
||||||
|
} else if normalized.starts_with("o1-mini") || normalized.starts_with("o1-preview") {
|
||||||
|
Some(128_000)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn latest_turn_total_usage_tokens(turns: &[MessageTurn]) -> Option<u64> {
|
||||||
|
turns.iter().rev().find_map(|turn| {
|
||||||
|
turn.usage.as_ref().map(|usage| {
|
||||||
|
usage
|
||||||
|
.input_tokens
|
||||||
|
.saturating_add(usage.output_tokens)
|
||||||
|
.saturating_add(usage.cache_creation_input_tokens)
|
||||||
|
.saturating_add(usage.cache_read_input_tokens)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn merge_context_window_stats(
|
||||||
|
stats: Option<SessionStats>,
|
||||||
|
used_tokens: Option<u64>,
|
||||||
|
max_tokens: Option<u64>,
|
||||||
|
) -> Option<SessionStats> {
|
||||||
|
if used_tokens.is_none() && max_tokens.is_none() {
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
let usage_percent = match (used_tokens, max_tokens) {
|
||||||
|
(Some(used), Some(max)) if max > 0 => Some((used as f64 / max as f64) * 100.0),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
match stats {
|
||||||
|
Some(mut s) => {
|
||||||
|
s.context_window_used_tokens = used_tokens;
|
||||||
|
s.context_window_max_tokens = max_tokens;
|
||||||
|
s.context_window_usage_percent = usage_percent;
|
||||||
|
Some(s)
|
||||||
|
}
|
||||||
|
None => Some(SessionStats {
|
||||||
|
total_usage: None,
|
||||||
|
total_tokens: None,
|
||||||
|
total_duration_ms: 0,
|
||||||
|
context_window_used_tokens: used_tokens,
|
||||||
|
context_window_max_tokens: max_tokens,
|
||||||
|
context_window_usage_percent: usage_percent,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the last path component as the folder name.
|
||||||
|
pub fn folder_name_from_path(path: &str) -> String {
|
||||||
|
path.rsplit(['/', '\\']).next().unwrap_or(path).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalize a filesystem path string for tolerant cross-platform comparison.
|
||||||
|
/// This intentionally does not hit the filesystem (no canonicalize), and only
|
||||||
|
/// normalizes separators/casing differences that commonly break exact matching.
|
||||||
|
pub fn normalize_path_for_matching(path: &str) -> String {
|
||||||
|
let mut normalized = path.trim().replace('\\', "/");
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
if let Some(stripped) = normalized.strip_prefix("//?/") {
|
||||||
|
normalized = stripped.to_string();
|
||||||
|
}
|
||||||
|
normalized = normalized.to_ascii_lowercase();
|
||||||
|
}
|
||||||
|
|
||||||
|
while normalized.ends_with('/') {
|
||||||
|
if normalized == "/" {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Keep Windows drive root such as "c:/" intact.
|
||||||
|
if normalized.len() == 3
|
||||||
|
&& normalized.as_bytes().get(1) == Some(&b':')
|
||||||
|
&& normalized.as_bytes().get(2) == Some(&b'/')
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
normalized.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn path_eq_for_matching(left: &str, right: &str) -> bool {
|
||||||
|
normalize_path_for_matching(left) == normalize_path_for_matching(right)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use chrono::Utc;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
infer_context_window_max_tokens, latest_turn_total_usage_tokens, merge_context_window_stats,
|
||||||
|
path_eq_for_matching,
|
||||||
|
};
|
||||||
|
use crate::models::{MessageTurn, SessionStats, TurnRole, TurnUsage};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn infers_model_context_limits() {
|
||||||
|
assert_eq!(
|
||||||
|
infer_context_window_max_tokens(Some("claude-sonnet-4-6")),
|
||||||
|
Some(200_000)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
infer_context_window_max_tokens(Some("gemini-2.5-pro")),
|
||||||
|
Some(1_000_000)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
infer_context_window_max_tokens(Some("claude-sonnet-4-6 [1.5M]")),
|
||||||
|
Some(1_500_000)
|
||||||
|
);
|
||||||
|
assert_eq!(infer_context_window_max_tokens(Some("unknown-model")), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn picks_latest_turn_usage_total_tokens() {
|
||||||
|
let timestamp = Utc::now();
|
||||||
|
let turns = vec![
|
||||||
|
MessageTurn {
|
||||||
|
id: "turn-0".to_string(),
|
||||||
|
role: TurnRole::Assistant,
|
||||||
|
blocks: vec![],
|
||||||
|
timestamp,
|
||||||
|
usage: Some(TurnUsage {
|
||||||
|
input_tokens: 10,
|
||||||
|
output_tokens: 20,
|
||||||
|
cache_creation_input_tokens: 30,
|
||||||
|
cache_read_input_tokens: 40,
|
||||||
|
}),
|
||||||
|
duration_ms: None,
|
||||||
|
model: None,
|
||||||
|
},
|
||||||
|
MessageTurn {
|
||||||
|
id: "turn-1".to_string(),
|
||||||
|
role: TurnRole::Assistant,
|
||||||
|
blocks: vec![],
|
||||||
|
timestamp,
|
||||||
|
usage: Some(TurnUsage {
|
||||||
|
input_tokens: 11,
|
||||||
|
output_tokens: 21,
|
||||||
|
cache_creation_input_tokens: 31,
|
||||||
|
cache_read_input_tokens: 41,
|
||||||
|
}),
|
||||||
|
duration_ms: None,
|
||||||
|
model: None,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
assert_eq!(latest_turn_total_usage_tokens(&turns), Some(104));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merges_context_window_stats() {
|
||||||
|
let merged = merge_context_window_stats(None, Some(1500), Some(3000))
|
||||||
|
.expect("context stats should exist");
|
||||||
|
assert_eq!(merged.context_window_used_tokens, Some(1500));
|
||||||
|
assert_eq!(merged.context_window_max_tokens, Some(3000));
|
||||||
|
assert!(merged.total_usage.is_none());
|
||||||
|
let percent = merged
|
||||||
|
.context_window_usage_percent
|
||||||
|
.expect("usage percent should exist");
|
||||||
|
assert!((percent - 50.0).abs() < f64::EPSILON);
|
||||||
|
|
||||||
|
let existing = Some(SessionStats {
|
||||||
|
total_usage: Some(TurnUsage {
|
||||||
|
input_tokens: 1,
|
||||||
|
output_tokens: 2,
|
||||||
|
cache_creation_input_tokens: 3,
|
||||||
|
cache_read_input_tokens: 4,
|
||||||
|
}),
|
||||||
|
total_tokens: Some(10),
|
||||||
|
total_duration_ms: 100,
|
||||||
|
context_window_used_tokens: None,
|
||||||
|
context_window_max_tokens: None,
|
||||||
|
context_window_usage_percent: None,
|
||||||
|
});
|
||||||
|
let merged_existing =
|
||||||
|
merge_context_window_stats(existing, Some(200), Some(1000)).expect("merged");
|
||||||
|
assert_eq!(merged_existing.total_tokens, Some(10));
|
||||||
|
assert_eq!(merged_existing.context_window_used_tokens, Some(200));
|
||||||
|
assert_eq!(merged_existing.context_window_max_tokens, Some(1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn path_matching_handles_separator_differences() {
|
||||||
|
assert!(path_eq_for_matching(
|
||||||
|
"/Users/demo/workspace/codeg",
|
||||||
|
"/Users/demo/workspace/codeg/"
|
||||||
|
));
|
||||||
|
assert!(path_eq_for_matching(
|
||||||
|
"C:\\Users\\demo\\workspace\\codeg",
|
||||||
|
"C:/Users/demo/workspace/codeg"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,646 @@
|
|||||||
|
use std::future::Future;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use chrono::{DateTime, TimeZone, Utc};
|
||||||
|
use sea_orm::{
|
||||||
|
ConnectOptions, ConnectionTrait, Database, DatabaseConnection, DbBackend, QueryResult,
|
||||||
|
Statement,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::models::*;
|
||||||
|
use crate::parsers::{folder_name_from_path, AgentParser, ParseError};
|
||||||
|
|
||||||
|
pub struct OpenCodeParser {
|
||||||
|
base_dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpenCodeParser {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let base_dir = resolve_opencode_base_dir();
|
||||||
|
Self { base_dir }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sqlite_db_path(&self) -> PathBuf {
|
||||||
|
self.base_dir.join("opencode.db")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn block_on<F, T>(&self, fut: F) -> Result<T, ParseError>
|
||||||
|
where
|
||||||
|
F: Future<Output = Result<T, ParseError>>,
|
||||||
|
{
|
||||||
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.map_err(|e| ParseError::InvalidData(format!("failed to build runtime: {e}")))?;
|
||||||
|
runtime.block_on(fut)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn open_sqlite_connection(&self) -> Result<DatabaseConnection, ParseError> {
|
||||||
|
let db_path = self.sqlite_db_path();
|
||||||
|
let db_url = format!(
|
||||||
|
"sqlite:{}?mode=ro",
|
||||||
|
urlencoding::encode(&db_path.to_string_lossy())
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut opts = ConnectOptions::new(db_url);
|
||||||
|
opts.max_connections(1)
|
||||||
|
.min_connections(1)
|
||||||
|
.connect_timeout(Duration::from_secs(5))
|
||||||
|
.idle_timeout(Duration::from_secs(30))
|
||||||
|
.sqlx_logging(false);
|
||||||
|
|
||||||
|
let conn = Database::connect(opts).await?;
|
||||||
|
conn.execute(Statement::from_string(
|
||||||
|
DbBackend::Sqlite,
|
||||||
|
"PRAGMA busy_timeout=3000;".to_owned(),
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_sqlite_summary_row(row: &QueryResult) -> Result<ConversationSummary, ParseError> {
|
||||||
|
let id: String = row.try_get("", "id")?;
|
||||||
|
let directory: Option<String> = row.try_get("", "directory")?;
|
||||||
|
let title: Option<String> = row.try_get("", "title")?;
|
||||||
|
let created_ms: i64 = row.try_get("", "created_ms")?;
|
||||||
|
let updated_ms: i64 = row.try_get("", "updated_ms")?;
|
||||||
|
let message_count_i64: i64 = row.try_get("", "message_count")?;
|
||||||
|
let model: Option<String> = row.try_get("", "model")?;
|
||||||
|
|
||||||
|
let folder_path = normalize_optional_string(directory);
|
||||||
|
let folder_name = folder_path.as_ref().map(|p| folder_name_from_path(p));
|
||||||
|
|
||||||
|
let message_count = if message_count_i64 <= 0 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
u32::try_from(message_count_i64).unwrap_or(u32::MAX)
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(ConversationSummary {
|
||||||
|
id,
|
||||||
|
agent_type: AgentType::OpenCode,
|
||||||
|
folder_path,
|
||||||
|
folder_name,
|
||||||
|
title: normalize_optional_string(title),
|
||||||
|
started_at: millis_to_datetime(created_ms),
|
||||||
|
ended_at: (updated_ms > 0).then(|| millis_to_datetime(updated_ms)),
|
||||||
|
message_count,
|
||||||
|
model: normalize_optional_string(model),
|
||||||
|
git_branch: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_conversations_from_sqlite(&self) -> Result<Vec<ConversationSummary>, ParseError> {
|
||||||
|
let conn = self.open_sqlite_connection().await?;
|
||||||
|
|
||||||
|
let rows = conn
|
||||||
|
.query_all(Statement::from_string(
|
||||||
|
DbBackend::Sqlite,
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
s.id AS id,
|
||||||
|
s.directory AS directory,
|
||||||
|
s.title AS title,
|
||||||
|
s.time_created AS created_ms,
|
||||||
|
s.time_updated AS updated_ms,
|
||||||
|
COALESCE((
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM message m
|
||||||
|
WHERE m.session_id = s.id
|
||||||
|
), 0) AS message_count,
|
||||||
|
(
|
||||||
|
SELECT json_extract(m2.data, '$.modelID')
|
||||||
|
FROM message m2
|
||||||
|
WHERE m2.session_id = s.id
|
||||||
|
AND json_extract(m2.data, '$.role') = 'assistant'
|
||||||
|
ORDER BY m2.time_created DESC
|
||||||
|
LIMIT 1
|
||||||
|
) AS model
|
||||||
|
FROM session s
|
||||||
|
ORDER BY s.time_created DESC
|
||||||
|
"#
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut conversations = Vec::with_capacity(rows.len());
|
||||||
|
for row in rows {
|
||||||
|
conversations.push(Self::parse_sqlite_summary_row(&row)?);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(conversations)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sqlite_summary_by_id(
|
||||||
|
&self,
|
||||||
|
conn: &DatabaseConnection,
|
||||||
|
conversation_id: &str,
|
||||||
|
) -> Result<Option<ConversationSummary>, ParseError> {
|
||||||
|
let row = conn
|
||||||
|
.query_one(Statement::from_sql_and_values(
|
||||||
|
DbBackend::Sqlite,
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
s.id AS id,
|
||||||
|
s.directory AS directory,
|
||||||
|
s.title AS title,
|
||||||
|
s.time_created AS created_ms,
|
||||||
|
s.time_updated AS updated_ms,
|
||||||
|
COALESCE((
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM message m
|
||||||
|
WHERE m.session_id = s.id
|
||||||
|
), 0) AS message_count,
|
||||||
|
(
|
||||||
|
SELECT json_extract(m2.data, '$.modelID')
|
||||||
|
FROM message m2
|
||||||
|
WHERE m2.session_id = s.id
|
||||||
|
AND json_extract(m2.data, '$.role') = 'assistant'
|
||||||
|
ORDER BY m2.time_created DESC
|
||||||
|
LIMIT 1
|
||||||
|
) AS model
|
||||||
|
FROM session s
|
||||||
|
WHERE s.id = ?
|
||||||
|
LIMIT 1
|
||||||
|
"#,
|
||||||
|
[conversation_id.into()],
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
row.map(|r| Self::parse_sqlite_summary_row(&r)).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_conversation_from_sqlite(
|
||||||
|
&self,
|
||||||
|
conversation_id: &str,
|
||||||
|
) -> Result<ConversationDetail, ParseError> {
|
||||||
|
let conn = self.open_sqlite_connection().await?;
|
||||||
|
let summary = self
|
||||||
|
.sqlite_summary_by_id(&conn, conversation_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ParseError::ConversationNotFound(conversation_id.to_string()))?;
|
||||||
|
|
||||||
|
let messages = self.load_sqlite_messages(&conn, conversation_id).await?;
|
||||||
|
let turns = group_into_turns(messages);
|
||||||
|
let context_window_used_tokens = super::latest_turn_total_usage_tokens(&turns);
|
||||||
|
let context_window_max_tokens =
|
||||||
|
super::infer_context_window_max_tokens(summary.model.as_deref());
|
||||||
|
let session_stats = super::merge_context_window_stats(
|
||||||
|
super::compute_session_stats(&turns),
|
||||||
|
context_window_used_tokens,
|
||||||
|
context_window_max_tokens,
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(ConversationDetail {
|
||||||
|
summary,
|
||||||
|
turns,
|
||||||
|
session_stats,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_sqlite_messages(
|
||||||
|
&self,
|
||||||
|
conn: &DatabaseConnection,
|
||||||
|
conversation_id: &str,
|
||||||
|
) -> Result<Vec<UnifiedMessage>, ParseError> {
|
||||||
|
let rows = conn
|
||||||
|
.query_all(Statement::from_sql_and_values(
|
||||||
|
DbBackend::Sqlite,
|
||||||
|
r#"
|
||||||
|
SELECT id, time_created, data
|
||||||
|
FROM message
|
||||||
|
WHERE session_id = ?
|
||||||
|
ORDER BY time_created ASC, id ASC
|
||||||
|
"#,
|
||||||
|
[conversation_id.into()],
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut messages = Vec::with_capacity(rows.len());
|
||||||
|
|
||||||
|
for row in rows {
|
||||||
|
let msg_id: String = row.try_get("", "id")?;
|
||||||
|
let row_time_created: i64 = row.try_get("", "time_created")?;
|
||||||
|
let data_raw: String = row.try_get("", "data")?;
|
||||||
|
|
||||||
|
let value: serde_json::Value = match serde_json::from_str(&data_raw) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let role = match value.get("role").and_then(|r| r.as_str()) {
|
||||||
|
Some("user") => MessageRole::User,
|
||||||
|
Some("assistant") => MessageRole::Assistant,
|
||||||
|
Some("system") => MessageRole::System,
|
||||||
|
Some("tool") => MessageRole::Tool,
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let created_ms = value
|
||||||
|
.get("time")
|
||||||
|
.and_then(|t| t.get("created"))
|
||||||
|
.and_then(|c| c.as_i64())
|
||||||
|
.unwrap_or(row_time_created);
|
||||||
|
let timestamp = millis_to_datetime(created_ms);
|
||||||
|
|
||||||
|
let is_assistant = matches!(role, MessageRole::Assistant);
|
||||||
|
let msg_model = if is_assistant {
|
||||||
|
value
|
||||||
|
.get("modelID")
|
||||||
|
.and_then(|m| m.as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let (content_blocks, usage_from_step_finish) =
|
||||||
|
self.load_sqlite_parts(conn, &msg_id).await?;
|
||||||
|
|
||||||
|
let usage = if is_assistant {
|
||||||
|
extract_opencode_usage(&value).or(usage_from_step_finish)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let duration_ms = if is_assistant {
|
||||||
|
let completed_ms = value
|
||||||
|
.get("time")
|
||||||
|
.and_then(|t| t.get("completed"))
|
||||||
|
.and_then(|c| c.as_i64());
|
||||||
|
match completed_ms {
|
||||||
|
Some(done) if done > created_ms => Some((done - created_ms) as u64),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
messages.push(UnifiedMessage {
|
||||||
|
id: msg_id,
|
||||||
|
role,
|
||||||
|
content: content_blocks,
|
||||||
|
timestamp,
|
||||||
|
usage,
|
||||||
|
duration_ms,
|
||||||
|
model: msg_model,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_sqlite_parts(
|
||||||
|
&self,
|
||||||
|
conn: &DatabaseConnection,
|
||||||
|
message_id: &str,
|
||||||
|
) -> Result<(Vec<ContentBlock>, Option<TurnUsage>), ParseError> {
|
||||||
|
let rows = conn
|
||||||
|
.query_all(Statement::from_sql_and_values(
|
||||||
|
DbBackend::Sqlite,
|
||||||
|
r#"
|
||||||
|
SELECT data
|
||||||
|
FROM part
|
||||||
|
WHERE message_id = ?
|
||||||
|
ORDER BY time_created ASC, id ASC
|
||||||
|
"#,
|
||||||
|
[message_id.into()],
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut blocks = Vec::new();
|
||||||
|
let mut usage_from_step_finish: Option<TurnUsage> = None;
|
||||||
|
|
||||||
|
for row in rows {
|
||||||
|
let data_raw: String = row.try_get("", "data")?;
|
||||||
|
let value: serde_json::Value = match serde_json::from_str(&data_raw) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let part_type = value.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||||
|
|
||||||
|
match part_type {
|
||||||
|
"text" => {
|
||||||
|
if let Some(text) = value
|
||||||
|
.get("text")
|
||||||
|
.and_then(|t| t.as_str())
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
{
|
||||||
|
blocks.push(ContentBlock::Text {
|
||||||
|
text: text.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"reasoning" => {
|
||||||
|
if let Some(text) = value
|
||||||
|
.get("text")
|
||||||
|
.and_then(|t| t.as_str())
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
{
|
||||||
|
blocks.push(ContentBlock::Thinking {
|
||||||
|
text: text.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"tool" => {
|
||||||
|
let tool_name = value
|
||||||
|
.get("tool")
|
||||||
|
.and_then(|t| t.as_str())
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let call_id = value
|
||||||
|
.get("callID")
|
||||||
|
.and_then(|c| c.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
let status = value
|
||||||
|
.get("state")
|
||||||
|
.and_then(|s| s.get("status"))
|
||||||
|
.and_then(|s| s.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
let input_preview = value
|
||||||
|
.get("state")
|
||||||
|
.and_then(|s| s.get("input"))
|
||||||
|
.and_then(|v| value_to_preview(Some(v)));
|
||||||
|
|
||||||
|
blocks.push(ContentBlock::ToolUse {
|
||||||
|
tool_use_id: call_id.clone(),
|
||||||
|
tool_name,
|
||||||
|
input_preview,
|
||||||
|
});
|
||||||
|
|
||||||
|
let output_preview = value
|
||||||
|
.get("state")
|
||||||
|
.and_then(|s| s.get("output"))
|
||||||
|
.and_then(|v| value_to_preview(Some(v)));
|
||||||
|
|
||||||
|
let has_error_field = value.get("state").and_then(|s| s.get("error")).is_some();
|
||||||
|
|
||||||
|
blocks.push(ContentBlock::ToolResult {
|
||||||
|
tool_use_id: call_id,
|
||||||
|
output_preview,
|
||||||
|
is_error: is_error_status(status) || has_error_field,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
"file" => {
|
||||||
|
if let Some(file_ref) = extract_file_reference(&value) {
|
||||||
|
blocks.push(ContentBlock::Text {
|
||||||
|
text: format!("@{}", file_ref),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"patch" => {
|
||||||
|
let files = value
|
||||||
|
.get("files")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.map(|arr| {
|
||||||
|
arr.iter()
|
||||||
|
.filter_map(|item| item.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
if !files.is_empty() {
|
||||||
|
blocks.push(ContentBlock::Text {
|
||||||
|
text: format!("Applied patch: {}", files.join(", ")),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"step-finish" => {
|
||||||
|
if usage_from_step_finish.is_none() {
|
||||||
|
usage_from_step_finish = value
|
||||||
|
.get("tokens")
|
||||||
|
.and_then(extract_opencode_usage_from_tokens);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((blocks, usage_from_step_finish))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AgentParser for OpenCodeParser {
|
||||||
|
fn list_conversations(&self) -> Result<Vec<ConversationSummary>, ParseError> {
|
||||||
|
if !self.sqlite_db_path().exists() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
self.block_on(self.list_conversations_from_sqlite())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_conversation(&self, conversation_id: &str) -> Result<ConversationDetail, ParseError> {
|
||||||
|
if !self.sqlite_db_path().exists() {
|
||||||
|
return Err(ParseError::ConversationNotFound(
|
||||||
|
conversation_id.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.block_on(self.get_conversation_from_sqlite(conversation_id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_opencode_base_dir() -> PathBuf {
|
||||||
|
resolve_xdg_data_home(std::env::var_os("XDG_DATA_HOME"), dirs::home_dir())
|
||||||
|
.map(|xdg_data_home| xdg_data_home.join("opencode"))
|
||||||
|
.unwrap_or_else(|| PathBuf::from("opencode"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_xdg_data_home(
|
||||||
|
xdg_data_home_env: Option<std::ffi::OsString>,
|
||||||
|
home_dir: Option<PathBuf>,
|
||||||
|
) -> Option<PathBuf> {
|
||||||
|
xdg_data_home_env
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.or_else(|| home_dir.map(|home| home.join(".local").join("share")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
||||||
|
value.and_then(|s| {
|
||||||
|
let trimmed = s.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(trimmed.to_string())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value_to_preview(value: Option<&serde_json::Value>) -> Option<String> {
|
||||||
|
let v = value?;
|
||||||
|
if v.is_null() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(s) = v.as_str() {
|
||||||
|
let trimmed = s.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(trimmed.to_string())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
serde_json::to_string(v).ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_file_reference(value: &serde_json::Value) -> Option<String> {
|
||||||
|
value
|
||||||
|
.get("source")
|
||||||
|
.and_then(|s| s.get("path"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.or_else(|| value.get("filename").and_then(|v| v.as_str()))
|
||||||
|
.map(|s| s.trim())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_error_status(status: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
status.to_ascii_lowercase().as_str(),
|
||||||
|
"error" | "failed" | "failure" | "cancelled" | "canceled"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_opencode_usage(value: &serde_json::Value) -> Option<TurnUsage> {
|
||||||
|
value
|
||||||
|
.get("tokens")
|
||||||
|
.and_then(extract_opencode_usage_from_tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_opencode_usage_from_tokens(tokens: &serde_json::Value) -> Option<TurnUsage> {
|
||||||
|
let input = tokens.get("input").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||||
|
let output = tokens.get("output").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||||
|
let cache = tokens.get("cache");
|
||||||
|
let cache_write = cache
|
||||||
|
.and_then(|c| c.get("write"))
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let cache_read = cache
|
||||||
|
.and_then(|c| c.get("read"))
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
if input == 0 && output == 0 && cache_write == 0 && cache_read == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(TurnUsage {
|
||||||
|
input_tokens: input,
|
||||||
|
output_tokens: output,
|
||||||
|
cache_creation_input_tokens: cache_write,
|
||||||
|
cache_read_input_tokens: cache_read,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn millis_to_datetime(ms: i64) -> DateTime<Utc> {
|
||||||
|
let secs = ms / 1000;
|
||||||
|
let nsecs = ((ms.rem_euclid(1000)) * 1_000_000) as u32;
|
||||||
|
Utc.timestamp_opt(secs, nsecs)
|
||||||
|
.single()
|
||||||
|
.unwrap_or_else(Utc::now)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Group flat messages into conversation turns (same strategy as Codex).
|
||||||
|
fn group_into_turns(messages: Vec<UnifiedMessage>) -> Vec<MessageTurn> {
|
||||||
|
let mut turns = Vec::new();
|
||||||
|
let mut i = 0;
|
||||||
|
|
||||||
|
while i < messages.len() {
|
||||||
|
let msg = &messages[i];
|
||||||
|
|
||||||
|
if matches!(msg.role, MessageRole::User) {
|
||||||
|
turns.push(MessageTurn {
|
||||||
|
id: format!("turn-{}", turns.len()),
|
||||||
|
role: TurnRole::User,
|
||||||
|
blocks: msg.content.clone(),
|
||||||
|
timestamp: msg.timestamp,
|
||||||
|
usage: None,
|
||||||
|
duration_ms: None,
|
||||||
|
model: None,
|
||||||
|
});
|
||||||
|
i += 1;
|
||||||
|
} else if matches!(msg.role, MessageRole::System) {
|
||||||
|
turns.push(MessageTurn {
|
||||||
|
id: format!("turn-{}", turns.len()),
|
||||||
|
role: TurnRole::System,
|
||||||
|
blocks: msg.content.clone(),
|
||||||
|
timestamp: msg.timestamp,
|
||||||
|
usage: None,
|
||||||
|
duration_ms: None,
|
||||||
|
model: None,
|
||||||
|
});
|
||||||
|
i += 1;
|
||||||
|
} else {
|
||||||
|
let mut blocks: Vec<ContentBlock> = msg.content.clone();
|
||||||
|
let mut usage = msg.usage.clone();
|
||||||
|
let mut duration_ms = msg.duration_ms;
|
||||||
|
let mut turn_model = msg.model.clone();
|
||||||
|
let timestamp = msg.timestamp;
|
||||||
|
i += 1;
|
||||||
|
|
||||||
|
while i < messages.len()
|
||||||
|
&& (matches!(messages[i].role, MessageRole::Assistant)
|
||||||
|
|| matches!(messages[i].role, MessageRole::Tool))
|
||||||
|
{
|
||||||
|
blocks.extend(messages[i].content.clone());
|
||||||
|
if usage.is_none() {
|
||||||
|
usage = messages[i].usage.clone();
|
||||||
|
}
|
||||||
|
if duration_ms.is_none() {
|
||||||
|
duration_ms = messages[i].duration_ms;
|
||||||
|
}
|
||||||
|
if turn_model.is_none() {
|
||||||
|
turn_model = messages[i].model.clone();
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
turns.push(MessageTurn {
|
||||||
|
id: format!("turn-{}", turns.len()),
|
||||||
|
role: TurnRole::Assistant,
|
||||||
|
blocks,
|
||||||
|
timestamp,
|
||||||
|
usage,
|
||||||
|
duration_ms,
|
||||||
|
model: turn_model,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
turns
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::resolve_xdg_data_home;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn xdg_data_home_env_overrides_home_fallback() {
|
||||||
|
let resolved = resolve_xdg_data_home(
|
||||||
|
Some(std::ffi::OsString::from("/tmp/xdg-data")),
|
||||||
|
Some(PathBuf::from("/Users/default")),
|
||||||
|
);
|
||||||
|
assert_eq!(resolved, Some(PathBuf::from("/tmp/xdg-data")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn xdg_data_home_falls_back_to_home_local_share() {
|
||||||
|
let resolved = resolve_xdg_data_home(
|
||||||
|
None,
|
||||||
|
Some(PathBuf::from("/Users/default")),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolved,
|
||||||
|
Some(PathBuf::from("/Users/default/.local/share"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
use std::ffi::{OsStr, OsString};
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||||
|
|
||||||
|
pub fn configure_tokio_command(
|
||||||
|
command: &mut tokio::process::Command,
|
||||||
|
) -> &mut tokio::process::Command {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
command.creation_flags(CREATE_NO_WINDOW);
|
||||||
|
}
|
||||||
|
|
||||||
|
command
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn maybe_windows_cmd_shim(program: &OsStr) -> Option<OsString> {
|
||||||
|
let path = Path::new(program);
|
||||||
|
if path.components().count() != 1 || path.extension().is_some() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw = program.to_string_lossy();
|
||||||
|
let normalized = raw.to_ascii_lowercase();
|
||||||
|
let needs_cmd_shim = matches!(
|
||||||
|
normalized.as_str(),
|
||||||
|
"npm" | "npx" | "pnpm" | "pnpx" | "yarn" | "yarnpkg" | "corepack"
|
||||||
|
);
|
||||||
|
|
||||||
|
if needs_cmd_shim {
|
||||||
|
Some(OsString::from(format!("{raw}.cmd")))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn normalized_program<S>(program: S) -> OsString
|
||||||
|
where
|
||||||
|
S: AsRef<OsStr>,
|
||||||
|
{
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
if let Some(shimmed) = maybe_windows_cmd_shim(program.as_ref()) {
|
||||||
|
return shimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
program.as_ref().to_os_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tokio_command<S>(program: S) -> tokio::process::Command
|
||||||
|
where
|
||||||
|
S: AsRef<OsStr>,
|
||||||
|
{
|
||||||
|
let mut command = tokio::process::Command::new(normalized_program(program));
|
||||||
|
configure_tokio_command(&mut command);
|
||||||
|
command
|
||||||
|
}
|
||||||