Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c404bfe60e | ||
|
|
d7fb4daca5 | ||
|
|
fa916e8603 | ||
|
|
8429754d1c | ||
|
|
9e46fe4746 | ||
|
|
99281682a6 | ||
|
|
19430d225d | ||
|
|
330a75b8fb | ||
|
|
a91faab615 | ||
|
|
962774be14 | ||
|
|
2821aeb95f | ||
|
|
604ab2451e | ||
|
|
8ccb0f575c | ||
|
|
29608dfe78 | ||
|
|
09406f2932 | ||
|
|
6bc7a7cdba | ||
|
|
9a156e88fe | ||
|
|
50f38c77c7 | ||
|
|
68449b81fc | ||
|
|
485a18ee51 | ||
|
|
6e68735e0a | ||
|
|
3a09a2b9c5 | ||
|
|
1090980452 | ||
|
|
cdb4b1c7b2 | ||
|
|
77e9c69a9d | ||
|
|
ce61452e45 | ||
|
|
90d2c33d3a | ||
|
|
7fdd219c0e | ||
|
|
8061478f87 | ||
|
|
af1e52bed1 | ||
|
|
a99b120902 | ||
|
|
3f1db467ee | ||
|
|
a654c97279 | ||
|
|
5517a3af78 | ||
|
|
a7c1640e7a | ||
|
|
3e8cf9440c | ||
|
|
edb90cd7e4 | ||
|
|
f339f668ad | ||
|
|
d304a7607d | ||
|
|
8d82bb573d | ||
|
|
32674bdd48 | ||
|
|
e5b6f817b0 | ||
|
|
5161dd3c84 | ||
|
|
f7a5b5a79a | ||
|
|
bae312ce7a | ||
|
|
0f7a0beac7 | ||
|
|
633984124a | ||
|
|
cc7a2bf71e | ||
|
|
5a46f7277b | ||
|
|
d5ac6a3cef | ||
|
|
f0eb2a474c | ||
|
|
5be617e513 | ||
|
|
661fbd2bd9 | ||
|
|
3a7c15a4bb | ||
|
|
5adce512d4 | ||
|
|
6a3d9c15af | ||
|
|
9ac2294bec | ||
|
|
e5580e4d30 | ||
|
|
ba9fb3c8d2 | ||
|
|
401c6b8cb0 | ||
|
|
dc979239c0 | ||
|
|
51832e9661 | ||
|
|
2b87a6e10d | ||
|
|
aa8c82a606 | ||
|
|
2a5e24848b | ||
|
|
c26a5500cf | ||
|
|
e1f144d743 | ||
|
|
eb06c32366 | ||
|
|
db3a0d0d7e | ||
|
|
6cc7a8ea66 | ||
|
|
71b0ffa80e | ||
|
|
b0d8fea64c | ||
|
|
95864a9cc7 | ||
|
|
18ee05d0f1 | ||
|
|
c41c9ed0cf | ||
|
|
08008a02ea | ||
|
|
a5654c5af1 | ||
|
|
3f80f74208 | ||
|
|
4b5b7a2927 | ||
|
|
8355c6f37b | ||
|
|
8735359f14 | ||
|
|
5f13757d7a | ||
|
|
fd162a3636 | ||
|
|
e7fb66996f | ||
|
|
6e5fa83fa3 | ||
|
|
548568b5e3 | ||
|
|
1e6b28f7e4 | ||
|
|
e105394f67 | ||
|
|
6cdc59ffca | ||
|
|
0c765e0f49 | ||
|
|
2bb40b2704 | ||
|
|
0097cde077 | ||
|
|
6df5791fc5 | ||
|
|
a0622c776f | ||
|
|
fbc38dd0cc | ||
|
|
27e8137f88 | ||
|
|
89e9c752aa | ||
|
|
eaae901d27 | ||
|
|
7965801689 | ||
|
|
ac2c5cee88 | ||
|
|
424838c00d | ||
|
|
f94de83550 | ||
|
|
8973327ebb | ||
|
|
520ed5c933 | ||
|
|
a6fb3a2608 | ||
|
|
c636332251 | ||
|
|
f0f5799880 | ||
|
|
414b915f58 | ||
|
|
219afe8f79 | ||
|
|
080b385feb | ||
|
|
06e449b277 | ||
|
|
48d2d9dd0c | ||
|
|
34f44540eb | ||
|
|
402e7fd2e7 | ||
|
|
fba0b421f9 | ||
|
|
86b7a8a4ae | ||
|
|
03812d9f65 | ||
|
|
fac3046495 | ||
|
|
e4a2c9fa5e | ||
|
|
607839b4b3 | ||
|
|
1d867554b4 | ||
|
|
e4f77af083 | ||
|
|
315acfc461 | ||
|
|
e9a7890950 | ||
|
|
af44361eab | ||
|
|
9f9af5e3c5 | ||
|
|
91231eb436 | ||
|
|
5f1a710d9b | ||
|
|
f9d56f7cc3 | ||
|
|
3ae7dad3dc | ||
|
|
85face1624 | ||
|
|
6c1d7e91fe | ||
|
|
124c1ac4a8 | ||
|
|
426366131f | ||
|
|
473c62e83e | ||
|
|
87428159d7 | ||
|
|
a24ea7298f | ||
|
|
87961386d8 | ||
|
|
0b5df530e4 | ||
|
|
77895624e8 | ||
|
|
150b8b6d65 | ||
|
|
ffd5294f73 | ||
|
|
1ea15305b9 | ||
|
|
0f19a88be4 | ||
|
|
e8df4b46d5 | ||
|
|
f26c39333d | ||
|
|
dbcd2f2271 | ||
|
|
3378b0e221 | ||
|
|
ee15e9fe0d | ||
|
|
2a890c5061 | ||
|
|
9288fb2a93 | ||
|
|
8638530792 | ||
|
|
05705dcb97 | ||
|
|
e3a56c14e9 | ||
|
|
3ef9dde09c | ||
|
|
f65677e070 | ||
|
|
bf64097412 | ||
|
|
87f233a389 | ||
|
|
7856ac9172 | ||
|
|
601dd4c8d4 | ||
|
|
0188d05a62 | ||
|
|
329f6c96c6 |
No files matched your search
@@ -1 +1,3 @@
|
||||
github: esengine
|
||||
custom:
|
||||
- https://paypal.me/yuhuahui
|
||||
|
After Width: | Height: | Size: 138 KiB |
@@ -0,0 +1,63 @@
|
||||
name: Publish dsnix
|
||||
|
||||
# Tag namespace: `dsnix-v<semver>` triggers npm publish for the
|
||||
# dsnix shim package only. Plain `v<semver>` tags publish the main
|
||||
# `reasonix` CLI (see publish-npm.yml); the two are decoupled so the
|
||||
# alias can be released independently when the shim itself changes.
|
||||
on:
|
||||
push:
|
||||
tags: ["dsnix-v*"]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Tag to publish (e.g. dsnix-v0.48.0). Required when running manually."
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: publish dsnix to npm
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.tag || github.ref }}
|
||||
|
||||
- name: Setup Node 22
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
registry-url: https://registry.npmjs.org/
|
||||
|
||||
- name: Verify tag matches packages/dsnix/package.json version
|
||||
shell: bash
|
||||
run: |
|
||||
tag="${{ github.event.inputs.tag || github.ref_name }}"
|
||||
tag_version="${tag#dsnix-v}"
|
||||
pkg_version=$(node -p "require('./packages/dsnix/package.json').version")
|
||||
if [ "$tag_version" != "$pkg_version" ]; then
|
||||
echo "::error::tag $tag (version $tag_version) does not match packages/dsnix/package.json version $pkg_version"
|
||||
exit 1
|
||||
fi
|
||||
echo "tag $tag matches packages/dsnix/package.json $pkg_version"
|
||||
|
||||
- name: Verify reasonix@<version> is already published on npm
|
||||
shell: bash
|
||||
run: |
|
||||
dep_version=$(node -p "require('./packages/dsnix/package.json').dependencies.reasonix")
|
||||
if ! npm view "reasonix@${dep_version}" version >/dev/null 2>&1; then
|
||||
echo "::error::dsnix depends on reasonix@${dep_version} but that version is not published yet"
|
||||
exit 1
|
||||
fi
|
||||
echo "reasonix@${dep_version} is published — safe to publish dsnix"
|
||||
|
||||
- name: Publish dsnix to npm
|
||||
run: npm publish --access public
|
||||
working-directory: packages/dsnix
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
@@ -41,6 +41,35 @@ jobs:
|
||||
-R "${{ github.repository }}" \
|
||||
-D assets
|
||||
|
||||
# Rewrite bundle URLs inside latest.json (Tauri updater manifest)
|
||||
# so the desktop pulls bundles from R2, not GitHub. Without this the
|
||||
# updater fetches the small JSON from R2 but still downloads the
|
||||
# heavy installer from github.com — defeating the mirror.
|
||||
# No-op when latest.json is absent (CLI-only releases).
|
||||
- name: Rewrite latest.json URLs → R2
|
||||
if: env.HAS_R2 == 'true'
|
||||
env:
|
||||
R2_PUBLIC_BASE: https://pub-147fb53b9c1e4bbf891a257968619ea7.r2.dev
|
||||
TAG: ${{ steps.tag.outputs.name }}
|
||||
run: |
|
||||
file=assets/latest.json
|
||||
if [ ! -f "$file" ]; then
|
||||
echo "no latest.json in release — skipping rewrite"
|
||||
exit 0
|
||||
fi
|
||||
# GitHub URLs look like:
|
||||
# https://github.com/<owner>/<repo>/releases/download/<TAG>/<file>
|
||||
# Rewrite to:
|
||||
# <R2_PUBLIC_BASE>/<TAG>/<file>
|
||||
jq --arg base "$R2_PUBLIC_BASE" --arg tag "$TAG" '
|
||||
.platforms |= with_entries(
|
||||
.value.url |= sub("https://github.com/[^/]+/[^/]+/releases/download/[^/]+/"; "\($base)/\($tag)/")
|
||||
)
|
||||
' "$file" > "$file.new"
|
||||
mv "$file.new" "$file"
|
||||
echo "rewrote latest.json:"
|
||||
cat "$file"
|
||||
|
||||
- name: Configure AWS CLI for R2
|
||||
if: env.HAS_R2 == 'true'
|
||||
run: |
|
||||
|
||||
@@ -96,6 +96,23 @@ jobs:
|
||||
working-directory: desktop
|
||||
run: npm run bundle:node
|
||||
|
||||
- name: Verify bundled Node is universal (macOS)
|
||||
if: startsWith(matrix.target.os, 'macos-')
|
||||
working-directory: desktop
|
||||
run: |
|
||||
archs=$(lipo -archs src-tauri/binaries/node)
|
||||
echo "node archs: $archs"
|
||||
echo "$archs" | grep -q "arm64"
|
||||
echo "$archs" | grep -q "x86_64"
|
||||
|
||||
- name: Build & verify desktop frontend
|
||||
working-directory: desktop
|
||||
shell: bash
|
||||
run: |
|
||||
npm run build
|
||||
test -f dist/index.html
|
||||
ls dist/assets/*.js >/dev/null
|
||||
|
||||
# tauri-action signs OS bundles whenever the corresponding env vars are
|
||||
# defined — including when they're defined to the empty string, which is
|
||||
# what `secrets.UNSET` evaluates to. Gate each signing block on its
|
||||
|
||||
@@ -3,6 +3,261 @@
|
||||
All notable changes to Reasonix. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
|
||||
this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.48.1] — 2026-05-21
|
||||
|
||||
**`dsnix` — short alias.** A new `dsnix` shim package lands alongside
|
||||
`reasonix` (#1440), so `npx dsnix` is now equivalent to `npx reasonix`
|
||||
for users who'd rather type five characters than eight. The shim is
|
||||
just a forwarding binary — same CLI, same release cadence — and ships
|
||||
on npm with the same version pinning (#1442). The root README documents
|
||||
the alias.
|
||||
|
||||
**Lifecycle — observability + corpus tests.** Strict lifecycle now
|
||||
surfaces step-level evidence and rail decisions through a structured
|
||||
observability channel (#1426), so what the loop is doing at each step
|
||||
is legible instead of inferred. Two corpus tests lock the behavior in:
|
||||
multi-file refactor flow (#1427) and config-migration replay (#1473).
|
||||
|
||||
**Approval prompt unification.** Phase 3 of the pause-request work
|
||||
(#1322) lands a single `PauseRequest` → UI data model shared by ACP,
|
||||
CLI, and Desktop (#1443) — the three surfaces now render the same
|
||||
prompt from the same payload instead of each translating in their own
|
||||
way. Net: pause prompts stay in sync across surfaces when fields are
|
||||
added.
|
||||
|
||||
**i18n.** Builtin skill descriptions are now translated (#1468), and
|
||||
MCP error toasts + status labels carry zh-CN (#1481). The remaining
|
||||
hardcoded English surface in skill picker and MCP error toasts is gone.
|
||||
|
||||
**Composer redesign + Ctrl+P shortcut modal.** The input area is
|
||||
rebuilt with a denser layout, a clearer affordance for multi-line
|
||||
input, and a Ctrl+P shortcut-help modal that lists every key binding
|
||||
in one place (#1483). The desktop composer also autosizes for
|
||||
multi-line input properly (#1439).
|
||||
|
||||
**Java tool — cheaper.** The Java source/decompile tool description
|
||||
and output are pruned (#1485), shaving cached prefix tokens. Pairs with
|
||||
the #1390 buffer raise from 0.48.0.
|
||||
|
||||
**Security.** `metaso` web-search no longer ships with a baked API
|
||||
key — it now requires the user-configured key like every other backend
|
||||
(#1488). Project-level hooks are trusted without an extra prompt
|
||||
provided they live inside the working directory.
|
||||
|
||||
**YOLO mode polish.** `run_command` and `run_background` approval
|
||||
gates now auto-resolve in YOLO mode (#1489) so power users don't get a
|
||||
modal for every shell call after explicitly opting in.
|
||||
|
||||
**Perf — folding thresholds.** Normal fold threshold raised 50% → 75%,
|
||||
aggressive 70% → 78% (#1461) — the loop now spends less time folding
|
||||
context that didn't need to be folded.
|
||||
|
||||
**Preflight.** The cap that previously only counted tokens now also
|
||||
gates on raw JSON body bytes (#1451), catching oversized payloads that
|
||||
weren't reflected in the token count.
|
||||
|
||||
**Desktop.** Error cards are dismissable and recoverable variants
|
||||
toned down (#1487); orphaned pause-gate modals are cleared on
|
||||
`$turn_complete` so a stale gate can't block the next turn (#1484);
|
||||
session titles are renameable from the sidebar (#1478); `/btw` now
|
||||
echoes input, surfaces busy state, and routes through the side-question
|
||||
prompt instead of silently dropping the keystroke (#1472).
|
||||
|
||||
**TUI.** SGR mouse mode stays on for Windows Terminal (#1486) —
|
||||
alternate-scroll silently breaks the wheel there. The default for
|
||||
non-WT terminals switches to alternate-scroll (`?1007h`) (#1477) to
|
||||
avoid the click-eats-selection problem on conhost-class hosts.
|
||||
|
||||
**Other:**
|
||||
- `fix(cost)` show session-aggregate cache hit so `/cost` matches the
|
||||
status-bar number (#1482)
|
||||
- `fix(plan)` Accept executes immediately when the plan has no open
|
||||
questions instead of waiting on a non-existent answer (#1480)
|
||||
- `fix` resolve root-relative edit-block paths (#1452)
|
||||
- `fix(tests)` bump chat-mcp-startup timeout to 30s for Windows test
|
||||
matrix (#1471)
|
||||
|
||||
## [0.48.0] — 2026-05-20
|
||||
|
||||
**Proxy-aware networking.** `NO_PROXY` is now honored end-to-end and
|
||||
the DeepSeek host is baked into the default bypass whitelist so a
|
||||
machine-wide `HTTPS_PROXY` no longer routes API traffic through an
|
||||
unwanted proxy (#1373). A `--no-proxy` CLI flag, `REASONIX_NO_PROXY`
|
||||
env, and `cfg.proxy` field give per-invocation control (#1374), and
|
||||
`/doctor` surfaces the resolved `NO_PROXY` value plus the routing
|
||||
decision Reasonix made for each host (#1375). If proxy behavior ever
|
||||
looks wrong, `/doctor` will tell you why in one screen.
|
||||
|
||||
**Code intelligence boost.** Two new tree-sitter–powered tools land:
|
||||
`get_symbols` and `find_in_code` cover TS/JS/Python/Go/Rust/Java
|
||||
(#1387), giving Reasonix structural code navigation instead of plain
|
||||
grep. Java gets a dedicated `java_source` tool that reads `.java`
|
||||
sources and decompiles `.class` / inner jar entries via `javap`
|
||||
(#1344), with output stripped of noise and the read buffer raised so
|
||||
large classes fit in one round (#1390). Web search adds Perplexity
|
||||
and Exa backends alongside the existing Mojeek / Tavily set (#1350).
|
||||
|
||||
**Lifecycle round two.** Checkpoints now gate mutating tools during
|
||||
restore so an in-flight edit can't race a rollback (#1389), state is
|
||||
preserved across plan revisions (#1388), and step evidence is
|
||||
summarized at the plan card so progress is legible without expanding
|
||||
each step (#1391). `/plan` is excluded from the strict-rails cache
|
||||
constraint that would otherwise force a re-prefix on every revision
|
||||
(#1407, #1386), and a strict end-to-end harness covers the whole
|
||||
lifecycle path (#1424).
|
||||
|
||||
**Subagent UX.** Long-running `explore` / `research` / `review` are
|
||||
now top-level slash commands with live byte-progress in the status
|
||||
row instead of a frozen spinner (#1422). The agent loop is hardened:
|
||||
`_turnAbort` resets in `finally` so a consumer break can't lock the
|
||||
session (#1421), ambiguous search-replace edit blocks are rejected
|
||||
instead of silently miss-applying (#1414), repeated gate rejections
|
||||
no longer ping-pong (#1392), and FS tools walk `.reasonix/` so user
|
||||
skills are reachable from any subdir (#1371).
|
||||
|
||||
**Desktop.** QQ sessions are now usable from the desktop app — the
|
||||
QQ Adapter routes events through the same RPC bridge as CLI tabs
|
||||
(#1334). Custom font support + file-link rendering improvements ship
|
||||
(#1376). Reliability fixes: `rpc_spawn` is idempotent and a
|
||||
`desktop_resync` rehydrate path recovers cleanly after a renderer
|
||||
reload (#1428); F5 / Ctrl+R are swallowed in packaged builds so a
|
||||
stray refresh can't wipe the chat (#1430); React error #31 with
|
||||
non-string MCP config items is fixed (#1394); the context-token
|
||||
meter syncs after `/compact` (#1399); the cost meter and session
|
||||
cost card apply USD→CNY conversion (#1369); `augmentProcessPath()`
|
||||
lost in the #1334 rebase is restored (#1361); desktop stdout partial
|
||||
writes are handled (#1405); `/btw` on empty payload now surfaces a
|
||||
usage hint instead of dropping the keystroke (#1383).
|
||||
|
||||
**TUI / UI.** `/about` is a new slash command showing version,
|
||||
website, GitHub, and license (#1434). Remaining hardcoded strings in
|
||||
edit history and MCP browse are now i18n'd (#1433). Reasoning,
|
||||
streaming, and diff content beyond the recent window are elided so
|
||||
long sessions don't tank scroll perf (#1432). Wheel scroll is less
|
||||
sensitive and per-card stdout resize listeners collapse to one (#1423);
|
||||
terminal mouse tracking now has a CLI opt-out for users on
|
||||
mouse-hostile terminals (#1345).
|
||||
|
||||
**Settings / config.** `config.json` with a UTF-8 BOM is tolerated
|
||||
instead of crashing the loader (#1431); preset-trace diagnostic
|
||||
retired (#1431). The dashboard can now clear `baseUrl` to fall back
|
||||
to the bundled default (#1425). `string[]` config fields are
|
||||
sanitized at the `readConfig` boundary so a malformed entry can't
|
||||
propagate into the running session (#1396).
|
||||
|
||||
**Other:**
|
||||
- `fix(test)` switch vitest to forks pool to stop heap OOM on
|
||||
many-core hosts (#1408)
|
||||
- `chore(shell)` trim `run_command` param description (saves 119
|
||||
tokens of cached prefix) (#1403)
|
||||
- `chore(scripts)` measure per-tool token cost end-to-end (#1402)
|
||||
- `test(tools)` close dispatch-level E2E gaps for shell jobs, web,
|
||||
and scaffold (#1400)
|
||||
- `test(shell-tools)` raise `run_background` dispatch test timeout
|
||||
to 15s (#1406)
|
||||
- `test(web)` cover Perplexity + Exa backends (#1366)
|
||||
- `docs(qq)` add desktop quick start and trim README surface (#1401)
|
||||
|
||||
## [0.47.2] — 2026-05-19
|
||||
|
||||
**Hotfix — `npx reasonix@latest` install.** `0.47.1` shipped with
|
||||
`@reasonix/core-utils` listed under runtime `dependencies` as
|
||||
`workspace:*`. Because the workspace package is `private: true`,
|
||||
`npm publish` did not rewrite the protocol, so consumers hit
|
||||
`EUNSUPPORTEDPROTOCOL: Unsupported URL Type "workspace:"` during
|
||||
install. The bundled `dist/` already inlines core-utils at build
|
||||
time, so the dependency belongs under `devDependencies`. Moved.
|
||||
`0.47.1` is deprecated on npm; please install `0.47.2`.
|
||||
|
||||
## [0.47.1] — 2026-05-19
|
||||
|
||||
**Desktop shakeout.** Follow-ups to the 0.47.0 desktop launch:
|
||||
inherit login-shell `PATH` so `nvm`/`asdf`/`fnm`-installed Node is
|
||||
visible to `run_command` (#1331); stop claiming "Connected" on the QQ
|
||||
settings page since the desktop is config-editor-only, not a live
|
||||
bridge (#1326); re-enable devtools + right-click context menu in
|
||||
release builds; route the in-app update check through the updater
|
||||
plugin so CSP doesn't block it (#1298); prevent the browser-level
|
||||
Select-All when the composer isn't focused (#1314); add unified
|
||||
`button:disabled` style; ship selectable theme styles (#1291); a few
|
||||
follow-up bug fixes from the release.
|
||||
|
||||
**Prompt budget — round one.** `codeSystemPrompt` compressed -51%
|
||||
(~3.1k tokens/request, #1323) and tool spec descriptions -28% (~2.7k
|
||||
tokens/request, #1321), with a byte-budget regression test locked in
|
||||
(#1320). The cache-prefix shrinks accordingly — cheaper first turn,
|
||||
same behavior.
|
||||
|
||||
**TUI / shell-exec.** Per-call shell-exec approval flow now surfaces
|
||||
on the first run instead of failing silently (#1278); spinner +
|
||||
stream tick rate dropped on legacy Windows conhost; light-theme
|
||||
picker rows highlight on selection (#1330); `//` line-comment input
|
||||
no longer parses as a slash command (#1288).
|
||||
|
||||
**Other:**
|
||||
- `feat(core-utils)` new `@reasonix/core-utils` workspace package —
|
||||
Phase 1 internal split, no public-API change (#1328)
|
||||
- `feat(lifecycle)` opt-in engineering lifecycle (#1306)
|
||||
- `feat(tools)` ordered interceptor chain (#1301)
|
||||
- `feat(plan)` persist step evidence metadata (#1302)
|
||||
- `feat(read_file)` lower outline threshold 512 KiB → 64 KiB (#1324)
|
||||
- `fix(multi_edit)` roll back files that may have been modified on
|
||||
write failure (#1325)
|
||||
- `fix(permissions)` strip literal `*` from always-allow prefix in
|
||||
Desktop/ACP (#1312)
|
||||
- `fix(client)` skip `extra_body` for Azure endpoints (#1316)
|
||||
- `fix(search)` honor `tavily` engine + read engine at call time
|
||||
- `fix(cli)` resolve heap reexec entrypoint (#1303)
|
||||
- `fix(docs)` point download mirrors at `desktop-v*` tag namespace
|
||||
|
||||
## [0.47.0] — 2026-05-18
|
||||
|
||||
**Desktop matures.** The Tauri app picks up the polish it was missing —
|
||||
About modal with one-click update check, KaTeX math rendering in chat,
|
||||
window/tab/session/scroll restore across relaunch, /compact /retry /btw
|
||||
/feedback slash commands, per-message copy + file-export actions, QQ
|
||||
settings entry, platform-aware keyboard shortcut hints. The auto-updater
|
||||
now reads `latest.json` from the R2 mirror so an npm-only `v*` release
|
||||
can no longer brick the in-app update banner; the banner also surfaces
|
||||
download progress while the bundle pulls. Universal Node is bundled on
|
||||
macOS so Intel Macs stop blank-screening at launch.
|
||||
|
||||
**TUI — composer round two.** Composer keys and visuals re-align with
|
||||
Claude Code: rounded border, status row at the bottom, input-box
|
||||
background fill, left stripe on user card. `ctrl+r` toggles verbose
|
||||
(reasoning + raw tool I/O); `esc-esc` opens a rewind picker over the
|
||||
last 5s; `u` undo is now gated to the same 5s window so stale taps
|
||||
can't revert. SGR mouse wheel is on by default again, IME cursor stays
|
||||
glued to `▌`, WelcomeBanner centers vertically on empty chats.
|
||||
|
||||
**Search hardening.** Regex now runs in a worker thread so a ReDoS
|
||||
pattern can be terminated cleanly; worker deadline 5s → 60s and walk
|
||||
deadline 15s → 120s for legitimately large repos. Tavily added as a
|
||||
`web_search` backend — escape hatch when Mojeek 403s. `search_content`
|
||||
gets a walk-level deadline and ESC preempts queued tool calls.
|
||||
|
||||
**Claude-ecosystem compat.** Reasonix now reads `.mcp.json` and
|
||||
`.claude/skills/` from the repo so existing Claude Code setups work
|
||||
without a second copy; common skill fields (`type`/`context`/`agent`)
|
||||
are aliased.
|
||||
|
||||
**Other:**
|
||||
- `feat(embedding)` batch OpenAI-compat embedding requests to respect
|
||||
provider batch limits
|
||||
- `feat(memory)` inject ancestor `AGENTS.md` into `list_directory`,
|
||||
not only `read_file`
|
||||
- `perf(session)` skip cross-workspace jsonls + byte-scan line count
|
||||
— sidebar list ~10× faster
|
||||
- `feat(config)` support pricing overrides and per-model rpm limits
|
||||
- `feat(dashboard)` new / switch / delete sessions in-place, no CLI
|
||||
bounce
|
||||
- `fix(mcp)` runtime schema validation for MCP server responses; ESC
|
||||
aborts hung handshakes during startup; surface the real bridge-
|
||||
failure reason in the dashboard
|
||||
- `fix(cli)` preserve flash-preset auto-escalate semantics
|
||||
- `ci` split desktop + npm releases into separate tag namespaces:
|
||||
`v*` → npm, `desktop-v*` → Tauri bundles
|
||||
|
||||
## [0.46.0] — 2026-05-17
|
||||
|
||||
**Breaking — Rust renderer removed.** Reasonix is back to a pure Ink/Node
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
·
|
||||
<a href="./benchmarks/">Benchmarks</a>
|
||||
·
|
||||
<a href="https://discord.gg/XF78rEME2D">Discord</a>
|
||||
<strong><a href="https://discord.gg/XF78rEME2D">Discord</a></strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -55,6 +55,9 @@
|
||||
> [!NOTE]
|
||||
> **Real user, single day (2026-05-01):** 435M input tokens, **99.82% cache hit**, ~$12 instead of the ~$61 the same workload would cost with no cache on `v4-flash` — see the [case study](./benchmarks/real-world-cache/README.md). DeepSeek provides the cacheable bytes; the four mechanisms in [Pillar 1](./docs/ARCHITECTURE.md#pillar-1--cache-first-loop) are how Reasonix keeps them cacheable across long sessions.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Community · 加入社区** — bilingual Discord with channels for setup help (`#help` / `#求助`), workflow showcases, feature ideas, and contributor-only PR coordination. Verify your GitHub in-server to get the **Contributor** role automatically. → **<https://discord.gg/XF78rEME2D>**
|
||||
|
||||
<br/>
|
||||
|
||||
## Install
|
||||
@@ -79,6 +82,15 @@ Grab a [DeepSeek API key →](https://platform.deepseek.com/api_keys) · `reason
|
||||
|
||||
If you use Reasonix daily, global install is the simplest path. If you just want to try it, use `npx`.
|
||||
|
||||
**Prefer fewer keystrokes?** The shorter `dsnix` alias resolves to the same CLI:
|
||||
|
||||
~~~bash
|
||||
npm install -g dsnix # exposes `dsnix` on PATH, depends on reasonix
|
||||
npx dsnix@latest code # one-shot via the shorter command
|
||||
~~~
|
||||
|
||||
A global `npm install -g reasonix` also drops a `dsnix` shim on PATH, so the two are interchangeable.
|
||||
|
||||
Bare `reasonix` (no subcommand) launches `code` in the current directory — typing `reasonix` and `reasonix code` are equivalent.
|
||||
|
||||
| Command | When |
|
||||
@@ -93,31 +105,15 @@ Other subcommands (`replay` · `diff` · `events` · `stats` · `index` · `mcp`
|
||||
|
||||
### QQ channel
|
||||
|
||||
Reasonix can attach QQ as a remote communication channel for existing `chat` and `code` sessions. It is not a separate runtime mode.
|
||||
QQ can extend an existing `chat`, `code`, or desktop session as a remote channel. It is part of the current session flow, not a separate runtime mode.
|
||||
|
||||
Start a session first:
|
||||
- CLI: start a session, then run `/qq connect`
|
||||
- Desktop: open `Settings -> General -> QQ Channel`
|
||||
|
||||
~~~bash
|
||||
reasonix code
|
||||
# or
|
||||
reasonix chat
|
||||
~~~
|
||||
Once connected, QQ messages can enter the current session, assistant replies route back to QQ, and follow-up interactions can continue remotely.
|
||||
|
||||
Then connect QQ from inside the session:
|
||||
For full setup, desktop quick start, and troubleshooting, see [QQ channel setup](./docs/qq-connect.md).
|
||||
|
||||
~~~text
|
||||
/qq connect
|
||||
~~~
|
||||
|
||||
Available commands:
|
||||
|
||||
- `/qq connect`
|
||||
- `/qq status`
|
||||
- `/qq disconnect`
|
||||
|
||||
Once enabled, later `chat` / `code` sessions auto-start the QQ channel. Slash commands, confirmation prompts, and follow-up assistant replies can continue through QQ without terminal-side input.
|
||||
|
||||
See [QQ channel setup](./docs/qq-connect.md) for setup details and QQ Open Platform bot registration.
|
||||
### Desktop client (prerelease)
|
||||
|
||||
A native Tauri client for users who want a GUI over the same loop. Multi-tab, the right-panel shows files the agent has read or edited this session, the same cost / cache / token meters live at the bottom. Same DeepSeek API key, same `~/.reasonix` config — the desktop bundles its own Node runtime, no separate `npm install` step.
|
||||
@@ -159,6 +155,13 @@ npx reasonix code --dir /path/to/project
|
||||
/skill new my-skill --global # ~/.reasonix/skills for cross-project use
|
||||
~~~
|
||||
|
||||
**Claude-format skills also load.** `<project>/.claude/skills/<name>/SKILL.md` and `~/.claude/skills/` are read alongside Reasonix's native paths, so tooling that emits Claude-format skills works out of the box. Example — drop OpenSpec workflows in without an upstream adapter:
|
||||
|
||||
~~~bash
|
||||
npx openspec init --tools claude # writes .claude/skills/openspec-*/SKILL.md
|
||||
/skill openspec-propose <task> # then invoke from Reasonix
|
||||
~~~
|
||||
|
||||
</details>
|
||||
|
||||
<br/>
|
||||
@@ -224,7 +227,7 @@ For live cache-hit rates, costs, and methodology, see [`benchmarks/`](./benchmar
|
||||
|
||||
- [**Architecture**](./docs/ARCHITECTURE.md) — three pillars: cache-first loop, tool-call repair, cost control
|
||||
- [**CLI Reference**](./docs/CLI-REFERENCE.md) — every shell subcommand, every slash command, every keybinding
|
||||
- [**QQ channel setup**](./docs/qq-connect.md) — what it is, commands, setup flow, and QQ Open Platform credentials
|
||||
- [**QQ channel setup**](./docs/qq-connect.md) — CLI first-connect flow, desktop entry, and QQ Open Platform credentials
|
||||
- [**Benchmarks**](./benchmarks/) — τ-bench-lite harness, transcripts, cost methodology
|
||||
- [**Website**](https://esengine.github.io/DeepSeek-Reasonix/) — getting started, dashboard mockup, TUI mockup
|
||||
- [**Contributing**](./CONTRIBUTING.md) — comment policy, error-handling rules, library-over-hand-rolled
|
||||
@@ -235,7 +238,7 @@ For live cache-hit rates, costs, and methodology, see [`benchmarks/`](./benchmar
|
||||
## Community
|
||||
|
||||
> [!NOTE]
|
||||
> Reasonix is open source and community-developed. The contributors wall below isn't decoration — every avatar is a real PR that shipped.
|
||||
> Reasonix is open source and community-developed. Every avatar in the Acknowledgments wall at the bottom of this file is a real PR that shipped.
|
||||
|
||||
Scoped starter tickets — each with background, code pointers, acceptance criteria, and hints — live under the [`good first issue`](https://github.com/esengine/reasonix/labels/good%20first%20issue) label. Pick anything open.
|
||||
|
||||
@@ -255,12 +258,6 @@ Scoped starter tickets — each with background, code pointers, acceptance crite
|
||||
|
||||
**Before your first PR**: read [`CONTRIBUTING.md`](./CONTRIBUTING.md) — short, strict rules (comments, errors, libraries-over-hand-rolled). `tests/comment-policy.test.ts` enforces the comment ones; `npm run verify` is the pre-push gate. By participating you agree to the [Code of Conduct](./CODE_OF_CONDUCT.md). Security issues → [SECURITY.md](./SECURITY.md).
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/esengine/reasonix/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=esengine/DeepSeek-Reasonix&max=100&columns=12" alt="Contributors to esengine/DeepSeek-Reasonix" width="860"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<br/>
|
||||
|
||||
## Non-goals
|
||||
@@ -287,12 +284,52 @@ Scoped starter tickets — each with background, code pointers, acceptance crite
|
||||
|
||||
<br/>
|
||||
|
||||
## Support
|
||||
|
||||
If Reasonix has been useful and you'd like to say thanks, you can. It stays a coffee, not a contract — donations don't buy feature priority or change how issues get triaged.
|
||||
|
||||
- **International** — PayPal: [paypal.me/yuhuahui](https://paypal.me/yuhuahui)
|
||||
- **国内** — 微信支付(扫码)
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/sponsor/wechat-pay.jpg" alt="WeChat Pay QR code" width="240"/>
|
||||
</p>
|
||||
|
||||
<br/>
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
A small list of folks whose work has shaped Reasonix the most — measured
|
||||
by both commit count and code volume. **Listed alphabetically, no ordering
|
||||
of importance.** The full contributor graph is on
|
||||
[GitHub](https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors).
|
||||
|
||||
- [**ctharvey**](https://github.com/ctharvey)
|
||||
- [**dimasd-angga**](https://github.com/dimasd-angga) (Dimas D. Angga)
|
||||
- [**Evan-Pycraft**](https://github.com/Evan-Pycraft)
|
||||
- [**ForeverYoungPp**](https://github.com/ForeverYoungPp)
|
||||
- [**GTC2080**](https://github.com/GTC2080) (TaoMu)
|
||||
- [**kabaka9527**](https://github.com/kabaka9527)
|
||||
- [**lisniuse**](https://github.com/lisniuse) (Richie)
|
||||
- [**wade19990814-hue**](https://github.com/wade19990814-hue)
|
||||
- [**wviana**](https://github.com/wviana) (Wesley Viana)
|
||||
|
||||
Also a separate thank-you to [**Bernardxu123**](https://github.com/Bernardxu123)
|
||||
for designing the project logo (see [`docs/brand/`](./docs/brand/)), and to
|
||||
[AIGC Link](https://xhslink.com/m/80ngts127cA) for promoting the project on XiaoHongShu.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=esengine/DeepSeek-Reasonix&max=100&columns=12" alt="Contributors to esengine/DeepSeek-Reasonix" width="860"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<br/>
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<sub>MIT — see <a href="./LICENSE">LICENSE</a></sub>
|
||||
<br/>
|
||||
<sub>Built by the community at <a href="https://github.com/esengine/reasonix/graphs/contributors">esengine/reasonix</a></sub>
|
||||
<br/>
|
||||
<sub>Special thanks to <a href="https://xhslink.com/m/80ngts127cA">AIGC Link</a> for the promotions on XiaoHongShu.</sub>
|
||||
<sub>Built by the community at <a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors">esengine/DeepSeek-Reasonix</a></sub>
|
||||
</p>
|
||||
@@ -15,7 +15,7 @@
|
||||
·
|
||||
<a href="./benchmarks/">基准测试</a>
|
||||
·
|
||||
<a href="https://discord.gg/XF78rEME2D">Discord</a>
|
||||
<strong><a href="https://discord.gg/XF78rEME2D">Discord</a></strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -52,6 +52,9 @@
|
||||
> [!TIP]
|
||||
> **缓存稳定不是开关,而是循环要围绕设计的不变量。** 这就是 Reasonix 只支持 DeepSeek 的根本原因 —— 每一层都为 DeepSeek 字节稳定的前缀缓存机制调过。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **加入社区 · Community** — 中英双语 Discord,频道包括 `#求助` / `#help`、`#分享` / `#showcase`、`#想法反馈`、贡献者专属 PR 协调区。在群内绑定 GitHub 后自动识别贡献者身份。→ **<https://discord.gg/XF78rEME2D>**
|
||||
|
||||
<br/>
|
||||
|
||||
## 安装
|
||||
@@ -77,31 +80,15 @@ npx reasonix code # 首次运行粘贴 DeepSeek API Key,之后会记住
|
||||
|
||||
### QQ 通道
|
||||
|
||||
Reasonix 可以把 QQ 挂到现有的 `chat` / `code` 会话上,作为远程通信通道使用。它不是一个独立的新模式。
|
||||
Reasonix 可以把现有的 `chat`、`code` 或桌面端会话延伸到 QQ 上,作为远程通道使用;它扩展的是当前会话,不是独立的新运行模式。
|
||||
|
||||
先启动一个会话:
|
||||
- CLI:先启动会话,再执行 `/qq connect`
|
||||
- 桌面端:打开 `设置 -> 通用 -> QQ通道`
|
||||
|
||||
~~~bash
|
||||
reasonix code
|
||||
# 或
|
||||
reasonix chat
|
||||
~~~
|
||||
连接成功后,QQ 消息可以进入当前会话,助手回复会回到 QQ,后续确认和跟进交互也可以继续在 QQ 上完成。
|
||||
|
||||
然后在会话里连接 QQ:
|
||||
完整配置、桌面端快速上手与排障说明见:[QQ 连接指南](./docs/qq-connect.zh-CN.md)。
|
||||
|
||||
~~~text
|
||||
/qq connect
|
||||
~~~
|
||||
|
||||
可用命令:
|
||||
|
||||
- `/qq connect`
|
||||
- `/qq status`
|
||||
- `/qq disconnect`
|
||||
|
||||
启用后,后续的 `chat` / `code` 会话会自动启动 QQ 通道。斜杠命令、确认提示,以及后续助手回复都可以通过 QQ 继续完成,不需要回到终端交互。
|
||||
|
||||
详细说明见 [QQ 连接指南](./docs/qq-connect.zh-CN.md)。
|
||||
<details>
|
||||
<summary><strong>切换工作区 · chat vs. code · 写第一个 Skill</strong></summary>
|
||||
|
||||
@@ -194,7 +181,7 @@ npx reasonix code --dir /path/to/project
|
||||
|
||||
- [**架构**](./docs/ARCHITECTURE.md) —— 四大支柱、缓存优先循环、思维提取、脚手架
|
||||
- [**CLI 参考**](./docs/CLI-REFERENCE.md) —— 每个 shell 子命令、每个 slash 命令、每个快捷键
|
||||
- [**QQ 连接指南**](./docs/qq-connect.zh-CN.md) —— 通道定位、命令、配置流程和 QQ 开放平台凭据
|
||||
- [**QQ 连接指南**](./docs/qq-connect.zh-CN.md) —— CLI 首次连接流程、桌面端入口和 QQ 开放平台凭据
|
||||
- [**基准测试**](./benchmarks/) —— τ-bench-lite harness、transcript、成本方法论
|
||||
- [**官方网站**](https://esengine.github.io/DeepSeek-Reasonix/) —— 入门、Dashboard 设计稿、TUI 设计稿
|
||||
- [**贡献指南**](./CONTRIBUTING.md) —— 注释规则、错误处理、用现成库不手写
|
||||
@@ -205,7 +192,7 @@ npx reasonix code --dir /path/to/project
|
||||
## 社区
|
||||
|
||||
> [!NOTE]
|
||||
> Reasonix 是开源、社区共建的项目。下面贡献者墙不是装饰 —— 每一个头像都对应一次真实合并的 PR。
|
||||
> Reasonix 是开源、社区共建的项目。文末"致谢"那面贡献者墙上的每一个头像,都对应一次真实合并的 PR。
|
||||
|
||||
给新手准备的入门 issue —— 每个都带背景说明、代码定位、验收标准、提示 —— 全部挂在 [`good first issue`](https://github.com/esengine/reasonix/labels/good%20first%20issue) 标签下。挑任意一个还没人认领的就行。
|
||||
|
||||
@@ -225,12 +212,6 @@ npx reasonix code --dir /path/to/project
|
||||
|
||||
**第一次提 PR 之前**:先读 [`CONTRIBUTING.md`](./CONTRIBUTING.md) —— 短小、严格的项目规则(注释、错误处理、用现成库不手写)。`tests/comment-policy.test.ts` 静态强制执行注释那部分,`npm run verify` 是 push 前的闸。参与本项目即同意 [行为准则](./CODE_OF_CONDUCT.md)。安全相关问题请走 [SECURITY.md](./SECURITY.md)。
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/esengine/reasonix/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=esengine/DeepSeek-Reasonix&max=100&columns=12" alt="esengine/DeepSeek-Reasonix 贡献者" width="860"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<br/>
|
||||
|
||||
## 不做的事
|
||||
@@ -257,10 +238,50 @@ npx reasonix code --dir /path/to/project
|
||||
|
||||
<br/>
|
||||
|
||||
## 支持本项目
|
||||
|
||||
如果 Reasonix 帮你省了时间或 token,欢迎请杯咖啡。捐助不会换来 feature 优先级,也不会影响 issue 的处理顺序——就是「谢谢」。
|
||||
|
||||
- **国内** — 微信支付(扫下方二维码)
|
||||
- **海外** — PayPal: [paypal.me/yuhuahui](https://paypal.me/yuhuahui)
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/sponsor/wechat-pay.jpg" alt="微信支付收款码" width="240"/>
|
||||
</p>
|
||||
|
||||
<br/>
|
||||
|
||||
## 致谢
|
||||
|
||||
下面这些朋友的工作塑造了 Reasonix 今天的样子 —— 综合 commit 数和代码量两个维度。
|
||||
**按字母顺序排列,排名不分先后。** 完整贡献者列表在
|
||||
[GitHub](https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors)。
|
||||
|
||||
- [**ctharvey**](https://github.com/ctharvey)
|
||||
- [**dimasd-angga**](https://github.com/dimasd-angga)(Dimas D. Angga)
|
||||
- [**Evan-Pycraft**](https://github.com/Evan-Pycraft)
|
||||
- [**ForeverYoungPp**](https://github.com/ForeverYoungPp)
|
||||
- [**GTC2080**](https://github.com/GTC2080)(TaoMu)
|
||||
- [**kabaka9527**](https://github.com/kabaka9527)
|
||||
- [**lisniuse**](https://github.com/lisniuse)(Richie)
|
||||
- [**wade19990814-hue**](https://github.com/wade19990814-hue)
|
||||
- [**wviana**](https://github.com/wviana)(Wesley Viana)
|
||||
|
||||
另外特别感谢 [**Bernardxu123**](https://github.com/Bernardxu123) 设计的项目 logo
|
||||
(见 [`docs/brand/`](./docs/brand/)),以及 [AIGC Link](https://xhslink.com/m/80ngts127cA) 在小红书上的推广。
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=esengine/DeepSeek-Reasonix&max=100&columns=12" alt="esengine/DeepSeek-Reasonix 贡献者" width="860"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<br/>
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<sub>MIT —— 见 <a href="./LICENSE">LICENSE</a></sub>
|
||||
<br/>
|
||||
<sub>由 <a href="https://github.com/esengine/reasonix/graphs/contributors">esengine/reasonix</a> 社区共建</sub>
|
||||
<sub>由 <a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors">esengine/DeepSeek-Reasonix</a> 社区共建</sub>
|
||||
</p>
|
||||
@@ -0,0 +1 @@
|
||||
runs/
|
||||
@@ -0,0 +1,165 @@
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { resolvePreset } from "../../src/cli/ui/presets.js";
|
||||
import { DeepSeekClient } from "../../src/client.js";
|
||||
import { codeSystemPrompt } from "../../src/code/prompt.js";
|
||||
import { buildCodeToolset } from "../../src/code/setup.js";
|
||||
import { loadApiKey, loadBaseUrl, loadPreset } from "../../src/config.js";
|
||||
import { loadDotenv } from "../../src/env.js";
|
||||
import { CacheFirstLoop } from "../../src/loop.js";
|
||||
import { ImmutablePrefix } from "../../src/memory/runtime.js";
|
||||
import { DEEPSEEK_CONTEXT_TOKENS } from "../../src/telemetry/stats.js";
|
||||
|
||||
interface DriverOptions {
|
||||
taskFile: string;
|
||||
transcriptOut: string;
|
||||
budgetUsd: number;
|
||||
rootDir: string;
|
||||
/** Override DEEPSEEK_CONTEXT_TOKENS to force fold triggers on short tasks. */
|
||||
fakeCtxMax: number | null;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): DriverOptions {
|
||||
let taskFile = "";
|
||||
let transcriptOut = "";
|
||||
let budgetUsd = 2.0;
|
||||
let rootDir = process.cwd();
|
||||
let fakeCtxMax: number | null = null;
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i]!;
|
||||
if (a === "--task-file") taskFile = argv[++i]!;
|
||||
else if (a === "--transcript-out") transcriptOut = argv[++i]!;
|
||||
else if (a === "--budget") budgetUsd = Number(argv[++i]);
|
||||
else if (a === "--root") rootDir = argv[++i]!;
|
||||
else if (a === "--fake-ctxmax") fakeCtxMax = Number(argv[++i]);
|
||||
}
|
||||
if (!taskFile || !transcriptOut) {
|
||||
console.error(
|
||||
"usage: driver.ts --task-file <p> --transcript-out <p> [--budget 2] [--root <dir>] [--fake-ctxmax N]",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
return { taskFile, transcriptOut, budgetUsd, rootDir, fakeCtxMax };
|
||||
}
|
||||
|
||||
interface TurnRecord {
|
||||
turn: number;
|
||||
role: string;
|
||||
prompt_tokens?: number;
|
||||
cache_hit_tokens?: number;
|
||||
cache_miss_tokens?: number;
|
||||
cost_usd?: number;
|
||||
tool?: string;
|
||||
content_preview?: string;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
loadDotenv();
|
||||
const opts = parseArgs(process.argv.slice(2));
|
||||
const key = loadApiKey();
|
||||
if (!key) {
|
||||
console.error("DEEPSEEK_API_KEY missing — run `reasonix setup` first");
|
||||
process.exit(1);
|
||||
}
|
||||
process.env.DEEPSEEK_API_KEY = key;
|
||||
|
||||
const presetSettings = resolvePreset(loadPreset());
|
||||
const model = presetSettings.model;
|
||||
if (opts.fakeCtxMax !== null && opts.fakeCtxMax > 0) {
|
||||
for (const k of Object.keys(DEEPSEEK_CONTEXT_TOKENS)) {
|
||||
DEEPSEEK_CONTEXT_TOKENS[k] = opts.fakeCtxMax;
|
||||
}
|
||||
console.log(`ctxMax override (ALL deepseek models): -> ${opts.fakeCtxMax}`);
|
||||
}
|
||||
console.log(`model: ${model}`);
|
||||
console.log(`root: ${opts.rootDir}`);
|
||||
console.log(`budget: $${opts.budgetUsd}`);
|
||||
|
||||
const task = readFileSync(opts.taskFile, "utf8").trim();
|
||||
console.log(`task: ${task.length} chars`);
|
||||
|
||||
const toolset = await buildCodeToolset({ rootDir: opts.rootDir });
|
||||
console.log(`tools: ${toolset.tools.specs().length} registered`);
|
||||
|
||||
const client = new DeepSeekClient({ baseUrl: loadBaseUrl() });
|
||||
const prefix = new ImmutablePrefix({
|
||||
system: codeSystemPrompt(opts.rootDir),
|
||||
toolSpecs: toolset.tools.specs(),
|
||||
});
|
||||
const loop = new CacheFirstLoop({
|
||||
client,
|
||||
prefix,
|
||||
tools: toolset.tools,
|
||||
model,
|
||||
budgetUsd: opts.budgetUsd,
|
||||
});
|
||||
|
||||
const records: TurnRecord[] = [];
|
||||
const t0 = Date.now();
|
||||
let lastTurn = 0;
|
||||
try {
|
||||
for await (const ev of loop.step(task)) {
|
||||
if (ev.role === "assistant_final" && ev.stats?.usage) {
|
||||
const u = ev.stats.usage;
|
||||
const rec: TurnRecord = {
|
||||
turn: ev.turn,
|
||||
role: "assistant_final",
|
||||
prompt_tokens: u.promptTokens,
|
||||
cache_hit_tokens: u.promptCacheHitTokens,
|
||||
cache_miss_tokens: u.promptCacheMissTokens,
|
||||
cost_usd: ev.stats.cost,
|
||||
content_preview: (ev.content ?? "").slice(0, 200),
|
||||
};
|
||||
records.push(rec);
|
||||
lastTurn = ev.turn;
|
||||
const ratio = u.promptCacheHitTokens / (u.promptTokens || 1);
|
||||
process.stdout.write(
|
||||
` turn ${ev.turn}: pt=${u.promptTokens} hit=${(ratio * 100).toFixed(1)}% cost=$${ev.stats.cost.toFixed(4)}\n`,
|
||||
);
|
||||
} else if (ev.role === "tool" && ev.toolName) {
|
||||
records.push({
|
||||
turn: ev.turn,
|
||||
role: "tool",
|
||||
tool: ev.toolName,
|
||||
content_preview: (ev.content ?? "").slice(0, 200),
|
||||
});
|
||||
} else if (ev.role === "warning" || ev.role === "status") {
|
||||
const lc = (ev.content ?? "").toLowerCase();
|
||||
const isFold = lc.includes("fold") || lc.includes("compact") || lc.includes("summar");
|
||||
records.push({
|
||||
turn: ev.turn,
|
||||
role: isFold ? "fold_event" : `${ev.role}_event`,
|
||||
content_preview: ev.content,
|
||||
});
|
||||
if (isFold) {
|
||||
process.stdout.write(
|
||||
` ⟳ ${ev.role.toUpperCase()} at turn ${ev.turn}: ${ev.content.slice(0, 120)}\n`,
|
||||
);
|
||||
}
|
||||
} else if (ev.role === "error") {
|
||||
records.push({ turn: ev.turn, role: "error", content_preview: ev.error });
|
||||
process.stdout.write(` ✗ error turn ${ev.turn}: ${ev.error}\n`);
|
||||
} else if (ev.role === "done") {
|
||||
process.stdout.write(` ✓ done (turn ${ev.turn})\n`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
process.stdout.write(` ✗ loop threw: ${(err as Error).message}\n`);
|
||||
}
|
||||
|
||||
const elapsed = Math.round((Date.now() - t0) / 1000);
|
||||
const s = loop.stats.summary();
|
||||
console.log(
|
||||
`\nDONE turns=${lastTurn} elapsed=${elapsed}s cache=${(s.cacheHitRatio * 100).toFixed(1)}% cost=$${s.totalCostUsd.toFixed(4)}`,
|
||||
);
|
||||
|
||||
writeFileSync(
|
||||
opts.transcriptOut,
|
||||
`${JSON.stringify({ _meta: { model, task_length: task.length, elapsed_s: elapsed, summary: s } })}\n${records.map((r) => JSON.stringify(r)).join("\n")}\n`,
|
||||
);
|
||||
console.log(`transcript: ${opts.transcriptOut}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("driver crashed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,325 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
interface RunSpec {
|
||||
name: string;
|
||||
foldThreshold: number;
|
||||
aggressiveThreshold: number;
|
||||
}
|
||||
|
||||
interface RunSummary {
|
||||
name: string;
|
||||
repeat: number;
|
||||
turns: number;
|
||||
cacheHitRatio: number;
|
||||
costUsd: number;
|
||||
totalInputTokens: number;
|
||||
cacheHitTokens: number;
|
||||
cacheMissTokens: number;
|
||||
foldCount: number;
|
||||
exitCode: number;
|
||||
}
|
||||
|
||||
const RUNS: RunSpec[] = [
|
||||
{ name: "no-fold", foldThreshold: 0.99, aggressiveThreshold: 0.99 },
|
||||
{ name: "current_50_70", foldThreshold: 0.5, aggressiveThreshold: 0.7 },
|
||||
{ name: "very_late_75_90", foldThreshold: 0.75, aggressiveThreshold: 0.9 },
|
||||
{ name: "aggressive_90_95", foldThreshold: 0.9, aggressiveThreshold: 0.95 },
|
||||
];
|
||||
|
||||
function parseArgs(argv: string[]): {
|
||||
taskFile: string;
|
||||
budget: number;
|
||||
outDir: string;
|
||||
only: string | null;
|
||||
fakeCtxMax: number | null;
|
||||
repeats: number;
|
||||
} {
|
||||
let taskFile = "";
|
||||
let budget = 2.0;
|
||||
let outDir = "";
|
||||
let only: string | null = null;
|
||||
let fakeCtxMax: number | null = null;
|
||||
let repeats = 1;
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i]!;
|
||||
if (a === "--task-file") taskFile = argv[++i]!;
|
||||
else if (a === "--budget") budget = Number(argv[++i]);
|
||||
else if (a === "--out-dir") outDir = argv[++i]!;
|
||||
else if (a === "--only") only = argv[++i]!;
|
||||
else if (a === "--fake-ctxmax") fakeCtxMax = Number(argv[++i]);
|
||||
else if (a === "--repeats") repeats = Number(argv[++i]);
|
||||
}
|
||||
if (!taskFile) {
|
||||
console.error(
|
||||
"usage: tsx e2e.ts --task-file <p> [--budget N] [--out-dir <p>] [--only <n>] [--fake-ctxmax N] [--repeats N]",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!outDir) {
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
||||
outDir = `benchmarks/compression-eval/runs/${ts}`;
|
||||
}
|
||||
return { taskFile, budget, outDir, only, fakeCtxMax, repeats };
|
||||
}
|
||||
|
||||
function loadTranscriptSummary(path: string): Partial<RunSummary> {
|
||||
let turns = 0;
|
||||
let inputTokens = 0;
|
||||
let cacheHit = 0;
|
||||
let cacheMiss = 0;
|
||||
let costUsd = 0;
|
||||
let foldCount = 0;
|
||||
const promptSeq: number[] = [];
|
||||
try {
|
||||
const lines = readFileSync(path, "utf8").split("\n");
|
||||
for (const ln of lines) {
|
||||
const t = ln.trim();
|
||||
if (!t) continue;
|
||||
let rec: {
|
||||
role?: string;
|
||||
prompt_tokens?: number;
|
||||
cache_hit_tokens?: number;
|
||||
cache_miss_tokens?: number;
|
||||
cost_usd?: number;
|
||||
};
|
||||
try {
|
||||
rec = JSON.parse(t);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (rec.role === "assistant_final") {
|
||||
turns++;
|
||||
const pt = rec.prompt_tokens ?? 0;
|
||||
inputTokens += pt;
|
||||
cacheHit += rec.cache_hit_tokens ?? 0;
|
||||
cacheMiss += rec.cache_miss_tokens ?? 0;
|
||||
costUsd += rec.cost_usd ?? 0;
|
||||
if (pt > 0) promptSeq.push(pt);
|
||||
} else if (rec.role === "fold_event") {
|
||||
foldCount++;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* file missing — caller will see exitCode and zeros */
|
||||
}
|
||||
if (foldCount === 0) {
|
||||
for (let i = 1; i < promptSeq.length; i++) {
|
||||
if (promptSeq[i]! < promptSeq[i - 1]! * 0.6) foldCount++;
|
||||
}
|
||||
}
|
||||
return {
|
||||
turns,
|
||||
totalInputTokens: inputTokens,
|
||||
cacheHitTokens: cacheHit,
|
||||
cacheMissTokens: cacheMiss,
|
||||
cacheHitRatio: inputTokens > 0 ? cacheHit / inputTokens : 0,
|
||||
costUsd,
|
||||
foldCount,
|
||||
};
|
||||
}
|
||||
|
||||
function runOne(
|
||||
spec: RunSpec,
|
||||
task: string,
|
||||
budget: number,
|
||||
outDir: string,
|
||||
fakeCtxMax: number | null,
|
||||
repeat: number,
|
||||
): RunSummary {
|
||||
const runName = repeat > 1 ? `${spec.name}_r${repeat}` : spec.name;
|
||||
const runDir = join(outDir, runName);
|
||||
mkdirSync(runDir, { recursive: true });
|
||||
const transcript = join(runDir, "transcript.jsonl");
|
||||
const logPath = join(runDir, "stdout.log");
|
||||
|
||||
console.log(
|
||||
`\n>>> [${spec.name}] fold=${spec.foldThreshold} agg=${spec.aggressiveThreshold}`,
|
||||
);
|
||||
const tsxCli = join("node_modules", "tsx", "dist", "cli.mjs");
|
||||
const taskFilePath = join(runDir, "task.txt");
|
||||
writeFileSync(taskFilePath, task);
|
||||
const t0 = Date.now();
|
||||
const res = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
tsxCli,
|
||||
"benchmarks/compression-eval/driver.ts",
|
||||
"--task-file",
|
||||
taskFilePath,
|
||||
"--transcript-out",
|
||||
transcript,
|
||||
"--budget",
|
||||
String(budget),
|
||||
...(fakeCtxMax ? ["--fake-ctxmax", String(fakeCtxMax)] : []),
|
||||
],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
REASONIX_FOLD_THRESHOLD: String(spec.foldThreshold),
|
||||
REASONIX_FOLD_AGGRESSIVE_THRESHOLD: String(spec.aggressiveThreshold),
|
||||
},
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
shell: false,
|
||||
maxBuffer: 1024 * 1024 * 128,
|
||||
},
|
||||
);
|
||||
const exitCode = res.status ?? -1;
|
||||
const stdout = res.stdout ?? "";
|
||||
const stderr = res.stderr ?? "";
|
||||
const elapsedS = Math.round((Date.now() - t0) / 1000);
|
||||
writeFileSync(logPath, `${stdout}\n--- stderr ---\n${stderr}`);
|
||||
console.log(` exit=${exitCode} elapsed=${elapsedS}s log=${logPath}`);
|
||||
|
||||
const parsed = loadTranscriptSummary(transcript);
|
||||
return {
|
||||
name: spec.name,
|
||||
repeat,
|
||||
turns: parsed.turns ?? 0,
|
||||
cacheHitRatio: parsed.cacheHitRatio ?? 0,
|
||||
costUsd: parsed.costUsd ?? 0,
|
||||
totalInputTokens: parsed.totalInputTokens ?? 0,
|
||||
cacheHitTokens: parsed.cacheHitTokens ?? 0,
|
||||
cacheMissTokens: parsed.cacheMissTokens ?? 0,
|
||||
foldCount: parsed.foldCount ?? 0,
|
||||
exitCode,
|
||||
};
|
||||
}
|
||||
|
||||
function pct(n: number): string {
|
||||
return `${(n * 100).toFixed(1)}%`;
|
||||
}
|
||||
function fmtTok(n: number): string {
|
||||
if (n >= 1e6) return `${(n / 1e6).toFixed(2)}M`;
|
||||
if (n >= 1e3) return `${(n / 1e3).toFixed(1)}k`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
interface AggregateRow {
|
||||
name: string;
|
||||
n: number;
|
||||
meanCost: number;
|
||||
stdCost: number;
|
||||
meanMiss: number;
|
||||
stdMiss: number;
|
||||
meanHitPct: number;
|
||||
stdHitPct: number;
|
||||
meanTurns: number;
|
||||
meanFolds: number;
|
||||
}
|
||||
|
||||
function meanStd(xs: number[]): [number, number] {
|
||||
if (xs.length === 0) return [0, 0];
|
||||
const m = xs.reduce((a, b) => a + b, 0) / xs.length;
|
||||
const v = xs.reduce((a, b) => a + (b - m) ** 2, 0) / xs.length;
|
||||
return [m, Math.sqrt(v)];
|
||||
}
|
||||
|
||||
function aggregateByConfig(rows: RunSummary[]): AggregateRow[] {
|
||||
const groups = new Map<string, RunSummary[]>();
|
||||
for (const r of rows) {
|
||||
if (!groups.has(r.name)) groups.set(r.name, []);
|
||||
groups.get(r.name)!.push(r);
|
||||
}
|
||||
const result: AggregateRow[] = [];
|
||||
for (const [name, group] of groups) {
|
||||
const [meanCost, stdCost] = meanStd(group.map((r) => r.costUsd));
|
||||
const [meanMiss, stdMiss] = meanStd(group.map((r) => r.cacheMissTokens));
|
||||
const [meanHitPct, stdHitPct] = meanStd(group.map((r) => r.cacheHitRatio * 100));
|
||||
const [meanTurns] = meanStd(group.map((r) => r.turns));
|
||||
const [meanFolds] = meanStd(group.map((r) => r.foldCount));
|
||||
result.push({
|
||||
name,
|
||||
n: group.length,
|
||||
meanCost,
|
||||
stdCost,
|
||||
meanMiss,
|
||||
stdMiss,
|
||||
meanHitPct,
|
||||
stdHitPct,
|
||||
meanTurns,
|
||||
meanFolds,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function reportAggregate(rows: AggregateRow[]): string {
|
||||
const cols: Array<[string, (r: AggregateRow) => string]> = [
|
||||
["config", (r) => r.name],
|
||||
["n", (r) => String(r.n)],
|
||||
["turns", (r) => r.meanTurns.toFixed(1)],
|
||||
["folds", (r) => r.meanFolds.toFixed(1)],
|
||||
["hit%", (r) => `${r.meanHitPct.toFixed(1)}±${r.stdHitPct.toFixed(1)}`],
|
||||
["miss", (r) => `${fmtTok(r.meanMiss)}±${fmtTok(r.stdMiss)}`],
|
||||
["cost$", (r) => `${r.meanCost.toFixed(4)}±${r.stdCost.toFixed(4)}`],
|
||||
];
|
||||
const widths = cols.map(([h, f]) => Math.max(h.length, ...rows.map((r) => f(r).length)));
|
||||
const head = cols.map(([h], i) => h.padEnd(widths[i]!)).join("│ ");
|
||||
const sep = "─".repeat(widths.reduce((a, b) => a + b + 2, 0));
|
||||
const body = rows
|
||||
.map((r) => cols.map(([_, f], i) => f(r).padEnd(widths[i]!)).join("│ "))
|
||||
.join("\n");
|
||||
return `${head}\n${sep}\n${body}`;
|
||||
}
|
||||
|
||||
function reportTable(rows: RunSummary[]): string {
|
||||
const cols: Array<[string, (r: RunSummary) => string]> = [
|
||||
["config", (r) => r.name],
|
||||
["turns", (r) => String(r.turns)],
|
||||
["input", (r) => fmtTok(r.totalInputTokens)],
|
||||
["hit", (r) => fmtTok(r.cacheHitTokens)],
|
||||
["hit%", (r) => pct(r.cacheHitRatio)],
|
||||
["miss", (r) => fmtTok(r.cacheMissTokens)],
|
||||
["folds", (r) => String(r.foldCount)],
|
||||
["cost", (r) => `$${r.costUsd.toFixed(4)}`],
|
||||
["exit", (r) => String(r.exitCode)],
|
||||
];
|
||||
const widths = cols.map(([h, f]) => Math.max(h.length, ...rows.map((r) => f(r).length)));
|
||||
const head = cols.map(([h], i) => h.padEnd(widths[i]!)).join("│ ");
|
||||
const sep = "─".repeat(widths.reduce((a, b) => a + b + 2, 0));
|
||||
const body = rows
|
||||
.map((r) => cols.map(([_, f], i) => f(r).padEnd(widths[i]!)).join("│ "))
|
||||
.join("\n");
|
||||
return `${head}\n${sep}\n${body}`;
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const { taskFile, budget, outDir, only, fakeCtxMax, repeats } = parseArgs(process.argv.slice(2));
|
||||
const task = readFileSync(taskFile, "utf8").trim();
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
const specs = only ? RUNS.filter((r) => r.name === only) : RUNS;
|
||||
if (specs.length === 0) {
|
||||
console.error(`no config matches --only=${only}; available: ${RUNS.map((r) => r.name).join(", ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`task: ${taskFile} (${task.length} chars)`);
|
||||
console.log(`out: ${outDir}`);
|
||||
console.log(`budget per run: $${budget}`);
|
||||
console.log(`configs: ${specs.map((s) => s.name).join(", ")}`);
|
||||
console.log(`repeats: ${repeats}`);
|
||||
if (fakeCtxMax) console.log(`fake ctxMax: ${fakeCtxMax}`);
|
||||
|
||||
const summaries: RunSummary[] = [];
|
||||
for (let r = 1; r <= repeats; r++) {
|
||||
for (const spec of specs) {
|
||||
summaries.push(runOne(spec, task, budget, outDir, fakeCtxMax, r));
|
||||
}
|
||||
}
|
||||
|
||||
const table = reportTable(summaries);
|
||||
console.log(`\n=== Raw runs ===\n${table}`);
|
||||
if (repeats > 1) {
|
||||
const agg = aggregateByConfig(summaries);
|
||||
const aggTable = reportAggregate(agg);
|
||||
console.log(`\n=== Aggregate (mean ± std) ===\n${aggTable}`);
|
||||
writeFileSync(join(outDir, "aggregate.txt"), aggTable);
|
||||
}
|
||||
writeFileSync(join(outDir, "summary.json"), JSON.stringify(summaries, null, 2));
|
||||
writeFileSync(join(outDir, "summary.txt"), table);
|
||||
console.log(`\nsaved: ${join(outDir, "summary.json")}`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,89 @@
|
||||
import { basename } from "node:path";
|
||||
import { loadSession, simulate, type RunResult, type ThresholdConfig } from "./simulator.js";
|
||||
|
||||
const BASELINE: ThresholdConfig = {
|
||||
ctxMax: 1_000_000,
|
||||
foldThreshold: 0.5,
|
||||
tailFraction: 0.2,
|
||||
aggressiveThreshold: 0.7,
|
||||
aggressiveTailFraction: 0.1,
|
||||
minSavingsFraction: 0.3,
|
||||
summaryRatio: 0.05,
|
||||
};
|
||||
|
||||
interface NamedConfig {
|
||||
name: string;
|
||||
config: ThresholdConfig;
|
||||
}
|
||||
|
||||
function configs(ctxMax: number): NamedConfig[] {
|
||||
const mk = (over: Partial<ThresholdConfig>): ThresholdConfig => ({ ...BASELINE, ...over, ctxMax });
|
||||
return [
|
||||
{ name: "no-fold", config: mk({ foldThreshold: 999, aggressiveThreshold: 999 }) },
|
||||
{ name: "current (50/70)", config: mk({ foldThreshold: 0.5, aggressiveThreshold: 0.7 }) },
|
||||
{ name: "late (60/80)", config: mk({ foldThreshold: 0.6, aggressiveThreshold: 0.8 }) },
|
||||
{ name: "very-late (75/90)", config: mk({ foldThreshold: 0.75, aggressiveThreshold: 0.9 }) },
|
||||
{ name: "early (35/55)", config: mk({ foldThreshold: 0.35, aggressiveThreshold: 0.55 }) },
|
||||
];
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): { sessions: string[]; ctxMax: number } {
|
||||
const sessions: string[] = [];
|
||||
let ctxMax = 1_000_000;
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i]!;
|
||||
if (a === "--ctx-max") ctxMax = Number(argv[++i]);
|
||||
else if (a === "--session") sessions.push(argv[++i]!);
|
||||
else if (!a.startsWith("--")) sessions.push(a);
|
||||
}
|
||||
return { sessions, ctxMax };
|
||||
}
|
||||
|
||||
function fmt(n: number): string {
|
||||
if (n >= 1e9) return `${(n / 1e9).toFixed(2)}G`;
|
||||
if (n >= 1e6) return `${(n / 1e6).toFixed(2)}M`;
|
||||
if (n >= 1e3) return `${(n / 1e3).toFixed(1)}k`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
function pct(n: number): string {
|
||||
return `${(n * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function report(label: string, rows: Array<{ name: string; result: RunResult }>): void {
|
||||
console.log(`\n=== ${label} ===`);
|
||||
const w = [22, 10, 10, 8, 10, 6, 10];
|
||||
const headers = ["config", "input", "cache_hit", "hit%", "miss", "folds", "peak"];
|
||||
console.log(headers.map((h, i) => h.padEnd(w[i]!)).join("│ "));
|
||||
console.log("─".repeat(w.reduce((a, b) => a + b + 2, 0)));
|
||||
for (const { name, result: r } of rows) {
|
||||
const peak = r.perTurn.reduce((m, t) => Math.max(m, t.ratio), 0);
|
||||
console.log(
|
||||
[
|
||||
name.padEnd(w[0]!),
|
||||
fmt(r.totalInputTokens).padEnd(w[1]!),
|
||||
fmt(r.totalCacheHitTokens).padEnd(w[2]!),
|
||||
pct(r.cacheHitRatio).padEnd(w[3]!),
|
||||
fmt(r.totalCacheMissTokens).padEnd(w[4]!),
|
||||
String(r.foldCount).padEnd(w[5]!),
|
||||
pct(peak).padEnd(w[6]!),
|
||||
].join("│ "),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const { sessions, ctxMax } = parseArgs(process.argv.slice(2));
|
||||
if (sessions.length === 0) {
|
||||
console.error("usage: tsx run.ts [--ctx-max N] <session.jsonl> [more...]");
|
||||
process.exit(1);
|
||||
}
|
||||
for (const path of sessions) {
|
||||
const msgs = loadSession(path);
|
||||
const cfgs = configs(ctxMax);
|
||||
const rows = cfgs.map(({ name, config }) => ({ name, result: simulate(msgs, config, path) }));
|
||||
report(`${basename(path)} (${msgs.length} msgs, ctxMax=${fmt(ctxMax)})`, rows);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,197 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import {
|
||||
countTokensBounded,
|
||||
estimateConversationTokens,
|
||||
estimateRequestTokens,
|
||||
} from "../../src/tokenizer.js";
|
||||
import type { ChatMessage } from "../../src/types.js";
|
||||
|
||||
export interface ThresholdConfig {
|
||||
ctxMax: number;
|
||||
foldThreshold: number;
|
||||
tailFraction: number;
|
||||
aggressiveThreshold: number;
|
||||
aggressiveTailFraction: number;
|
||||
minSavingsFraction: number;
|
||||
summaryRatio: number;
|
||||
}
|
||||
|
||||
export interface FoldEvent {
|
||||
turn: number;
|
||||
ratioBeforeFold: number;
|
||||
promptTokensBefore: number;
|
||||
headMessages: number;
|
||||
tailMessages: number;
|
||||
headTokens: number;
|
||||
summaryTokens: number;
|
||||
aggressive: boolean;
|
||||
}
|
||||
|
||||
export interface TurnRecord {
|
||||
turn: number;
|
||||
promptTokens: number;
|
||||
cacheHitTokens: number;
|
||||
cacheMissTokens: number;
|
||||
ratio: number;
|
||||
folded: boolean;
|
||||
}
|
||||
|
||||
export interface RunResult {
|
||||
config: ThresholdConfig;
|
||||
sessionPath: string;
|
||||
totalTurns: number;
|
||||
totalInputTokens: number;
|
||||
totalCacheHitTokens: number;
|
||||
totalCacheMissTokens: number;
|
||||
cacheHitRatio: number;
|
||||
foldCount: number;
|
||||
folds: FoldEvent[];
|
||||
perTurn: TurnRecord[];
|
||||
}
|
||||
|
||||
export function loadSession(path: string): ChatMessage[] {
|
||||
const lines = readFileSync(path, "utf8").split("\n");
|
||||
const out: ChatMessage[] = [];
|
||||
for (const ln of lines) {
|
||||
const t = ln.trim();
|
||||
if (!t) continue;
|
||||
try {
|
||||
out.push(JSON.parse(t) as ChatMessage);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function messageTokens(m: ChatMessage): number {
|
||||
return estimateConversationTokens([m], true);
|
||||
}
|
||||
|
||||
function messageKey(m: ChatMessage): string {
|
||||
return JSON.stringify({
|
||||
r: m.role,
|
||||
c: m.content ?? "",
|
||||
tc: m.tool_calls ?? null,
|
||||
tcid: m.tool_call_id ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
function commonPrefixTokens(curr: ChatMessage[], prev: ChatMessage[]): number {
|
||||
const n = Math.min(curr.length, prev.length);
|
||||
let tokens = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (messageKey(curr[i]!) !== messageKey(prev[i]!)) break;
|
||||
tokens += messageTokens(curr[i]!);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function findFoldBoundary(log: ChatMessage[], tailBudget: number): number {
|
||||
let cum = 0;
|
||||
let boundary = log.length;
|
||||
for (let i = log.length - 1; i >= 0; i--) {
|
||||
const t = messageTokens(log[i]!);
|
||||
if (cum + t > tailBudget) break;
|
||||
cum += t;
|
||||
if (log[i]!.role === "user") boundary = i;
|
||||
}
|
||||
return boundary;
|
||||
}
|
||||
|
||||
function synthesizeSummary(head: ChatMessage[], targetTokens: number): ChatMessage {
|
||||
const userBits: string[] = [];
|
||||
for (const m of head) {
|
||||
if (m.role === "user" && typeof m.content === "string") userBits.push(m.content);
|
||||
}
|
||||
const seed = userBits.join("\n") || "earlier turns folded";
|
||||
let text = `[CONVERSATION HISTORY SUMMARY — fold of ${head.length} messages]\n\n${seed}`;
|
||||
const currentTokens = countTokensBounded(text);
|
||||
if (currentTokens > targetTokens) {
|
||||
const ratio = targetTokens / currentTokens;
|
||||
text = text.slice(0, Math.max(80, Math.floor(text.length * ratio)));
|
||||
}
|
||||
return { role: "assistant", content: text, reasoning_content: "" };
|
||||
}
|
||||
|
||||
function turnBoundaries(messages: ChatMessage[]): number[] {
|
||||
const ends: number[] = [];
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
if (messages[i]!.role === "assistant") ends.push(i);
|
||||
}
|
||||
return ends;
|
||||
}
|
||||
|
||||
export function simulate(messages: ChatMessage[], config: ThresholdConfig, sessionPath: string): RunResult {
|
||||
const ends = turnBoundaries(messages);
|
||||
const log: ChatMessage[] = [];
|
||||
let prevSnapshot: ChatMessage[] = [];
|
||||
const perTurn: TurnRecord[] = [];
|
||||
const folds: FoldEvent[] = [];
|
||||
let totalInput = 0;
|
||||
let totalHit = 0;
|
||||
let totalMiss = 0;
|
||||
let cursor = 0;
|
||||
|
||||
for (let turn = 0; turn < ends.length; turn++) {
|
||||
const end = ends[turn]!;
|
||||
for (let i = cursor; i < end; i++) log.push(messages[i]!);
|
||||
cursor = end;
|
||||
|
||||
const promptTokens = estimateRequestTokens(log, null, true);
|
||||
const ratio = promptTokens / config.ctxMax;
|
||||
const hit = commonPrefixTokens(log, prevSnapshot);
|
||||
const miss = Math.max(0, promptTokens - hit);
|
||||
let folded = false;
|
||||
|
||||
if (ratio > config.foldThreshold) {
|
||||
const aggressive = ratio > config.aggressiveThreshold;
|
||||
const tailFrac = aggressive ? config.aggressiveTailFraction : config.tailFraction;
|
||||
const tailBudget = Math.floor(config.ctxMax * tailFrac);
|
||||
const boundary = findFoldBoundary(log, tailBudget);
|
||||
if (boundary > 0) {
|
||||
const head = log.slice(0, boundary);
|
||||
const tail = log.slice(boundary);
|
||||
const headTokens = head.reduce((a, m) => a + messageTokens(m), 0);
|
||||
const totalTokens = log.reduce((a, m) => a + messageTokens(m), 0);
|
||||
if (headTokens >= totalTokens * config.minSavingsFraction) {
|
||||
const summaryTokens = Math.max(64, Math.floor(headTokens * config.summaryRatio));
|
||||
const summary = synthesizeSummary(head, summaryTokens);
|
||||
const next = [summary, ...tail];
|
||||
log.length = 0;
|
||||
for (const m of next) log.push(m);
|
||||
folds.push({
|
||||
turn,
|
||||
ratioBeforeFold: ratio,
|
||||
promptTokensBefore: promptTokens,
|
||||
headMessages: head.length,
|
||||
tailMessages: tail.length,
|
||||
headTokens,
|
||||
summaryTokens,
|
||||
aggressive,
|
||||
});
|
||||
folded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
totalInput += promptTokens;
|
||||
totalHit += hit;
|
||||
totalMiss += miss;
|
||||
perTurn.push({ turn, promptTokens, cacheHitTokens: hit, cacheMissTokens: miss, ratio, folded });
|
||||
prevSnapshot = log.slice();
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
sessionPath,
|
||||
totalTurns: ends.length,
|
||||
totalInputTokens: totalInput,
|
||||
totalCacheHitTokens: totalHit,
|
||||
totalCacheMissTokens: totalMiss,
|
||||
cacheHitRatio: totalInput > 0 ? totalHit / totalInput : 0,
|
||||
foldCount: folds.length,
|
||||
folds,
|
||||
perTurn,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
为这个仓库的 src/ 目录写一份完整的架构总览文档。
|
||||
|
||||
要求:
|
||||
1. 用 read_file / directory_tree / grep_search 等工具,**逐个**读取 src/ 下每个子目录(cli/, mcp/, memory/, telemetry/, transcript/, tools/, ui/, i18n/, loop/ 等)的代表性文件。每个子目录至少读 3-5 个核心 .ts 文件。
|
||||
2. 不要只 grep 关键词就下结论 —— 必须打开文件读真实内容,从代码里抽出"这个模块的职责 / 它的核心数据结构 / 它对外暴露什么"。
|
||||
3. 边读边在心里搭建依赖图。
|
||||
4. 全部读完后,输出一份 markdown 报告,包含:
|
||||
- 整体架构图(mermaid 或 ASCII,模块之间的依赖关系)
|
||||
- 按模块列出:职责 / 关键文件 / 对外接口 / 内部依赖
|
||||
- 至少 8 个具体的代码引用(路径:行号),证明你真读了
|
||||
- 结尾给出"如果新人加入项目,应该按什么顺序读代码"的建议
|
||||
|
||||
这是一个真实的代码审计任务,请不要偷懒、不要瞎编、不要用通用模板。文件读得不够多就直接说"我读得不够"。
|
||||
@@ -304,6 +304,16 @@ export const en = {
|
||||
loadingTranscript: "loading transcript…",
|
||||
emptyTranscript: "empty transcript.",
|
||||
messages: "{count} message{s}",
|
||||
newBtn: "New session",
|
||||
newHint: "Archive the current conversation and start a fresh one",
|
||||
switchBtn: "Switch to this session",
|
||||
deleteBtn: "Delete",
|
||||
deleteConfirm: "Delete session \"{name}\"? This removes the transcript file and cannot be undone.",
|
||||
cantDeleteActive: "Switch to a different session before deleting this one.",
|
||||
attachRequired:
|
||||
"Live session operations need an attached CLI session. Launch via reasonix chat or open the dashboard from inside a TUI session.",
|
||||
activeChip: "active",
|
||||
activePill: "active",
|
||||
},
|
||||
tools: {
|
||||
loading: "loading tools…",
|
||||
@@ -431,6 +441,10 @@ export const en = {
|
||||
whyUnbridgedDesc:
|
||||
"This spec lives in your config.json but isn't bridged into the live session. MCP servers attach when reasonix code starts; the dashboard alone can't spawn the child process.",
|
||||
whyUnbridgedHint: "To activate: restart reasonix code, then refresh this dashboard.",
|
||||
bridgeFailed: "Bridge failed",
|
||||
bridgeFailedTitle: "bridge failed · in config",
|
||||
bridgeFailedHint:
|
||||
"Reasonix tried to bridge this server and the attempt errored. Common causes: wrong URL, missing auth header, upstream 404/5xx, missing local command. Fix and restart reasonix code to retry.",
|
||||
pickHint: "Pick an MCP server on the left to inspect tools / resources / prompts.",
|
||||
toolsTitle: "Tools · {count}",
|
||||
resourcesTitle: "Resources · {count}",
|
||||
@@ -614,6 +628,7 @@ export const en = {
|
||||
customRequestBodyMustBeObject: "Custom request body must be a JSON object.",
|
||||
saveBeforeIndex: "Save semantic settings before starting an index.",
|
||||
extraBody: "extra body",
|
||||
batchSize: "batch size",
|
||||
keepExistingKey: "leave blank to keep existing key",
|
||||
remoteProvider: "Remote embedding provider",
|
||||
remoteProviderDesc:
|
||||
|
||||
@@ -300,6 +300,16 @@ export const zhCN = {
|
||||
loadingTranscript: "加载转录稿…",
|
||||
emptyTranscript: "空的转录稿。",
|
||||
messages: "{count} 条消息",
|
||||
newBtn: "新建会话",
|
||||
newHint: "归档当前会话并开启新会话",
|
||||
switchBtn: "切换到此会话",
|
||||
deleteBtn: "删除",
|
||||
deleteConfirm: "确定删除会话「{name}」?转录文件将被移除,无法撤销。",
|
||||
cantDeleteActive: "请先切换到其他会话,再删除当前会话。",
|
||||
attachRequired:
|
||||
"实时会话操作需要已连接的 CLI 会话。请通过 reasonix chat 启动,或在 TUI 会话中打开仪表盘。",
|
||||
activeChip: "当前",
|
||||
activePill: "当前",
|
||||
},
|
||||
tools: {
|
||||
loading: "加载工具…",
|
||||
@@ -407,6 +417,10 @@ export const zhCN = {
|
||||
whyUnbridgedDesc:
|
||||
"此规格存在于您的 config.json 中,但未桥接到实时会话。MCP 服务器在 reasonix code 启动时连接;仪表盘本身无法生成子进程。",
|
||||
whyUnbridgedHint: "激活方法:重启 reasonix code,然后刷新此仪表盘。",
|
||||
bridgeFailed: "桥接失败",
|
||||
bridgeFailedTitle: "桥接失败 · 在配置中",
|
||||
bridgeFailedHint:
|
||||
"已尝试桥接但失败。常见原因:URL 错误、需要鉴权、上游 404 / 5xx、本地命令缺失。修复后重启 reasonix code 重试。",
|
||||
pickHint: "选择左侧的 MCP 服务器以检查工具 / 资源 / 提示。",
|
||||
toolsTitle: "工具 · {count}",
|
||||
resourcesTitle: "资源 · {count}",
|
||||
@@ -587,6 +601,7 @@ export const zhCN = {
|
||||
customRequestBodyMustBeObject: "自定义请求体必须是 JSON 对象。",
|
||||
saveBeforeIndex: "请先保存语义设置,再启动索引。",
|
||||
extraBody: "扩展请求体",
|
||||
batchSize: "批次大小",
|
||||
keepExistingKey: "留空则保留现有 Key",
|
||||
remoteProvider: "远程向量服务",
|
||||
remoteProviderDesc:
|
||||
|
||||
@@ -85,10 +85,18 @@ interface RegistryListResponse {
|
||||
|
||||
type McpFilter = "all" | "live" | "unbridged" | "marketplace";
|
||||
|
||||
interface McpFailure {
|
||||
spec: string;
|
||||
name: string;
|
||||
reason: string;
|
||||
at: number;
|
||||
}
|
||||
|
||||
export function McpPanel() {
|
||||
useLang();
|
||||
const [data, setData] = useState<McpData | null>(null);
|
||||
const [specs, setSpecs] = useState<string[] | null>(null);
|
||||
const [failures, setFailures] = useState<McpFailure[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [info, setInfo] = useState<string | null>(null);
|
||||
const [newSpec, setNewSpec] = useState("");
|
||||
@@ -171,11 +179,27 @@ export function McpPanel() {
|
||||
spec: normalizeMcpSpec(server.spec) ?? "",
|
||||
})),
|
||||
});
|
||||
const specResponse = await api<{ specs?: unknown[] }>("/mcp/specs");
|
||||
const specResponse = await api<{ specs?: unknown[]; failures?: unknown[] }>("/mcp/specs");
|
||||
const normalized = (Array.isArray(specResponse.specs) ? specResponse.specs : [])
|
||||
.map(normalizeMcpSpec)
|
||||
.filter((spec): spec is string => spec !== null && spec.length > 0);
|
||||
setSpecs(normalized);
|
||||
const rawFailures = Array.isArray(specResponse.failures) ? specResponse.failures : [];
|
||||
const validFailures: McpFailure[] = [];
|
||||
for (const f of rawFailures) {
|
||||
if (typeof f !== "object" || f === null) continue;
|
||||
const o = f as Record<string, unknown>;
|
||||
const rawSpec = typeof o.spec === "string" ? o.spec : "";
|
||||
const norm = normalizeMcpSpec(rawSpec);
|
||||
if (!norm) continue;
|
||||
validFailures.push({
|
||||
spec: norm,
|
||||
name: typeof o.name === "string" ? o.name : "",
|
||||
reason: typeof o.reason === "string" ? o.reason : "",
|
||||
at: typeof o.at === "number" ? o.at : 0,
|
||||
});
|
||||
}
|
||||
setFailures(validFailures);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
@@ -341,8 +365,9 @@ export function McpPanel() {
|
||||
}
|
||||
${
|
||||
showUnbridged
|
||||
? unbridgedSpecs.map(
|
||||
(spec) => html`
|
||||
? unbridgedSpecs.map((spec) => {
|
||||
const failure = failures.find((f) => f.spec === spec);
|
||||
return html`
|
||||
<div
|
||||
class=${`ssl-row ${openUnbridged === spec ? "sel" : ""}`}
|
||||
onClick=${() => {
|
||||
@@ -350,12 +375,12 @@ export function McpPanel() {
|
||||
setOpen(null);
|
||||
}}
|
||||
>
|
||||
<span class="name">${specLabel(spec)} <span class="pill">${t("mcp.unbridged")}</span></span>
|
||||
<span class="name">${specLabel(spec)} <span class=${`pill ${failure ? "err" : ""}`}>${failure ? t("mcp.bridgeFailed") : t("mcp.unbridged")}</span></span>
|
||||
<span class="preview">${specCommand(spec)}</span>
|
||||
<span class="meta"><span class="dim">${t("mcp.inConfig")}</span></span>
|
||||
<span class="meta"><span class=${failure ? "" : "dim"} style=${failure ? "color:var(--c-err)" : ""}>${failure ? failure.reason : t("mcp.inConfig")}</span></span>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
`;
|
||||
})
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
@@ -376,10 +401,12 @@ export function McpPanel() {
|
||||
onClose: () => setOpenRegistry(null),
|
||||
})
|
||||
: openUnbridged != null
|
||||
? html`
|
||||
? (() => {
|
||||
const failure = failures.find((f) => f.spec === openUnbridged);
|
||||
return html`
|
||||
<div class="sessions-detail-h">
|
||||
<span class="name">${specLabel(openUnbridged)}</span>
|
||||
<span class="ws"><span class="pill">${t("mcp.unbridgedTitle")}</span></span>
|
||||
<span class="ws"><span class="pill">${failure ? t("mcp.bridgeFailedTitle") : t("mcp.unbridgedTitle")}</span></span>
|
||||
<span class="actions">
|
||||
<button class="btn" disabled=${busy} onClick=${() => removeSpec(openUnbridged)}
|
||||
style="border-color:var(--c-err);color:var(--c-err)">${t("mcp.removeBtn")}</button>
|
||||
@@ -390,16 +417,29 @@ export function McpPanel() {
|
||||
<div class="card-h"><span class="title">${t("mcp.spec")}</span></div>
|
||||
<code class="mono" style="font-size:11.5px;color:var(--fg-2);word-break:break-all">${openUnbridged}</code>
|
||||
</div>
|
||||
<div class="card accent-warn">
|
||||
<div class="card-h"><span class="title" style="color:var(--c-warn)">${t("mcp.whyUnbridged")}</span></div>
|
||||
<div class="card-b" style="font-size:13px;line-height:1.6">
|
||||
${t("mcp.whyUnbridgedDesc")}
|
||||
<div style="margin-top:10px;color:var(--fg-3);font-size:12px">
|
||||
${t("mcp.whyUnbridgedHint")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
${
|
||||
failure
|
||||
? html`<div class="card accent-err">
|
||||
<div class="card-h"><span class="title" style="color:var(--c-err)">${t("mcp.bridgeFailed")}</span></div>
|
||||
<div class="card-b" style="font-size:13px;line-height:1.6">
|
||||
<code class="mono" style="font-size:12px;color:var(--fg-1);word-break:break-word;white-space:pre-wrap">${failure.reason}</code>
|
||||
<div style="margin-top:10px;color:var(--fg-3);font-size:12px">
|
||||
${t("mcp.bridgeFailedHint")}
|
||||
</div>
|
||||
</div>
|
||||
</div>`
|
||||
: html`<div class="card accent-warn">
|
||||
<div class="card-h"><span class="title" style="color:var(--c-warn)">${t("mcp.whyUnbridged")}</span></div>
|
||||
<div class="card-b" style="font-size:13px;line-height:1.6">
|
||||
${t("mcp.whyUnbridgedDesc")}
|
||||
<div style="margin-top:10px;color:var(--fg-3);font-size:12px">
|
||||
${t("mcp.whyUnbridgedHint")}
|
||||
</div>
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
`;
|
||||
})()
|
||||
: open == null
|
||||
? html`<div style="color:var(--fg-3);font-size:13px;text-align:center;padding:60px 20px">
|
||||
${showMarketplace ? t("mcp.marketplacePickHint") : t("mcp.pickHint")}
|
||||
|
||||
@@ -16,6 +16,7 @@ interface SemanticConfigView {
|
||||
apiKeySet: boolean;
|
||||
model: string;
|
||||
extraBody: Record<string, unknown>;
|
||||
batchSize: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -44,6 +45,7 @@ interface SemanticData {
|
||||
apiKeySet: boolean;
|
||||
model: string;
|
||||
extraBodyKeys: string[];
|
||||
batchSize: number;
|
||||
};
|
||||
index?: IndexInfo;
|
||||
job?: SemanticJob | null;
|
||||
@@ -106,6 +108,7 @@ interface SemanticConfigDraft {
|
||||
apiKey: string;
|
||||
model: string;
|
||||
extraBodyText: string;
|
||||
batchSize: number;
|
||||
apiKeySet: boolean;
|
||||
};
|
||||
}
|
||||
@@ -243,6 +246,7 @@ export function SemanticPanel() {
|
||||
apiKey: draft.openaiCompat.apiKey,
|
||||
model: draft.openaiCompat.model,
|
||||
extraBody,
|
||||
batchSize: draft.openaiCompat.batchSize,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -414,6 +418,27 @@ export function SemanticPanel() {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<span class="lbl">${t("semantic.batchSize")}</span>
|
||||
<input
|
||||
class="input mono"
|
||||
type="number"
|
||||
min="1"
|
||||
value=${draft.openaiCompat.batchSize}
|
||||
onInput=${(e: Event) => {
|
||||
const v = Number.parseInt((e.target as HTMLInputElement).value, 10);
|
||||
draftDirtyRef.current = true;
|
||||
setDraftDirty(true);
|
||||
setDraft({
|
||||
...draft,
|
||||
openaiCompat: {
|
||||
...draft.openaiCompat,
|
||||
batchSize: Number.isInteger(v) && v > 0 ? v : 10,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<details style="margin-top:10px">
|
||||
<summary style="cursor:pointer;color:var(--fg-2);font-size:12px">${t("semantic.customRequestBody")}</summary>
|
||||
<div class="form-row" style="margin-top:10px">
|
||||
@@ -600,6 +625,7 @@ export function SemanticPanel() {
|
||||
<div class="rail-kv"><span class="k">${t("semantic.apiKey")}</span><span class="v">${remote?.apiKeySet ? html`<span class="pill ok">${t("semantic.found")}</span>` : html`<span class="pill warn">${t("semantic.missing")}</span>`}</span></div>
|
||||
<div class="rail-kv"><span class="k">${t("semantic.model")}</span><span class="v" style="font-size:11px">${remote?.model ?? draft.openaiCompat.model}</span></div>
|
||||
<div class="rail-kv"><span class="k">${t("semantic.extraBody")}</span><span class="v">${fmtNum(remote?.extraBodyKeys.length ?? 0)}</span></div>
|
||||
<div class="rail-kv"><span class="k">${t("semantic.batchSize")}</span><span class="v">${remote?.batchSize ?? 10}</span></div>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
@@ -622,6 +648,7 @@ function toConfigDraft(config: SemanticConfigView): SemanticConfigDraft {
|
||||
apiKey: "",
|
||||
model: config.openaiCompat.model,
|
||||
extraBodyText: JSON.stringify(config.openaiCompat.extraBody ?? {}, null, 2),
|
||||
batchSize: config.openaiCompat.batchSize,
|
||||
apiKeySet: config.openaiCompat.apiKeySet,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -15,6 +15,8 @@ interface SessionEntry {
|
||||
|
||||
interface SessionsData {
|
||||
sessions?: SessionEntry[];
|
||||
currentSession?: string | null;
|
||||
canSwitch?: boolean;
|
||||
}
|
||||
|
||||
interface OpenSession {
|
||||
@@ -25,10 +27,12 @@ interface OpenSession {
|
||||
|
||||
export function SessionsPanel() {
|
||||
useLang();
|
||||
const { data, error, loading } = usePoll<SessionsData>("/sessions", 5000);
|
||||
const { data, error, loading, refresh } = usePoll<SessionsData>("/sessions", 5000);
|
||||
const [open, setOpen] = useState<OpenSession | null>(null);
|
||||
const [openLoading, setOpenLoading] = useState(false);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
const view = useCallback(async (name: string) => {
|
||||
setOpen({ name, messages: null });
|
||||
@@ -43,13 +47,62 @@ export function SessionsPanel() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const newSession = useCallback(async () => {
|
||||
setBusy("new");
|
||||
setActionError(null);
|
||||
try {
|
||||
await api("/sessions/new", { method: "POST" });
|
||||
setOpen(null);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setActionError((err as Error).message);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}, [refresh]);
|
||||
|
||||
const switchTo = useCallback(
|
||||
async (name: string) => {
|
||||
setBusy(`switch:${name}`);
|
||||
setActionError(null);
|
||||
try {
|
||||
await api(`/sessions/${encodeURIComponent(name)}/switch`, { method: "POST" });
|
||||
setOpen(null);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setActionError((err as Error).message);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
},
|
||||
[refresh],
|
||||
);
|
||||
|
||||
const remove = useCallback(
|
||||
async (name: string) => {
|
||||
if (!confirm(t("sessions.deleteConfirm", { name }))) return;
|
||||
setBusy(`delete:${name}`);
|
||||
setActionError(null);
|
||||
try {
|
||||
await api(`/sessions/${encodeURIComponent(name)}`, { method: "DELETE" });
|
||||
if (open?.name === name) setOpen(null);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setActionError((err as Error).message);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
},
|
||||
[open, refresh],
|
||||
);
|
||||
|
||||
if (loading && !data)
|
||||
return html`<div class="card" style="color:var(--fg-3)">${t("sessions.loading")}</div>`;
|
||||
if (error) return html`<div class="card accent-err">${t("common.loadingFailed", { name: "sessions", error: error.message })}</div>`;
|
||||
if (error)
|
||||
return html`<div class="card accent-err">${t("common.loadingFailed", { name: "sessions", error: error.message })}</div>`;
|
||||
const sessions = data?.sessions ?? [];
|
||||
|
||||
if (sessions.length === 0)
|
||||
return html`<div class="card" style="color:var(--fg-3)">${t("sessions.noSessions")}</div>`;
|
||||
const currentSession = data?.currentSession ?? null;
|
||||
const canSwitch = data?.canSwitch ?? false;
|
||||
|
||||
const filtered = filter.trim()
|
||||
? sessions.filter((s) => s.name.toLowerCase().includes(filter.toLowerCase()))
|
||||
@@ -58,7 +111,7 @@ export function SessionsPanel() {
|
||||
return html`
|
||||
<div class="sessions-grid">
|
||||
<div class="sessions-list">
|
||||
<div class="ssl-h">
|
||||
<div class="ssl-h" style="display:flex;gap:6px">
|
||||
<input
|
||||
type="text"
|
||||
placeholder=${t("sessions.filterPlaceholder")}
|
||||
@@ -66,27 +119,54 @@ export function SessionsPanel() {
|
||||
onInput=${(e: Event) => setFilter((e.target as HTMLInputElement).value)}
|
||||
style="flex:1"
|
||||
/>
|
||||
<button
|
||||
class="btn primary"
|
||||
disabled=${!canSwitch || busy === "new"}
|
||||
title=${canSwitch ? t("sessions.newHint") : t("sessions.attachRequired")}
|
||||
onClick=${newSession}
|
||||
>
|
||||
${busy === "new" ? t("common.loading") : `+ ${t("sessions.newBtn")}`}
|
||||
</button>
|
||||
</div>
|
||||
${
|
||||
!canSwitch
|
||||
? html`<div style="padding:0 12px 6px;font-size:11.5px;color:var(--fg-3)">${t("sessions.attachRequired")}</div>`
|
||||
: null
|
||||
}
|
||||
${
|
||||
actionError
|
||||
? html`<div class="card accent-err" style="margin:0 12px 8px;padding:6px 10px;font-size:12px">${actionError}</div>`
|
||||
: null
|
||||
}
|
||||
<div class="chips" style="padding:0 12px 8px">
|
||||
<span class="chip-f static active">${t("common.all")} <span class="ct">${sessions.length}</span></span>
|
||||
${currentSession ? html`<span class="chip-f static">${t("sessions.activeChip")} <span class="ct">${currentSession}</span></span>` : null}
|
||||
</div>
|
||||
<div class="ssl-rows">
|
||||
${filtered.map(
|
||||
(s) => html`
|
||||
<div
|
||||
class=${`ssl-row ${open?.name === s.name ? "sel" : ""}`}
|
||||
onClick=${() => view(s.name)}
|
||||
>
|
||||
<span class="name">${s.name}</span>
|
||||
<span class="meta">
|
||||
<span><span class="v">${fmtNum(s.messageCount)}</span> ${t("sessions.msgs")}</span>
|
||||
<span><span class="v">${fmtBytes(s.size)}</span></span>
|
||||
<span>${fmtRelativeTime(s.mtime)}</span>
|
||||
</span>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
${
|
||||
sessions.length === 0
|
||||
? html`<div class="ctx-empty" style="padding:24px 12px;color:var(--fg-3)">${t("sessions.noSessions")}</div>`
|
||||
: html`<div class="ssl-rows">
|
||||
${filtered.map((s) => {
|
||||
const isCurrent = currentSession === s.name;
|
||||
return html`
|
||||
<div
|
||||
class=${`ssl-row ${open?.name === s.name ? "sel" : ""}`}
|
||||
onClick=${() => view(s.name)}
|
||||
>
|
||||
<span class="name">
|
||||
${isCurrent ? html`<span class="pill ok" style="margin-right:6px">${t("sessions.activePill")}</span>` : null}
|
||||
${s.name}
|
||||
</span>
|
||||
<span class="meta">
|
||||
<span><span class="v">${fmtNum(s.messageCount)}</span> ${t("sessions.msgs")}</span>
|
||||
<span><span class="v">${fmtBytes(s.size)}</span></span>
|
||||
<span>${fmtRelativeTime(s.mtime)}</span>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>`
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="sessions-detail">
|
||||
@@ -95,9 +175,14 @@ export function SessionsPanel() {
|
||||
? html`<div style="color:var(--fg-3);font-size:13px;text-align:center;padding:60px 20px">
|
||||
${t("sessions.pickHint")}
|
||||
</div>`
|
||||
: html`
|
||||
: (() => {
|
||||
const isCurrent = currentSession === open.name;
|
||||
return html`
|
||||
<div class="sessions-detail-h">
|
||||
<span class="name">${open.name}</span>
|
||||
<span class="name">
|
||||
${isCurrent ? html`<span class="pill ok" style="margin-right:6px">${t("sessions.activePill")}</span>` : null}
|
||||
${open.name}
|
||||
</span>
|
||||
<span class="ws">
|
||||
${
|
||||
open.messages
|
||||
@@ -106,16 +191,32 @@ export function SessionsPanel() {
|
||||
}
|
||||
</span>
|
||||
<span class="actions">
|
||||
${
|
||||
canSwitch && !isCurrent
|
||||
? html`<button class="btn primary" disabled=${busy === `switch:${open.name}`} onClick=${() => switchTo(open.name)}>${busy === `switch:${open.name}` ? t("common.loading") : t("sessions.switchBtn")}</button>`
|
||||
: null
|
||||
}
|
||||
<button
|
||||
class="btn"
|
||||
disabled=${isCurrent || busy === `delete:${open.name}`}
|
||||
title=${isCurrent ? t("sessions.cantDeleteActive") : t("sessions.deleteBtn")}
|
||||
style="border-color:var(--c-err);color:var(--c-err)"
|
||||
onClick=${() => remove(open.name)}
|
||||
>${busy === `delete:${open.name}` ? t("common.loading") : t("sessions.deleteBtn")}</button>
|
||||
<button class="btn ghost" onClick=${() => setOpen(null)}>${t("common.back")}</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="card accent-brand" style="margin-bottom:10px">
|
||||
<div class="card-h"><span class="title">${t("sessions.resumeTitle")}</span></div>
|
||||
<div class="card-b" style="font-size:12.5px;color:var(--fg-2)">
|
||||
${t("sessions.resumeDesc")}
|
||||
<code class="mono" style="display:block;margin-top:8px;padding:8px 10px;background:var(--bg-input);border-radius:var(--r);color:var(--fg-0);font-size:12px;user-select:all">reasonix chat --session ${open.name}</code>
|
||||
</div>
|
||||
</div>
|
||||
${
|
||||
!canSwitch
|
||||
? html`<div class="card accent-brand" style="margin-bottom:10px">
|
||||
<div class="card-h"><span class="title">${t("sessions.resumeTitle")}</span></div>
|
||||
<div class="card-b" style="font-size:12.5px;color:var(--fg-2)">
|
||||
${t("sessions.resumeDesc")}
|
||||
<code class="mono" style="display:block;margin-top:8px;padding:8px 10px;background:var(--bg-input);border-radius:var(--r);color:var(--fg-0);font-size:12px;user-select:all">reasonix chat --session ${open.name}</code>
|
||||
</div>
|
||||
</div>`
|
||||
: null
|
||||
}
|
||||
${
|
||||
openLoading
|
||||
? html`<div style="color:var(--fg-3)">${t("sessions.loadingTranscript")}</div>`
|
||||
@@ -147,7 +248,8 @@ export function SessionsPanel() {
|
||||
</div>`
|
||||
: html`<div style="color:var(--fg-3)">${t("sessions.emptyTranscript")}</div>`
|
||||
}
|
||||
`
|
||||
`;
|
||||
})()
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,32 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="_图层_1" data-name="图层 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1254 1254">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#8b7cff"/>
|
||||
<stop offset="100%" stop-color="#5b3df0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="sheen" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.22"/>
|
||||
<stop offset="38%" stop-color="#ffffff" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<filter id="innerShadow" x="-10%" y="-10%" width="120%" height="120%">
|
||||
<feGaussianBlur stdDeviation="6" result="blur"/>
|
||||
<feOffset dy="-12" result="off"/>
|
||||
<feComposite in="off" in2="SourceAlpha" operator="arithmetic" k2="-1" k3="1" result="inner"/>
|
||||
<feFlood flood-color="#1a0e7a" flood-opacity="0.35"/>
|
||||
<feComposite in2="inner" operator="in"/>
|
||||
</filter>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #0153e5;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<rect x="64" y="64" width="896" height="896" rx="220" ry="220" fill="url(#g)"/>
|
||||
<rect x="64" y="64" width="896" height="896" rx="220" ry="220" fill="url(#sheen)"/>
|
||||
<g fill="#ffffff" font-family="JetBrains Mono, ui-monospace, SFMono-Regular, Menlo, monospace" font-weight="800" text-anchor="middle">
|
||||
<text x="512" y="700" font-size="640" letter-spacing="-12">R</text>
|
||||
<rect class="cls-1" x="6.03" y="6.03" width="1241.94" height="1241.94" rx="212.47" ry="212.47"/>
|
||||
<g>
|
||||
<path class="cls-2" d="M365.68,979.74l-25.49-38.96c-6.04-.37-11.31-.37-17.43.02l-.32,38.89-25.45-.07.17-116.94,41.97-.09c8.63-.02,17.06-.36,25.04,2.45,16,4.84,25.56,18.48,25.9,34.98s-8.99,30.24-24.61,36.54l29.2,42.77-28.97.42ZM349.06,918.57c10.12-1.14,15.92-8.96,15.42-18.1-.45-8.24-7.17-15.04-16.43-15.27-8.58-.22-16.84-.16-25.42.11l.2,33.63c9-.35,17.44.61,26.23-.38Z"/>
|
||||
<path class="cls-2" d="M575.08,979.32c-7.43,1.03-14.99.28-22.9.38l-.74-9.77c-7.73,8.95-17.92,13.2-29.47,11.93-14.76-1.61-26.67-11.64-31.95-25.58-4.4-11.6-4.2-24.54.48-36.11,5.71-14.09,18.45-24.15,33.47-25.24,11.06-.8,20.62,2.91,27.6,11.13l.68-9.23,22.6-.13.22,82.61ZM552.67,938.39c0-11.9-9.65-21.55-21.55-21.55s-21.55,9.65-21.55,21.55,9.65,21.55,21.55,21.55,21.55-9.65,21.55-21.55Z"/>
|
||||
<path class="cls-2" d="M458.55,951.89c6.66,3,12.37,7.13,18.78,10.99-8.73,13.37-23.6,19.69-39.12,19.17-25.4-.85-43.92-21.32-42.46-46.43,1.26-21.79,17.21-38.77,39.14-40.79,15.8-1.45,31.24,4.92,39.65,18.61,5.72,9.31,6.94,20.05,5.86,30.58l-60.66.08c.84,8.87,8.11,15.02,15.57,16.66,8.84,1.95,17.43-.49,23.24-8.87ZM458.14,928.74c-2.47-9.92-10.62-14.65-19.52-14.49-8.25.15-16.52,5.45-18.32,14.48h37.83Z"/>
|
||||
<path class="cls-2" d="M750.19,938.44c0,24.46-19.83,44.3-44.3,44.3s-44.3-19.83-44.3-44.3,19.83-44.3,44.3-44.3,44.3,19.83,44.3,44.3ZM727.57,938.41c0-12-9.72-21.72-21.72-21.72s-21.72,9.72-21.72,21.72,9.72,21.72,21.72,21.72,21.72-9.72,21.72-21.72Z"/>
|
||||
<path class="cls-2" d="M801.56,916.44c-9.85-.29-18.14,6.26-18.21,15.86l-.36,47.37-23.37-.11.03-82.78,22.91.21c.25,3.4-.62,5.99.87,9.46,11.74-15.92,38.04-15.05,49.82-.69,5.53,6.74,8.09,15.39,8.08,24.25l-.04,49.62-23.39.09-.42-47.61c-.08-8.96-6.5-15.4-15.94-15.67Z"/>
|
||||
<path class="cls-2" d="M973.16,979.4l-26.93.31-17.96-25.12-18.33,25.11-26-.32,30.46-42.06-28.9-40.27c9.78-1.08,18.2-.09,27.28-.01l15.65,23.31,16.3-23.6c8.92-.23,17.29-.37,26.73.41l-28.8,40.03,30.5,42.21Z"/>
|
||||
<path class="cls-2" d="M638.97,978.82c-18.03,6.92-47.26,4.18-56.68-16.59l17.95-8.65c2.7,1.18,3.3,4.09,5.67,5.7,9.87,6.71,26.42,5.86,26.64-2.77.07-2.84-2.08-5.34-5.15-6.18l-21.93-5.98c-7.07-1.93-13.56-5.89-16.89-12.73-6.16-12.65.07-27.35,12.99-33.01,17.43-7.64,46.57-4.97,53.87,15.09l-17.45,7.58c-6.29-7.68-16.61-10.11-25.61-6.26-2.11.9-3.33,3.62-3.17,5.29.21,2.22,2.03,4.63,4.56,5.32l24.3,6.66c5.87,1.61,11.42,4.7,14.59,10.07,8.12,13.79,1.39,30.66-13.68,36.45Z"/>
|
||||
<rect class="cls-2" x="823.88" y="926.58" width="83.07" height="23.31" transform="translate(-73.46 1802.96) rotate(-89.96)"/>
|
||||
<circle class="cls-2" cx="865.58" cy="871.74" r="14.34"/>
|
||||
</g>
|
||||
</svg>
|
||||
<g>
|
||||
<path class="cls-2" d="M657.07,556.02c4.59,3.86,10.93,5.3,17.45,2.32,2.59-1.18,4.3-4.19,3.22-7.14-8.6-23.51-32.86-40.71-42.55-27.41-1.18,1.63-1.14,3.74-.24,5.03.71,1.02,2.14,1.26,3.99.88,5.89-1.22,13.21,2.87,12.52,9.44-.72,6.85.66,12.72,5.61,16.89Z"/>
|
||||
<path class="cls-2" d="M798.75,598.8c22.21-24.86,36.14-54.9,40.99-87.98,11.07-72.3-24.83-138.35-92.45-166.33-23.77-9.56-48.19-13.62-74.13-13.37l-251.41.07.02,481.78,123.58-.1v-169.44c-4.09-1.52-6.91-2.78-6.91-2.78-26.86-12.28-47.42-33.58-58.71-60.25-14.41-34.05-9.96-72.23,12.69-101.26,22.56-28.2,53.39-37.16,88.72-32.2,13.05,1.83,27-13.49,53.57-10.57,1.79.2,3.58,1.54,3.76,2.6.62,3.63-9.65,5.57-9.66,15.48,0,4.17,1.87,9,5.81,11.76,13.54,9.5,25.27,20.18,37.54,31.22,5.96,5.36,26.19,20.45,31.34,8.16,3-7.17,4.99-14.67,6.98-22.28.98-3.77-1.1-6.02-4.14-7.82-20.87-12.35-28.73-38.11-20.57-60.92.79-2.2,2.82-3.27,4.56-3.34,7.01-.3,3.28,12.7,20.59,18.03,16.44,5.06,16.94,18.73,24.93,13.29,18.63-12.7,25.34-2.3,42.61-18.58,1.78-1.67,4.83-1.79,6.68-.51,1.14.79,2.12,2.84,2.03,5.22-.51,13.75-5.87,26.88-15.26,36.97-17.76,19.08-32.69,9.95-33.65,23.31-2.83,39.22-15.66,78.37-43.49,106.85-.89.91-1.43,2.22-1.28,3.1.16.92,1.26,1.82,2.46,2.23l23.45,7.97c3.32,1.13,5.72,4.1,5.3,7.27-.37,2.76-2.57,5.2-5.74,6.25-17.21,5.7-35.7,4.7-53.23-.6-23.19,19.53-51.68,29.59-82.74,28.38l45.08,62.23,73.54,100.4,146.79-.2-124.45-168.04c28.97-8.55,54.42-23.87,74.8-45.98Z"/>
|
||||
<circle class="cls-2" cx="640.36" cy="538.21" r="3.47"/>
|
||||
<path class="cls-2" d="M504.6,517.71c-7.75.94-12.41,6.5-11.66,14.8,5.25,57.86,58.62,105.22,118.44,93.8,12.73-2.43,24.33-7.49,33.74-16.99-20.35-17.53-30.27-35.39-47.01-52.99-23.95-25.18-58.04-42.91-93.5-38.62Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 4.5 KiB |
@@ -16,14 +16,17 @@
|
||||
"@tauri-apps/plugin-opener": "^2.0.0",
|
||||
"@tauri-apps/plugin-process": "^2.0.0",
|
||||
"@tauri-apps/plugin-updater": "^2.0.0",
|
||||
"katex": "^0.16.47",
|
||||
"lucide-react": "^0.469.0",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-virtuoso": "^4.12.5",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.0"
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remark-math": "^6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.1.0",
|
||||
@@ -1482,6 +1485,12 @@
|
||||
"@types/unist": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/katex": {
|
||||
"version": "0.16.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz",
|
||||
"integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/mdast": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
|
||||
@@ -1702,6 +1711,15 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
|
||||
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
@@ -1774,6 +1792,18 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||
@@ -1876,6 +1906,101 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-from-dom": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz",
|
||||
"integrity": "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"hastscript": "^9.0.0",
|
||||
"web-namespaces": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-from-html": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz",
|
||||
"integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"devlop": "^1.1.0",
|
||||
"hast-util-from-parse5": "^8.0.0",
|
||||
"parse5": "^7.0.0",
|
||||
"vfile": "^6.0.0",
|
||||
"vfile-message": "^4.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-from-html-isomorphic": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-from-html-isomorphic/-/hast-util-from-html-isomorphic-2.0.0.tgz",
|
||||
"integrity": "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"hast-util-from-dom": "^5.0.0",
|
||||
"hast-util-from-html": "^2.0.0",
|
||||
"unist-util-remove-position": "^5.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-from-parse5": {
|
||||
"version": "8.0.3",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz",
|
||||
"integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"@types/unist": "^3.0.0",
|
||||
"devlop": "^1.0.0",
|
||||
"hastscript": "^9.0.0",
|
||||
"property-information": "^7.0.0",
|
||||
"vfile": "^6.0.0",
|
||||
"vfile-location": "^5.0.0",
|
||||
"web-namespaces": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-is-element": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz",
|
||||
"integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-parse-selector": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz",
|
||||
"integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-to-jsx-runtime": {
|
||||
"version": "2.3.6",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
|
||||
@@ -1903,6 +2028,22 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-to-text": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz",
|
||||
"integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"@types/unist": "^3.0.0",
|
||||
"hast-util-is-element": "^3.0.0",
|
||||
"unist-util-find-after": "^5.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-whitespace": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
|
||||
@@ -1916,6 +2057,23 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hastscript": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz",
|
||||
"integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"comma-separated-tokens": "^2.0.0",
|
||||
"hast-util-parse-selector": "^4.0.0",
|
||||
"property-information": "^7.0.0",
|
||||
"space-separated-tokens": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/html-url-attributes": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
||||
@@ -2021,6 +2179,22 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/katex": {
|
||||
"version": "0.16.47",
|
||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz",
|
||||
"integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==",
|
||||
"funding": [
|
||||
"https://opencollective.com/katex",
|
||||
"https://github.com/sponsors/katex"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"commander": "^8.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"katex": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/longest-streak": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
|
||||
@@ -2201,6 +2375,25 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-math": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz",
|
||||
"integrity": "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"@types/mdast": "^4.0.0",
|
||||
"devlop": "^1.0.0",
|
||||
"longest-streak": "^3.0.0",
|
||||
"mdast-util-from-markdown": "^2.0.0",
|
||||
"mdast-util-to-markdown": "^2.1.0",
|
||||
"unist-util-remove-position": "^5.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-mdx-expression": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
|
||||
@@ -2534,6 +2727,25 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-extension-math": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz",
|
||||
"integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/katex": "^0.16.0",
|
||||
"devlop": "^1.0.0",
|
||||
"katex": "^0.16.0",
|
||||
"micromark-factory-space": "^2.0.0",
|
||||
"micromark-util-character": "^2.0.0",
|
||||
"micromark-util-symbol": "^2.0.0",
|
||||
"micromark-util-types": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-factory-destination": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
|
||||
@@ -2964,6 +3176,18 @@
|
||||
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -3091,6 +3315,25 @@
|
||||
"react-dom": ">=16 || >=17 || >= 18 || >=19"
|
||||
}
|
||||
},
|
||||
"node_modules/rehype-katex": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz",
|
||||
"integrity": "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"@types/katex": "^0.16.0",
|
||||
"hast-util-from-html-isomorphic": "^2.0.0",
|
||||
"hast-util-to-text": "^4.0.0",
|
||||
"katex": "^0.16.0",
|
||||
"unist-util-visit-parents": "^6.0.0",
|
||||
"vfile": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/remark-breaks": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-4.0.0.tgz",
|
||||
@@ -3124,6 +3367,22 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/remark-math": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/remark-math/-/remark-math-6.0.0.tgz",
|
||||
"integrity": "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"mdast-util-math": "^3.0.0",
|
||||
"micromark-extension-math": "^3.0.0",
|
||||
"unified": "^11.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/remark-parse": {
|
||||
"version": "11.0.0",
|
||||
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
|
||||
@@ -3338,6 +3597,20 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/unist-util-find-after": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz",
|
||||
"integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/unist": "^3.0.0",
|
||||
"unist-util-is": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/unist-util-is": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz",
|
||||
@@ -3364,6 +3637,20 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/unist-util-remove-position": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz",
|
||||
"integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/unist": "^3.0.0",
|
||||
"unist-util-visit": "^5.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/unist-util-stringify-position": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
|
||||
@@ -3451,6 +3738,20 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/vfile-location": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz",
|
||||
"integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/unist": "^3.0.0",
|
||||
"vfile": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/vfile-message": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
|
||||
@@ -3525,6 +3826,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/web-namespaces": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
|
||||
"integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "reasonix-desktop",
|
||||
"private": true,
|
||||
"version": "0.43.0",
|
||||
"version": "0.48.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -14,19 +14,23 @@
|
||||
"@fontsource/geist": "^5.2.8",
|
||||
"@fontsource/geist-mono": "^5.2.7",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@reasonix/core-utils": "workspace:*",
|
||||
"@tauri-apps/api": "^2.1.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-opener": "^2.0.0",
|
||||
"@tauri-apps/plugin-process": "^2.0.0",
|
||||
"@tauri-apps/plugin-updater": "^2.0.0",
|
||||
"katex": "^0.16.47",
|
||||
"lucide-react": "^0.469.0",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-virtuoso": "^4.12.5",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.0"
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remark-math": "^6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.1.0",
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
/**
|
||||
* Cross-platform Node 22 downloader. Detects host platform + arch and pulls
|
||||
* the matching official Node distribution from nodejs.org, then extracts the
|
||||
* node binary into desktop/src-tauri/binaries/. Tauri's per-platform conf
|
||||
* overlay (tauri.<platform>.conf.json) picks it up at bundle time.
|
||||
*
|
||||
* Usage: `npm run bundle:node` from desktop/.
|
||||
* Re-run when bumping NODE_VERSION. The download is gitignored.
|
||||
*/
|
||||
// Pulls Node 22 from nodejs.org into desktop/src-tauri/binaries/.
|
||||
// Mac always produces a universal Mach-O so Intel + Apple Silicon ship from one .dmg (issue #1234).
|
||||
import { execSync } from "node:child_process";
|
||||
import { chmodSync, createWriteStream, existsSync, mkdirSync, renameSync, rmSync, statSync } from "node:fs";
|
||||
import https from "node:https";
|
||||
@@ -20,32 +13,35 @@ const binDir = join(here, "..", "src-tauri", "binaries");
|
||||
|
||||
const PLAT = process.platform;
|
||||
const ARCH_RAW = process.arch;
|
||||
const ARCH = ARCH_RAW === "arm64" ? "arm64" : ARCH_RAW === "x64" ? "x64" : null;
|
||||
const HOST_ARCH = ARCH_RAW === "arm64" ? "arm64" : ARCH_RAW === "x64" ? "x64" : null;
|
||||
|
||||
if (!ARCH || !["win32", "darwin", "linux"].includes(PLAT)) {
|
||||
if (!HOST_ARCH || !["win32", "darwin", "linux"].includes(PLAT)) {
|
||||
console.error(`Unsupported host: ${PLAT}/${ARCH_RAW}. Supported: win32 / darwin / linux × x64 / arm64.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const isWin = PLAT === "win32";
|
||||
const triple =
|
||||
PLAT === "win32" ? `win-${ARCH}` : PLAT === "darwin" ? `darwin-${ARCH}` : `linux-${ARCH}`;
|
||||
const archiveExt = PLAT === "win32" ? "zip" : PLAT === "darwin" ? "tar.gz" : "tar.xz";
|
||||
const archiveBase = `node-v${NODE_VERSION}-${triple}`;
|
||||
const archiveFile = `${archiveBase}.${archiveExt}`;
|
||||
const url = `https://nodejs.org/dist/v${NODE_VERSION}/${archiveFile}`;
|
||||
|
||||
const targetExe = join(binDir, isWin ? "node.exe" : "node");
|
||||
const archivePath = join(binDir, archiveFile);
|
||||
const extractDir = join(binDir, "_extract");
|
||||
|
||||
if (existsSync(targetExe)) {
|
||||
const size = statSync(targetExe).size;
|
||||
if (size > 1024 * 1024) {
|
||||
console.log(`${targetExe} already present (${(size / 1024 / 1024).toFixed(1)} MB) — delete to refetch`);
|
||||
if (existsSync(targetExe) && statSync(targetExe).size > 1024 * 1024) {
|
||||
if (PLAT === "darwin") {
|
||||
try {
|
||||
const archs = execSync(`lipo -archs "${targetExe}"`, { encoding: "utf8" }).trim();
|
||||
if (archs.includes("arm64") && archs.includes("x86_64")) {
|
||||
console.log(`${targetExe} already universal (${archs}) — delete to refetch`);
|
||||
process.exit(0);
|
||||
}
|
||||
console.log(`${targetExe} present but not universal (${archs}) — rebuilding`);
|
||||
rmSync(targetExe);
|
||||
} catch {
|
||||
console.log(`${targetExe} present but not verifiable as Mach-O — rebuilding`);
|
||||
rmSync(targetExe);
|
||||
}
|
||||
} else {
|
||||
const mb = (statSync(targetExe).size / 1024 / 1024).toFixed(1);
|
||||
console.log(`${targetExe} already present (${mb} MB) — delete to refetch`);
|
||||
process.exit(0);
|
||||
}
|
||||
// < 1 MB = placeholder, refetch.
|
||||
}
|
||||
|
||||
mkdirSync(binDir, { recursive: true });
|
||||
@@ -82,43 +78,73 @@ function follow(url, dest, redirects = 5) {
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Downloading ${archiveFile} (Node v${NODE_VERSION} ${triple}) ...`);
|
||||
await follow(url, archivePath);
|
||||
process.stdout.write("\n");
|
||||
async function fetchAndExtract(arch) {
|
||||
const triple =
|
||||
PLAT === "win32" ? `win-${arch}` : PLAT === "darwin" ? `darwin-${arch}` : `linux-${arch}`;
|
||||
const archiveExt = PLAT === "win32" ? "zip" : PLAT === "darwin" ? "tar.gz" : "tar.xz";
|
||||
const archiveBase = `node-v${NODE_VERSION}-${triple}`;
|
||||
const archiveFile = `${archiveBase}.${archiveExt}`;
|
||||
const url = `https://nodejs.org/dist/v${NODE_VERSION}/${archiveFile}`;
|
||||
const archivePath = join(binDir, archiveFile);
|
||||
const extractDir = join(binDir, `_extract_${arch}`);
|
||||
|
||||
rmSync(extractDir, { recursive: true, force: true });
|
||||
mkdirSync(extractDir, { recursive: true });
|
||||
console.log(`Downloading ${archiveFile} ...`);
|
||||
await follow(url, archivePath);
|
||||
process.stdout.write("\n");
|
||||
|
||||
console.log("Extracting ...");
|
||||
if (isWin) {
|
||||
execSync(
|
||||
`powershell -NoProfile -Command "Expand-Archive -Force -Path '${archivePath}' -DestinationPath '${extractDir}'"`,
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
} else {
|
||||
// tar handles both .tar.gz (with -z auto) and .tar.xz (needs xz-utils on host).
|
||||
execSync(`tar -xf "${archivePath}" -C "${extractDir}"`, { stdio: "inherit" });
|
||||
}
|
||||
rmSync(extractDir, { recursive: true, force: true });
|
||||
mkdirSync(extractDir, { recursive: true });
|
||||
|
||||
const inner = isWin
|
||||
? join(extractDir, archiveBase, "node.exe")
|
||||
: join(extractDir, archiveBase, "bin", "node");
|
||||
if (!existsSync(inner)) {
|
||||
console.error(`Extracted binary not found at expected path: ${inner}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (existsSync(targetExe)) rmSync(targetExe);
|
||||
renameSync(inner, targetExe);
|
||||
if (!isWin) {
|
||||
try {
|
||||
chmodSync(targetExe, 0o755);
|
||||
} catch {
|
||||
/* ignore */
|
||||
console.log(`Extracting ${arch} ...`);
|
||||
if (isWin) {
|
||||
execSync(
|
||||
`powershell -NoProfile -Command "Expand-Archive -Force -Path '${archivePath}' -DestinationPath '${extractDir}'"`,
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
} else {
|
||||
execSync(`tar -xf "${archivePath}" -C "${extractDir}"`, { stdio: "inherit" });
|
||||
}
|
||||
}
|
||||
rmSync(extractDir, { recursive: true, force: true });
|
||||
rmSync(archivePath);
|
||||
|
||||
const mb = (statSync(targetExe).size / 1024 / 1024).toFixed(1);
|
||||
console.log(`Done: ${targetExe} (${mb} MB)`);
|
||||
const inner = isWin
|
||||
? join(extractDir, archiveBase, "node.exe")
|
||||
: join(extractDir, archiveBase, "bin", "node");
|
||||
if (!existsSync(inner)) {
|
||||
console.error(`Extracted binary not found at expected path: ${inner}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
rmSync(archivePath);
|
||||
return { inner, extractDir };
|
||||
}
|
||||
|
||||
if (PLAT === "darwin") {
|
||||
const [arm, x64] = await Promise.all([fetchAndExtract("arm64"), fetchAndExtract("x64")]);
|
||||
|
||||
console.log("Creating universal binary with lipo ...");
|
||||
if (existsSync(targetExe)) rmSync(targetExe);
|
||||
execSync(`lipo -create "${arm.inner}" "${x64.inner}" -output "${targetExe}"`, { stdio: "inherit" });
|
||||
chmodSync(targetExe, 0o755);
|
||||
|
||||
rmSync(arm.extractDir, { recursive: true, force: true });
|
||||
rmSync(x64.extractDir, { recursive: true, force: true });
|
||||
|
||||
const archs = execSync(`lipo -archs "${targetExe}"`, { encoding: "utf8" }).trim();
|
||||
const mb = (statSync(targetExe).size / 1024 / 1024).toFixed(1);
|
||||
console.log(`Done: ${targetExe} (${mb} MB, archs: ${archs})`);
|
||||
} else {
|
||||
const { inner, extractDir } = await fetchAndExtract(HOST_ARCH);
|
||||
|
||||
if (existsSync(targetExe)) rmSync(targetExe);
|
||||
renameSync(inner, targetExe);
|
||||
if (!isWin) {
|
||||
try {
|
||||
chmodSync(targetExe, 0o755);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
rmSync(extractDir, { recursive: true, force: true });
|
||||
|
||||
const mb = (statSync(targetExe).size / 1024 / 1024).toFixed(1);
|
||||
console.log(`Done: ${targetExe} (${mb} MB)`);
|
||||
}
|
||||
@@ -2776,7 +2776,7 @@ checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
|
||||
|
||||
[[package]]
|
||||
name = "reasonix-desktop"
|
||||
version = "0.43.0"
|
||||
version = "0.48.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"parking_lot",
|
||||
@@ -2788,6 +2788,7 @@ dependencies = [
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-updater",
|
||||
"tauri-plugin-window-state",
|
||||
"which",
|
||||
]
|
||||
|
||||
@@ -3865,6 +3866,21 @@ dependencies = [
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-window-state"
|
||||
version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.11.1"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "reasonix-desktop"
|
||||
version = "0.43.0"
|
||||
version = "0.48.1"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
@@ -13,6 +13,7 @@ tauri-plugin-updater = "2"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-window-state = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
parking_lot = "0.12"
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"updater:default",
|
||||
"process:allow-restart",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save",
|
||||
{
|
||||
"identifier": "opener:allow-open-url",
|
||||
"allow": [{ "url": "https://*" }, { "url": "http://*" }]
|
||||
|
||||
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 907 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 964 B After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 644 B After Width: | Height: | Size: 570 B |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 941 B After Width: | Height: | Size: 896 B |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 8.2 KiB |
@@ -203,6 +203,11 @@ fn open_in_editor(command: String, path: String, line: Option<u32>) -> Result<()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn write_text_file(path: String, content: String) -> Result<(), String> {
|
||||
std::fs::write(&path, &content).map_err(|e| format!("write failed: {e}"))
|
||||
}
|
||||
|
||||
fn main() {
|
||||
#[cfg(target_os = "linux")]
|
||||
linux_webkit_compat();
|
||||
@@ -212,6 +217,7 @@ fn main() {
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||
.manage(RpcState::default())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
rpc_spawn,
|
||||
@@ -219,7 +225,8 @@ fn main() {
|
||||
rpc_kill,
|
||||
open_in_editor,
|
||||
list_workspace_tree,
|
||||
git_status
|
||||
git_status,
|
||||
write_text_file
|
||||
])
|
||||
.setup(|app| {
|
||||
use tauri::Manager;
|
||||
|
||||
@@ -141,7 +141,10 @@ fn find_real_node() -> Result<PathBuf> {
|
||||
pub fn rpc_spawn(app: AppHandle, state: State<'_, RpcState>) -> Result<(), String> {
|
||||
let mut guard = state.inner.lock();
|
||||
if guard.is_some() {
|
||||
return Err("rpc already spawned".into());
|
||||
// Idempotent — a second call (effect re-run, WebView reload) keeps the
|
||||
// existing Node child. The frontend follows up with `desktop_resync`
|
||||
// so a reloaded React app catches up on bootstrap events it missed.
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let (program, args) = resolve_cli(&app).map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Reasonix",
|
||||
"version": "0.43.0",
|
||||
"version": "0.48.1",
|
||||
"identifier": "dev.reasonix.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -46,7 +46,10 @@
|
||||
"createUpdaterArtifacts": true,
|
||||
"windows": {
|
||||
"nsis": {
|
||||
"languages": ["English", "SimpChinese"],
|
||||
"languages": [
|
||||
"English",
|
||||
"SimpChinese"
|
||||
],
|
||||
"customLanguageFiles": {
|
||||
"English": "nsis/English.nsh",
|
||||
"SimpChinese": "nsis/SimpChinese.nsh"
|
||||
@@ -58,6 +61,7 @@
|
||||
"updater": {
|
||||
"active": true,
|
||||
"endpoints": [
|
||||
"https://pub-147fb53b9c1e4bbf891a257968619ea7.r2.dev/latest/latest.json",
|
||||
"https://github.com/esengine/reasonix/releases/latest/download/latest.json"
|
||||
],
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDI5RkE3RDJDQTgzMkFDNkMKUldSc3JES29MSDM2S1pwcjR2MnZZMW15eG5ydnQrRUJtdjdteFJiaHF6cnE0NzYyb09Wc2k0ZHEK"
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./CommandPalette", () => ({
|
||||
CommandPalette: () => null,
|
||||
Toast: () => null,
|
||||
buildCommands: vi.fn(() => []),
|
||||
useCommandPalette: vi.fn(() => ({ open: false, setOpen: vi.fn() })),
|
||||
}));
|
||||
vi.mock("./Markdown", () => ({
|
||||
WorkspaceProvider: ({ children }: { children?: unknown }) => children ?? null,
|
||||
}));
|
||||
vi.mock("./theme", () => ({
|
||||
FONT_FAMILY: "sans-serif",
|
||||
FONT_FAMILY_STACK: "sans-serif",
|
||||
FONT_SCALE: 1,
|
||||
FONT_SCALE_ZOOM: 1,
|
||||
THEME: "dark",
|
||||
defaultStyleForTheme: vi.fn(() => ({
|
||||
bg: "#000",
|
||||
surface: "#111",
|
||||
border: "#222",
|
||||
text: "#fff",
|
||||
muted: "#888",
|
||||
accent: "#0af",
|
||||
danger: "#f00",
|
||||
warn: "#fa0",
|
||||
success: "#0f0",
|
||||
brand: "#0af",
|
||||
})),
|
||||
isFontFamily: vi.fn(() => true),
|
||||
isFontScale: vi.fn(() => true),
|
||||
isTheme: vi.fn(() => true),
|
||||
isThemeStyle: vi.fn(() => true),
|
||||
themeForStyle: vi.fn(() => "dark"),
|
||||
}));
|
||||
|
||||
import { reduce } from "./App";
|
||||
|
||||
function initialState(): Parameters<typeof reduce>[0] {
|
||||
return {
|
||||
ready: false,
|
||||
needsSetup: false,
|
||||
busy: false,
|
||||
messages: [],
|
||||
pendingConfirms: [],
|
||||
pendingPathAccess: [],
|
||||
pendingChoices: [],
|
||||
pendingPlans: [],
|
||||
pendingCheckpoints: [],
|
||||
pendingRevisions: [],
|
||||
activePlan: null,
|
||||
usage: { totalCostUsd: 0, tokens: { input: 0, output: 0, cacheHit: 0, cacheMiss: 0 } },
|
||||
sessions: [],
|
||||
settings: null,
|
||||
qq: null,
|
||||
balance: null,
|
||||
mentionResults: null,
|
||||
mentionPreview: null,
|
||||
mcpSpecs: [],
|
||||
mcpBridged: false,
|
||||
skills: [],
|
||||
sessionFiles: [],
|
||||
memory: [],
|
||||
jobs: [],
|
||||
activeSkill: null,
|
||||
queuedSends: [],
|
||||
retryNonce: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function makeShellPrompt(command: string): import("@reasonix/core-utils").ApprovalPrompt {
|
||||
return {
|
||||
id: 1,
|
||||
kind: "shell",
|
||||
tone: "warn",
|
||||
title: "Run command",
|
||||
subtitle: command,
|
||||
preview: command,
|
||||
meta: {},
|
||||
actions: [
|
||||
{ id: "run_once", label: "Run once", kind: "allow_once" },
|
||||
{ id: "always_allow", label: "Always allow", kind: "allow_always" },
|
||||
{
|
||||
id: "deny",
|
||||
label: "Deny",
|
||||
kind: "reject",
|
||||
secondaryInput: { hint: "Reason", required: false },
|
||||
},
|
||||
],
|
||||
data: { prefix: command.split(" ")[0] ?? "" },
|
||||
};
|
||||
}
|
||||
|
||||
function makePathPrompt(
|
||||
path: string,
|
||||
intent: "read" | "write",
|
||||
): import("@reasonix/core-utils").ApprovalPrompt {
|
||||
return {
|
||||
id: 2,
|
||||
kind: "path",
|
||||
tone: "warn",
|
||||
title: `Access path — ${intent}`,
|
||||
subtitle: path,
|
||||
preview: `tool → ${path}`,
|
||||
meta: { sandboxRoot: "/workspace" },
|
||||
actions: [
|
||||
{
|
||||
id: "run_once",
|
||||
label: intent === "write" ? "Allow write" : "Allow read",
|
||||
kind: "allow_once",
|
||||
},
|
||||
{ id: "always_allow", label: "Always allow", kind: "allow_always" },
|
||||
{
|
||||
id: "deny",
|
||||
label: "Deny",
|
||||
kind: "reject",
|
||||
secondaryInput: { hint: "Reason", required: false },
|
||||
},
|
||||
],
|
||||
data: { prefix: "/workspace", intent },
|
||||
};
|
||||
}
|
||||
|
||||
describe("Desktop App reducer — ApprovalPrompt integration", () => {
|
||||
it("stores shell confirm with prompt on $confirm_required", () => {
|
||||
const state = initialState();
|
||||
const prompt = makeShellPrompt("git status");
|
||||
const next = reduce(state, {
|
||||
t: "incoming",
|
||||
event: {
|
||||
type: "$confirm_required",
|
||||
id: 7,
|
||||
kind: "run_command",
|
||||
command: "git status",
|
||||
prompt,
|
||||
},
|
||||
});
|
||||
expect(next.pendingConfirms).toHaveLength(1);
|
||||
expect(next.pendingConfirms[0]).toMatchObject({
|
||||
id: 7,
|
||||
kind: "run_command",
|
||||
command: "git status",
|
||||
});
|
||||
expect(next.pendingConfirms[0].prompt).toEqual(prompt);
|
||||
});
|
||||
|
||||
it("stores path access with prompt on $path_access_required", () => {
|
||||
const state = initialState();
|
||||
const prompt = makePathPrompt("/etc/passwd", "read");
|
||||
const next = reduce(state, {
|
||||
t: "incoming",
|
||||
event: {
|
||||
type: "$path_access_required",
|
||||
id: 8,
|
||||
path: "/etc/passwd",
|
||||
intent: "read",
|
||||
toolName: "read_file",
|
||||
sandboxRoot: "/workspace",
|
||||
allowPrefix: "/workspace",
|
||||
prompt,
|
||||
},
|
||||
});
|
||||
expect(next.pendingPathAccess).toHaveLength(1);
|
||||
expect(next.pendingPathAccess[0]).toMatchObject({
|
||||
id: 8,
|
||||
path: "/etc/passwd",
|
||||
intent: "read",
|
||||
});
|
||||
expect(next.pendingPathAccess[0].prompt).toEqual(prompt);
|
||||
});
|
||||
|
||||
it("removes confirm on resolve_confirm", () => {
|
||||
const prompt = makeShellPrompt("ls");
|
||||
const state = {
|
||||
...initialState(),
|
||||
pendingConfirms: [
|
||||
{ id: 1, kind: "run_command" as const, command: "ls", prompt },
|
||||
{
|
||||
id: 2,
|
||||
kind: "run_command" as const,
|
||||
command: "pwd",
|
||||
prompt: { ...prompt, id: 2, subtitle: "pwd" },
|
||||
},
|
||||
],
|
||||
};
|
||||
const next = reduce(state, { t: "resolve_confirm", id: 1 });
|
||||
expect(next.pendingConfirms).toHaveLength(1);
|
||||
expect(next.pendingConfirms[0].id).toBe(2);
|
||||
});
|
||||
|
||||
it("removes path access on resolve_path_access", () => {
|
||||
const prompt = makePathPrompt("/tmp", "write");
|
||||
const state = {
|
||||
...initialState(),
|
||||
pendingPathAccess: [
|
||||
{
|
||||
id: 3,
|
||||
path: "/tmp",
|
||||
intent: "write" as const,
|
||||
toolName: "write_file",
|
||||
sandboxRoot: "/workspace",
|
||||
allowPrefix: "/workspace",
|
||||
prompt,
|
||||
},
|
||||
],
|
||||
};
|
||||
const next = reduce(state, { t: "resolve_path_access", id: 3 });
|
||||
expect(next.pendingPathAccess).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("clears all pending on clear action", () => {
|
||||
const shellPrompt = makeShellPrompt("echo hi");
|
||||
const pathPrompt = makePathPrompt("/x", "read");
|
||||
const state = {
|
||||
...initialState(),
|
||||
pendingConfirms: [
|
||||
{ id: 1, kind: "run_command" as const, command: "echo hi", prompt: shellPrompt },
|
||||
],
|
||||
pendingPathAccess: [
|
||||
{
|
||||
id: 2,
|
||||
path: "/x",
|
||||
intent: "read" as const,
|
||||
toolName: "read_file",
|
||||
sandboxRoot: "/ws",
|
||||
allowPrefix: "/ws",
|
||||
prompt: pathPrompt,
|
||||
},
|
||||
],
|
||||
};
|
||||
const next = reduce(state, { t: "clear" });
|
||||
expect(next.pendingConfirms).toHaveLength(0);
|
||||
expect(next.pendingPathAccess).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { t, useLang } from "./i18n";
|
||||
import { Shortcut, type ShortcutKey } from "./ui/shortcut";
|
||||
|
||||
export type CommandGroup = "nav" | "action" | "workspace" | "settings";
|
||||
|
||||
@@ -22,7 +23,7 @@ export type Command = {
|
||||
label: string;
|
||||
hint?: string;
|
||||
icon: ReactNode;
|
||||
shortcut?: string[];
|
||||
shortcut?: ShortcutKey[];
|
||||
group: CommandGroup;
|
||||
run: () => void;
|
||||
};
|
||||
@@ -55,6 +56,7 @@ export type CommandHandlers = {
|
||||
about: () => void;
|
||||
abort: () => void;
|
||||
copyLast: () => void;
|
||||
conversationCopy: () => void;
|
||||
exportMarkdown: () => void;
|
||||
pickWorkspace: () => void;
|
||||
newTab: () => void;
|
||||
@@ -72,7 +74,7 @@ export function buildCommands(handlers: CommandHandlers): Command[] {
|
||||
label: t("palette.newChat"),
|
||||
hint: t("palette.newChatHint"),
|
||||
icon: <FilePlus size={13} />,
|
||||
shortcut: ["⌘", "N"],
|
||||
shortcut: ["mod", "N"],
|
||||
run: handlers.newChat,
|
||||
},
|
||||
{
|
||||
@@ -81,7 +83,7 @@ export function buildCommands(handlers: CommandHandlers): Command[] {
|
||||
label: t("palette.newTab"),
|
||||
hint: t("palette.newTabHint"),
|
||||
icon: <Plus size={13} />,
|
||||
shortcut: ["⌘", "T"],
|
||||
shortcut: ["mod", "T"],
|
||||
run: handlers.newTab,
|
||||
},
|
||||
];
|
||||
@@ -92,7 +94,7 @@ export function buildCommands(handlers: CommandHandlers): Command[] {
|
||||
label: t("palette.closeTab"),
|
||||
hint: t("palette.closeTabHint"),
|
||||
icon: <SquareX size={13} />,
|
||||
shortcut: ["⌘", "W"],
|
||||
shortcut: ["mod", "W"],
|
||||
run: handlers.closeTab,
|
||||
});
|
||||
}
|
||||
@@ -101,7 +103,7 @@ export function buildCommands(handlers: CommandHandlers): Command[] {
|
||||
group: "nav",
|
||||
label: t("palette.focusComposer"),
|
||||
icon: <FocusIcon size={13} />,
|
||||
shortcut: ["⌘", "L"],
|
||||
shortcut: ["mod", "L"],
|
||||
run: handlers.focusComposer,
|
||||
});
|
||||
if (handlers.busy) {
|
||||
@@ -124,6 +126,14 @@ export function buildCommands(handlers: CommandHandlers): Command[] {
|
||||
icon: <ClipboardCopy size={13} />,
|
||||
run: handlers.copyLast,
|
||||
});
|
||||
list.push({
|
||||
id: "copy-conv",
|
||||
group: "action",
|
||||
label: t("palette.copyConv"),
|
||||
hint: t("palette.copyConvHint"),
|
||||
icon: <ClipboardCopy size={13} />,
|
||||
run: handlers.conversationCopy,
|
||||
});
|
||||
list.push({
|
||||
id: "export-md",
|
||||
group: "action",
|
||||
@@ -262,7 +272,7 @@ export function CommandPalette({
|
||||
}}
|
||||
/>
|
||||
<span className="hint">
|
||||
<kbd>esc</kbd>
|
||||
<Shortcut keys={["esc"]} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="cmdk-body" ref={listRef}>
|
||||
@@ -287,7 +297,9 @@ export function CommandPalette({
|
||||
<span className="l">{c.label}</span>
|
||||
<span className="g">{groupLabel(c.group)}</span>
|
||||
{c.shortcut ? (
|
||||
<span className="kb">{c.shortcut.join("")}</span>
|
||||
<span className="kb">
|
||||
<Shortcut keys={c.shortcut} />
|
||||
</span>
|
||||
) : (
|
||||
<span className="kb-empty" />
|
||||
)}
|
||||
@@ -299,15 +311,15 @@ export function CommandPalette({
|
||||
</div>
|
||||
<div className="cmdk-foot">
|
||||
<span>
|
||||
<kbd>↑↓</kbd>
|
||||
<Shortcut keys={["updown"]} />
|
||||
{t("palette.footMove")}
|
||||
</span>
|
||||
<span>
|
||||
<kbd>⏎</kbd>
|
||||
<Shortcut keys={["enter"]} />
|
||||
{t("palette.footRun")}
|
||||
</span>
|
||||
<span>
|
||||
<kbd>esc</kbd>
|
||||
<Shortcut keys={["esc"]} />
|
||||
{t("palette.footClose")}
|
||||
</span>
|
||||
<span style={{ marginLeft: "auto", color: "var(--muted)" }}>
|
||||
|
||||
@@ -13,9 +13,12 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
import remarkBreaks from "remark-breaks";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import remarkMath from "remark-math";
|
||||
import { CodeView } from "./CodeView";
|
||||
import { t, useLang } from "./i18n";
|
||||
|
||||
async function openWithEditor(
|
||||
editor: string | undefined,
|
||||
@@ -43,20 +46,79 @@ function resolveAgainstWorkspace(rel: string, ws: string | undefined): string {
|
||||
|
||||
const KNOWN_EXTS =
|
||||
"ts|tsx|mts|cts|js|jsx|mjs|cjs|py|pyi|rs|go|json|jsonc|md|mdx|css|scss|less|html|htm|xml|svg|yaml|yml|toml|sh|bash|zsh|fish|sql|rb|java|kt|swift|c|cpp|cc|cxx|h|hpp|hxx|cs|php|lua|dart|ex|exs|erl|hs|clj|cljs|zig|vue|svelte|graphql|gql|proto";
|
||||
const PATH_SEG = "[\\w.@()+~%#=-]+";
|
||||
const FILE_NAME_SOURCE = `${PATH_SEG}\\.(?:${KNOWN_EXTS})`;
|
||||
const FILE_REF_SOURCE = [
|
||||
`[A-Za-z]:[\\\\/](?:${PATH_SEG}[\\\\/])*${FILE_NAME_SOURCE}`,
|
||||
`/(?:${PATH_SEG}[\\\\/])*${FILE_NAME_SOURCE}`,
|
||||
`(?:\\.{1,2}[\\\\/])?(?:${PATH_SEG}[\\\\/])+${FILE_NAME_SOURCE}`,
|
||||
FILE_NAME_SOURCE,
|
||||
].join("|");
|
||||
const LINE_REF_SOURCE = "(?::(\\d+(?::\\d+)?(?:-\\d+)?))?";
|
||||
// No lookbehind here — Tauri's WKWebView on macOS Monterey (Safari < 16.4)
|
||||
// can't parse `(?<=...)` and the whole bundle fails to load with an
|
||||
// "invalid group specifier name" error. Capture the leading char as
|
||||
// group 1 instead and let splitFilePaths skip past it. Issue #1209.
|
||||
const FILE_PATH_RE = new RegExp(
|
||||
`(?:^|(?<=[\\s\`'"(\\[]))((?:[\\w.-]+\\/)+[\\w.-]+\\.(?:${KNOWN_EXTS}))(?::(\\d+(?:-\\d+)?))?(?=[\\s.,;!?\\]\\)'"\`]|$)`,
|
||||
`(^|[\\s\`'"(\\[])(${FILE_REF_SOURCE})${LINE_REF_SOURCE}(?=[\\s.,;!?\\]\\)'"\`]|$)`,
|
||||
"g",
|
||||
);
|
||||
const EXACT_FILE_REF_RE = new RegExp(`^(${FILE_REF_SOURCE})${LINE_REF_SOURCE}$`);
|
||||
|
||||
type ParsedFileRef = { path: string; line?: string };
|
||||
|
||||
function firstLine(line?: string): number | undefined {
|
||||
if (!line) return undefined;
|
||||
const parsed = Number.parseInt(line.split(/[:-]/)[0] ?? line, 10);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function parseFileRef(value: string): ParsedFileRef | null {
|
||||
const trimmed = value.trim();
|
||||
const m = EXACT_FILE_REF_RE.exec(trimmed);
|
||||
if (!m) return null;
|
||||
return { path: m[1]!, line: m[2] };
|
||||
}
|
||||
|
||||
function decodeMaybeUri(value: string): string {
|
||||
try {
|
||||
return decodeURI(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function stripFileScheme(value: string): string {
|
||||
if (!/^file:\/\//i.test(value)) return value;
|
||||
let raw = decodeMaybeUri(value.replace(/^file:\/\//i, ""));
|
||||
if (/^\/[a-zA-Z]:[\\/]/.test(raw)) raw = raw.slice(1);
|
||||
return raw;
|
||||
}
|
||||
|
||||
function protocolScheme(value: string): string | null {
|
||||
if (/^[a-zA-Z]:[\\/]/.test(value)) return null;
|
||||
return /^([a-z][\w+.-]*):/i.exec(value)?.[1]?.toLowerCase() ?? null;
|
||||
}
|
||||
|
||||
function parseFileHref(value: string): ParsedFileRef | null {
|
||||
const stripped = stripFileScheme(value);
|
||||
const decoded = decodeMaybeUri(stripped);
|
||||
const hashLine = /#L?(\d+)/i.exec(decoded)?.[1];
|
||||
const clean = decoded.split("#")[0]!.split("?")[0]!;
|
||||
const parsed = parseFileRef(clean);
|
||||
if (!parsed) return null;
|
||||
return { ...parsed, line: parsed.line ?? hashLine };
|
||||
}
|
||||
|
||||
function FilePill({ path, line }: { path: string; line?: string }) {
|
||||
useLang();
|
||||
const ctx = useContext(WorkspaceContext);
|
||||
const [done, setDone] = useState<"open" | "copy" | null>(null);
|
||||
const display = line ? `${path}:${line}` : path;
|
||||
const openInEditor = async () => {
|
||||
try {
|
||||
const abs = resolveAgainstWorkspace(path, ctx.dir);
|
||||
const lineNum = line ? Number.parseInt(line.split("-")[0] ?? line, 10) : undefined;
|
||||
await openWithEditor(ctx.editor, abs, Number.isFinite(lineNum) ? lineNum : undefined);
|
||||
await openWithEditor(ctx.editor, abs, firstLine(line));
|
||||
setDone("open");
|
||||
setTimeout(() => setDone(null), 1200);
|
||||
} catch {
|
||||
@@ -95,7 +157,7 @@ function FilePill({ path, line }: { path: string; line?: string }) {
|
||||
void openInEditor();
|
||||
}
|
||||
}}
|
||||
title="click to open · right-click to copy"
|
||||
title={t("markdown.filePillTitle")}
|
||||
>
|
||||
<FileText size={10} className="file-pill-icon" />
|
||||
<span className="file-pill-path">{path}</span>
|
||||
@@ -111,9 +173,13 @@ function splitFilePaths(text: string): ReactNode[] | string {
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null = FILE_PATH_RE.exec(text);
|
||||
while (m !== null) {
|
||||
if (m.index > last) out.push(text.slice(last, m.index));
|
||||
out.push(<FilePill key={`fp-${m.index}`} path={m[1]!} line={m[2]} />);
|
||||
last = m.index + m[0].length;
|
||||
const prefix = m[1] ?? "";
|
||||
const path = m[2]!;
|
||||
const line = m[3];
|
||||
const pillStart = m.index + prefix.length;
|
||||
if (pillStart > last) out.push(text.slice(last, pillStart));
|
||||
out.push(<FilePill key={`fp-${pillStart}`} path={path} line={line} />);
|
||||
last = pillStart + path.length + (line ? line.length + 1 : 0);
|
||||
m = FILE_PATH_RE.exec(text);
|
||||
}
|
||||
if (out.length === 0) return text;
|
||||
@@ -127,6 +193,9 @@ function withFilePills(children: ReactNode): ReactNode {
|
||||
return Children.map(children, (child) => {
|
||||
if (typeof child === "string") return splitFilePaths(child);
|
||||
if (isValidElement(child)) {
|
||||
if (typeof child.type === "string" && ["a", "code", "pre"].includes(child.type)) {
|
||||
return child;
|
||||
}
|
||||
const props = child.props as AnyProps;
|
||||
if (props.children !== undefined) {
|
||||
return cloneElement(child, undefined, withFilePills(props.children));
|
||||
@@ -140,7 +209,8 @@ export const Markdown = memo(function Markdown({ source }: { source: string }) {
|
||||
return (
|
||||
<div className="markdown">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||
remarkPlugins={[remarkGfm, remarkBreaks, remarkMath]}
|
||||
rehypePlugins={[[rehypeKatex, { throwOnError: false }]]}
|
||||
components={{
|
||||
pre: ({ children }) => {
|
||||
const codeEl = Children.toArray(children).find(
|
||||
@@ -152,7 +222,12 @@ export const Markdown = memo(function Markdown({ source }: { source: string }) {
|
||||
const lang = /language-([\w-]+)/.exec(codeEl.props.className ?? "")?.[1] ?? "text";
|
||||
return <CodeBlock lang={lang} text={text} />;
|
||||
},
|
||||
code: ({ className, children }) => <code className={className}>{children}</code>,
|
||||
code: ({ className, children }) => {
|
||||
const text = String(children ?? "");
|
||||
const parsed = !className ? parseFileRef(text.trim()) : null;
|
||||
if (parsed) return <FilePill path={parsed.path} line={parsed.line} />;
|
||||
return <code className={className}>{children}</code>;
|
||||
},
|
||||
a: ({ href, children }) => <SafeLink href={href}>{children}</SafeLink>,
|
||||
p: ({ children }) => <p>{withFilePills(children)}</p>,
|
||||
li: ({ children }) => <li>{withFilePills(children)}</li>,
|
||||
@@ -166,9 +241,11 @@ export const Markdown = memo(function Markdown({ source }: { source: string }) {
|
||||
});
|
||||
|
||||
function SafeLink({ href, children }: { href?: string; children: ReactNode }) {
|
||||
useLang();
|
||||
const ctx = useContext(WorkspaceContext);
|
||||
const [done, setDone] = useState(false);
|
||||
const isExternal = !!href && /^https?:\/\//i.test(href);
|
||||
const scheme = href ? protocolScheme(href) : null;
|
||||
const isExternal = !!scheme && scheme !== "file";
|
||||
const onClick = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
if (!href) return;
|
||||
@@ -181,9 +258,10 @@ function SafeLink({ href, children }: { href?: string; children: ReactNode }) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const stripped = href.replace(/^file:\/\//, "");
|
||||
const abs = resolveAgainstWorkspace(stripped, ctx.dir);
|
||||
await openWithEditor(ctx.editor, abs);
|
||||
const parsed = parseFileHref(href);
|
||||
const target = parsed ?? { path: decodeMaybeUri(stripFileScheme(href)) };
|
||||
const abs = resolveAgainstWorkspace(target.path, ctx.dir);
|
||||
await openWithEditor(ctx.editor, abs, firstLine(target.line));
|
||||
} catch {
|
||||
try {
|
||||
await navigator.clipboard.writeText(href);
|
||||
@@ -199,7 +277,11 @@ function SafeLink({ href, children }: { href?: string; children: ReactNode }) {
|
||||
href={href ?? "#"}
|
||||
onClick={onClick}
|
||||
className={`md-link ${isExternal ? "external" : "local"} ${done ? "done" : ""}`}
|
||||
title={isExternal ? `open ${href} in browser` : `open ${href}`}
|
||||
title={
|
||||
isExternal
|
||||
? t("markdown.externalLinkTitle", { href: href ?? "" })
|
||||
: t("markdown.localLinkTitle", { href: href ?? "" })
|
||||
}
|
||||
>
|
||||
{children}
|
||||
{isExternal ? (
|
||||
@@ -212,6 +294,7 @@ function SafeLink({ href, children }: { href?: string; children: ReactNode }) {
|
||||
}
|
||||
|
||||
function CodeBlock({ lang, text }: { lang: string; text: string }): ReactNode {
|
||||
useLang();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const onCopy = async () => {
|
||||
try {
|
||||
@@ -228,7 +311,7 @@ function CodeBlock({ lang, text }: { lang: string; text: string }): ReactNode {
|
||||
<span className="codeblock-lang">{lang}</span>
|
||||
<button type="button" className={`copy-btn ${copied ? "done" : ""}`} onClick={onCopy}>
|
||||
{copied ? <Check size={11} /> : <Copy size={11} />}
|
||||
{copied ? "copied" : "copy"}
|
||||
{copied ? t("markdown.copied") : t("markdown.copy")}
|
||||
</button>
|
||||
</div>
|
||||
<CodeView text={text} lang={lang} />
|
||||
|
||||
@@ -15,9 +15,11 @@ export const en = {
|
||||
abortHint: "Interrupt the model mid-generation",
|
||||
copyLast: "Copy last reply",
|
||||
copyLastHint: "Copy the latest assistant reply",
|
||||
copyConv: "Copy conversation as Markdown",
|
||||
copyConvHint: "Copy the whole conversation to the clipboard",
|
||||
exportMd: "Export conversation as Markdown",
|
||||
exportMdHint: "Copy the whole conversation to the clipboard",
|
||||
clearChat: "Clear messages",
|
||||
exportMdHint: "Save as .md file",
|
||||
clearChat: "Clear screen",
|
||||
clearChatHint: "Clear the UI only — subprocess stays alive",
|
||||
focusComposer: "Focus composer",
|
||||
pickWorkspace: "Switch workspace…",
|
||||
@@ -31,6 +33,19 @@ export const en = {
|
||||
groupSettings: "Settings",
|
||||
countSuffix: "items",
|
||||
},
|
||||
about: {
|
||||
sidebarLabel: "About",
|
||||
tagline: "DeepSeek-native coding agent",
|
||||
version: "Version",
|
||||
repo: "Repository",
|
||||
checkUpdates: "Check for updates",
|
||||
checking: "Checking…",
|
||||
upToDate: "Up to date ({version})",
|
||||
updateAvailable: "Update available: {version}",
|
||||
openReleases: "View releases",
|
||||
checkFailed: "Check failed: {message}",
|
||||
close: "Close",
|
||||
},
|
||||
workdir: {
|
||||
title: "Switch workspace",
|
||||
searchPlaceholder: "Search recent paths…",
|
||||
@@ -74,6 +89,9 @@ export const en = {
|
||||
confirm: "confirm",
|
||||
close: "close",
|
||||
imageFilterName: "Images",
|
||||
commandsLabel: "commands",
|
||||
mentionLabel: "mention",
|
||||
workspaceRoot: "workspace root",
|
||||
},
|
||||
dragDrop: {
|
||||
overlay: "Drop to attach as @-mention",
|
||||
@@ -98,6 +116,16 @@ export const en = {
|
||||
themeLight: "light",
|
||||
themeDarkDesc: "default · low-light",
|
||||
themeLightDesc: "high-contrast · daytime",
|
||||
themeStyle: "Style",
|
||||
themeStyleHint: "Choose the visual palette for the selected light or dark mode.",
|
||||
themeStyleGraphite: "Graphite",
|
||||
themeStyleGraphiteDesc: "Original dark palette with neutral graphite panels.",
|
||||
themeStyleSandstone: "Sandstone",
|
||||
themeStyleSandstoneDesc: "Original warm light palette.",
|
||||
themeStylePorcelain: "Porcelain",
|
||||
themeStylePorcelainDesc: "Clean Codex-like light palette with quiet contrast.",
|
||||
themeStyleMidnight: "Midnight",
|
||||
themeStyleMidnightDesc: "Navy dark palette with cool blue surfaces.",
|
||||
fontScale: "Font scale",
|
||||
fontScaleHint: "Scales the whole UI; pick larger for high-DPI screens.",
|
||||
fontScaleSmall: "small",
|
||||
@@ -108,6 +136,9 @@ export const en = {
|
||||
fontFamilySans: "sans",
|
||||
fontFamilySystem: "system",
|
||||
fontFamilySerif: "serif",
|
||||
fontFamilyCustom: "custom",
|
||||
customFontFamily: "Custom font stack",
|
||||
customFontFamilyHint: "CSS font-family value for the whole desktop UI.",
|
||||
reasoningEffort: "Reasoning effort",
|
||||
reasoningEffortHint: "Applies live to the running session.",
|
||||
effortHigh: "high",
|
||||
@@ -169,6 +200,39 @@ export const en = {
|
||||
workspaceSection: "Workspace",
|
||||
behaviorSection: "Behavior",
|
||||
apiSection: "DeepSeek API",
|
||||
qqSection: "QQ Channel",
|
||||
qqTitle: "QQ bot integration",
|
||||
qqConnected: "Connected",
|
||||
qqConnecting: "Connecting",
|
||||
qqEnabled: "Enabled",
|
||||
qqFailed: "Failed",
|
||||
qqDisconnected: "Disconnected",
|
||||
qqSummaryMissing: "Credentials not configured yet",
|
||||
qqSummaryAppId: "App ID {appId}",
|
||||
qqSummaryDetail: "{appId} · {environment} · {access}",
|
||||
qqConnect: "Connect",
|
||||
qqConfigure: "Configure…",
|
||||
qqDisconnect: "Disconnect",
|
||||
qqConfigureTitle: "Configure QQ Channel",
|
||||
qqConfigureHint: "Register a QQ bot to receive and reply to messages.",
|
||||
qqEnvironment: "QQ environment",
|
||||
qqEnvironmentHint:
|
||||
"Choose the QQ Open Platform environment for this bot. Use Sandbox for testing and Production for live use.",
|
||||
qqBack: "Back",
|
||||
qqAppId: "App ID",
|
||||
qqAppSecret: "App Secret",
|
||||
qqSandbox: "Sandbox",
|
||||
qqProduction: "Production",
|
||||
qqAccess: "Access",
|
||||
qqAccessOwner: "Owner {openId}",
|
||||
qqAccessOwnerWithAllowlist: "Owner {openId} + allowlist {count}",
|
||||
qqAccessAllowlist: "Allowlist {count}",
|
||||
qqAccessRuntime: "First sender (runtime only, {openId})",
|
||||
qqAccessOpen: "Open (unbound)",
|
||||
qqApplyLabel: "Apply for QQ Bot",
|
||||
qqApplyAction: "Open apply page",
|
||||
qqSave: "Save",
|
||||
qqSaveAndConnect: "Save and connect",
|
||||
mcpSection: "Configured",
|
||||
currentWorkspace: "Current workspace",
|
||||
notSelected: "(not selected)",
|
||||
@@ -248,6 +312,13 @@ export const en = {
|
||||
autoHint: "Commands matching the allowlist are auto-approved",
|
||||
yoloHint: "All auto-approved · Use with caution",
|
||||
},
|
||||
markdown: {
|
||||
filePillTitle: "click to open · right-click to copy",
|
||||
externalLinkTitle: "open {href} in browser",
|
||||
localLinkTitle: "open {href}",
|
||||
copy: "copy",
|
||||
copied: "copied",
|
||||
},
|
||||
preset: {
|
||||
autoDesc: "Flash → Pro auto-upgrade",
|
||||
flashDesc: "Fast, cheap, long context",
|
||||
@@ -277,6 +348,9 @@ export const en = {
|
||||
cleared: "Cleared",
|
||||
copied: "Copied",
|
||||
copiedMd: "Copied as Markdown",
|
||||
exportedMd: "Exported as Markdown file",
|
||||
exportFailed: "Export failed: {error}",
|
||||
exportDialogTitle: "Export conversation",
|
||||
emptySession: "Session is empty",
|
||||
langSwitched: "Switched to {lang}",
|
||||
modelSwitched: "Switched to {model}",
|
||||
@@ -284,7 +358,7 @@ export const en = {
|
||||
},
|
||||
cmd: {
|
||||
newSession: "New session",
|
||||
clearChat: "Clear current conversation",
|
||||
clearChat: "Clear screen",
|
||||
abort: "Stop streaming output",
|
||||
copyLast: "Copy last reply",
|
||||
switchModel: "Switch model",
|
||||
@@ -293,6 +367,22 @@ export const en = {
|
||||
toggleLang: "Toggle language (CN / EN)",
|
||||
exportMd: "Copy session as Markdown",
|
||||
help: "Show all commands",
|
||||
feedback: "Submit feedback on GitHub (opens browser)",
|
||||
compact: "Fold older turns into a summary (save context)",
|
||||
retry: "Truncate and resend your last message",
|
||||
btw: "Ask a side question (never added to context)",
|
||||
},
|
||||
skill: {
|
||||
generic: "{scope} skill · {runAs}",
|
||||
scope: {
|
||||
builtin: "Builtin",
|
||||
global: "Global",
|
||||
project: "Project",
|
||||
},
|
||||
runAs: {
|
||||
inline: "inline",
|
||||
subagent: "subagent",
|
||||
},
|
||||
},
|
||||
yolo: {
|
||||
banner1: "All tool calls, shell commands, and file edits will be ",
|
||||
@@ -301,7 +391,10 @@ export const en = {
|
||||
switchBack: "Switch to Review",
|
||||
toast: "All operations will be auto-approved without asking",
|
||||
},
|
||||
btwUsage: "▸ /btw <question> — ask a side question without polluting the conversation context.",
|
||||
errorLabel: "Error",
|
||||
warningLabel: "Warning",
|
||||
dismissError: "Dismiss",
|
||||
jumpToBottom: "Jump to bottom",
|
||||
splashSubtitle: "DeepSeek Agents",
|
||||
connecting: "Connecting to reasonix core…",
|
||||
@@ -312,8 +405,9 @@ export const en = {
|
||||
contextPanel: "Context panel",
|
||||
more: "More",
|
||||
commandPalette: "Command palette",
|
||||
copyMd: "Copy conversation as Markdown",
|
||||
exportMd: "Export Markdown",
|
||||
clearChat: "Clear conversation",
|
||||
clearChat: "Clear screen",
|
||||
settings: "Settings",
|
||||
windowControls: "Window controls",
|
||||
close: "Close",
|
||||
@@ -329,8 +423,10 @@ export const en = {
|
||||
noWorkspace: "No workspace selected",
|
||||
running: "Running",
|
||||
clickToSelect: "Click to select workspace",
|
||||
copy: "Copy",
|
||||
copyMd: "Copy conversation as Markdown",
|
||||
export: "Export",
|
||||
exportMd: "Export as Markdown file",
|
||||
newChat: "New chat",
|
||||
abort: "Stop",
|
||||
},
|
||||
@@ -355,12 +451,16 @@ export const en = {
|
||||
update: {
|
||||
available: "New version available · {current} → {latest}",
|
||||
installing: "Installing…",
|
||||
downloading: "Downloading · {downloaded} / {total} ({pct}%)",
|
||||
downloadingUnknown: "Downloading · {downloaded}",
|
||||
failed: "Installation failed",
|
||||
clickToInstall: "Click to install & restart",
|
||||
install: "Install",
|
||||
later: "Later",
|
||||
},
|
||||
exportUserLabel: "You",
|
||||
exportReasoningSummary: "Reasoning",
|
||||
exportToolLabel: "Tool",
|
||||
session: {
|
||||
new: "{workspace} · New session",
|
||||
format: "Session {month}-{day} {hour}:{minute}",
|
||||
@@ -396,6 +496,20 @@ export const en = {
|
||||
memoryCountSuffix: "items",
|
||||
checkpointRewind: "Rewind here",
|
||||
planDefaultTitle: "Plan",
|
||||
streaming: "streaming…",
|
||||
failed: "failed",
|
||||
done: "done",
|
||||
running: "Running",
|
||||
error: "error",
|
||||
result: "result",
|
||||
hits: "hits",
|
||||
applied: "applied",
|
||||
cacheHit: "cache hit",
|
||||
prompt: "prompt",
|
||||
reasoning: "reasoning",
|
||||
output: "output",
|
||||
cost: "cost",
|
||||
elapsed: "elapsed",
|
||||
},
|
||||
statusbar: {
|
||||
offline: "Offline",
|
||||
@@ -403,6 +517,20 @@ export const en = {
|
||||
online: "Online",
|
||||
jobs: "jobs",
|
||||
jobsTip: "Background processes · ⌘J",
|
||||
cacheHit: "cache hit",
|
||||
cache: "cache",
|
||||
tokens: "tokens",
|
||||
thisTurn: "this turn",
|
||||
switchWorkspace: "Switch workspace · {workspace}",
|
||||
switchCurrency: "Switch currency (CNY / USD)",
|
||||
balance: "balance",
|
||||
switchTheme: "Switch theme",
|
||||
themeDark: "dark",
|
||||
themeLight: "light",
|
||||
themeStyleGraphite: "Graphite",
|
||||
themeStyleSandstone: "Sandstone",
|
||||
themeStylePorcelain: "Porcelain",
|
||||
themeStyleMidnight: "Midnight",
|
||||
},
|
||||
jobs: {
|
||||
title: "Background jobs",
|
||||
@@ -419,15 +547,137 @@ export const en = {
|
||||
contextPanel: {
|
||||
reservedKey: "reserved",
|
||||
usedKey: "used",
|
||||
filesTab: "Files",
|
||||
toolsTab: "Tools",
|
||||
memoryTab: "Memory",
|
||||
rulesTab: "Rules",
|
||||
contextTokens: "Context · tokens",
|
||||
cacheKey: "cache",
|
||||
freeKey: "free",
|
||||
filesTitle: "Files in context",
|
||||
filesCount: "{count} files",
|
||||
fileModified: "modified",
|
||||
fileInContext: "in context",
|
||||
mcpTitle: "MCP servers",
|
||||
mcpReadyAll: "{count} ready",
|
||||
mcpReadySome: "{ready}/{count} ready",
|
||||
mcpEmpty: "No MCP servers configured",
|
||||
mcpTools: "{count} tools",
|
||||
mcpReady: "ready",
|
||||
mcpConnecting: "connecting",
|
||||
mcpDisabled: "disabled",
|
||||
mcpFailed: "failed",
|
||||
mcpConfigured: "configured",
|
||||
memoryTitle: "Long-term memory",
|
||||
itemCount: "{count} items",
|
||||
scopeProject: "project",
|
||||
scopeGlobal: "global",
|
||||
autoApproveTitle: "Auto approval",
|
||||
ruleYolo: "YOLO mode · all tool calls auto-approved",
|
||||
ruleReadOnly: "Read-only tools auto-approved",
|
||||
ruleShellAllowlist: "Shell commands matching the allowlist auto-approved",
|
||||
ruleWritesAsk: "Writes and unknown shell commands require confirmation",
|
||||
ruleReview: "Review mode · every tool call requires confirmation",
|
||||
allow: "ALLOW",
|
||||
ask: "ASK",
|
||||
noFilesMsg: "No files in context yet.",
|
||||
noMemoriesMsg: "No memories saved yet.",
|
||||
},
|
||||
sidebarPanel: {
|
||||
sessionTitle: "Session {month}-{day} {hour}:{minute}",
|
||||
justNow: "just now",
|
||||
minutesAgo: "{n}m ago",
|
||||
hoursAgo: "{n}h ago",
|
||||
daysAgo: "{n}d ago",
|
||||
weeksAgo: "{n}w ago",
|
||||
newChat: "New chat",
|
||||
commandPalette: "Command palette",
|
||||
searchSessions: "Search sessions…",
|
||||
recent: "Recent",
|
||||
noSessions: "No sessions yet",
|
||||
noMatches: "No matches",
|
||||
messageCount: "{count} messages",
|
||||
deleteSession: "Delete session",
|
||||
renameSession: "Rename session",
|
||||
renamePlaceholder: "Session title",
|
||||
approvalRules: "Approval rules",
|
||||
settings: "Settings",
|
||||
cancel: "Cancel",
|
||||
delete: "Delete",
|
||||
},
|
||||
extraCards: {
|
||||
sessionUsage: "Session usage",
|
||||
sessionCost: "Cost {costLabel}",
|
||||
approve: "Approve",
|
||||
reject: "Reject",
|
||||
doctorTitle: "/doctor · system check",
|
||||
balance: "balance {balanceLabel}",
|
||||
prompt: "prompt",
|
||||
reasoning: "reasoning",
|
||||
output: "output",
|
||||
cacheHit: "cache hit",
|
||||
cache: "cache",
|
||||
contextWindow: "Context window",
|
||||
topToolsUsage: "Top tools — usage",
|
||||
memoryUser: "USER · preferences",
|
||||
memoryFeedback: "FEEDBACK · corrections",
|
||||
memoryProject: "PROJECT · project rules",
|
||||
memoryReference: "REFERENCE · references",
|
||||
unknownKind: "# unknown card kind — fallback render",
|
||||
},
|
||||
thread: {
|
||||
keepSteps: "Keep {n} remaining step(s)",
|
||||
keepOriginal: "Keep original",
|
||||
you: "You",
|
||||
planRunning: "Plan running · step {step} / {total}",
|
||||
collapse: "Collapse",
|
||||
activePlan: "Active plan",
|
||||
risk: "risk",
|
||||
planStepCount: "{count} step(s)",
|
||||
planConfirmationKind: "plan confirmation",
|
||||
startPlan: "Start plan",
|
||||
approve: "Approve",
|
||||
cancel: "Cancel",
|
||||
refine: "Refine",
|
||||
checkpointKind: "plan checkpoint",
|
||||
checkpointTitle: "step {completed} / {total} complete",
|
||||
checkpointSub: "checkpoint · {completed} / {total}",
|
||||
continue: "Continue",
|
||||
stop: "Stop",
|
||||
revise: "Revise",
|
||||
planRevisionKind: "plan revision",
|
||||
rewritePlan: "Plan rewrite",
|
||||
revisionMeta: "reason · runtime constraint",
|
||||
approveRewrite: "Approve rewrite",
|
||||
shellConfirmationKind: "shell confirmation",
|
||||
runCommand: "Run command",
|
||||
runBackgroundCommand: "Run background command",
|
||||
riskMedium: "risk · medium · {kind}",
|
||||
execute: "Execute",
|
||||
reject: "Reject",
|
||||
alwaysAllow: "Always allow \"{prefix}\"",
|
||||
pathAccessKind: "path access",
|
||||
read: "read",
|
||||
write: "write",
|
||||
readPathOutsideSandbox: "Read path outside sandbox",
|
||||
writePathOutsideSandbox: "Write path outside sandbox",
|
||||
allowRead: "Allow read",
|
||||
allowWrite: "Allow write",
|
||||
alwaysAllowPrefix: "Always allow {prefix}",
|
||||
userChoiceKind: "user choice",
|
||||
optionCount: "{count} options",
|
||||
subagent: "subagent",
|
||||
copyMessage: "Copy this message",
|
||||
copyResponse: "Copy this response",
|
||||
},
|
||||
live: {
|
||||
reasoning: "live · reasoning",
|
||||
running: "running",
|
||||
you: "You",
|
||||
secondsAgo: "{seconds}s ago",
|
||||
deliveredWaiting: "Delivered · waiting for agent",
|
||||
},
|
||||
panel: {
|
||||
renderError: "{panel} panel render error",
|
||||
},
|
||||
};
|
||||
@@ -17,9 +17,11 @@ export const zhCN: typeof en = {
|
||||
abortHint: "打断模型当前生成",
|
||||
copyLast: "复制最近回复",
|
||||
copyLastHint: "复制最近一条助手回复",
|
||||
copyConv: "复制对话为 Markdown",
|
||||
copyConvHint: "整段对话复制到剪贴板",
|
||||
exportMd: "导出对话为 Markdown",
|
||||
exportMdHint: "整段对话复制到剪贴板",
|
||||
clearChat: "清空消息",
|
||||
exportMdHint: "保存为 .md 文件",
|
||||
clearChat: "清屏",
|
||||
clearChatHint: "只清当前 UI,subprocess 不重启",
|
||||
focusComposer: "聚焦输入框",
|
||||
pickWorkspace: "切换工作目录…",
|
||||
@@ -33,6 +35,19 @@ export const zhCN: typeof en = {
|
||||
groupSettings: "设置",
|
||||
countSuffix: "项",
|
||||
},
|
||||
about: {
|
||||
sidebarLabel: "关于",
|
||||
tagline: "DeepSeek 原生编码助手",
|
||||
version: "版本",
|
||||
repo: "代码仓库",
|
||||
checkUpdates: "检查更新",
|
||||
checking: "检查中…",
|
||||
upToDate: "已是最新版本({version})",
|
||||
updateAvailable: "新版本可用:{version}",
|
||||
openReleases: "查看发布页",
|
||||
checkFailed: "检查失败:{message}",
|
||||
close: "关闭",
|
||||
},
|
||||
workdir: {
|
||||
title: "切换工作目录",
|
||||
searchPlaceholder: "搜索最近路径…",
|
||||
@@ -76,6 +91,9 @@ export const zhCN: typeof en = {
|
||||
confirm: "确认",
|
||||
close: "关闭",
|
||||
imageFilterName: "图片",
|
||||
commandsLabel: "命令",
|
||||
mentionLabel: "提及",
|
||||
workspaceRoot: "工作区根目录",
|
||||
},
|
||||
dragDrop: {
|
||||
overlay: "松开以作为 @ 提及附加",
|
||||
@@ -99,6 +117,16 @@ export const zhCN: typeof en = {
|
||||
themeLight: "浅色",
|
||||
themeDarkDesc: "默认 · 暗光环境",
|
||||
themeLightDesc: "高对比 · 白天",
|
||||
themeStyle: "风格",
|
||||
themeStyleHint: "为当前浅色或深色模式选择具体视觉配色。",
|
||||
themeStyleGraphite: "Graphite",
|
||||
themeStyleGraphiteDesc: "原有深色风格,中性石墨面板。",
|
||||
themeStyleSandstone: "Sandstone",
|
||||
themeStyleSandstoneDesc: "原有浅色风格,温暖明亮。",
|
||||
themeStylePorcelain: "Porcelain",
|
||||
themeStylePorcelainDesc: "类似 Codex App 的干净浅色风格。",
|
||||
themeStyleMidnight: "Midnight",
|
||||
themeStyleMidnightDesc: "偏藏青色的深色风格,冷静克制。",
|
||||
fontScale: "字体大小",
|
||||
fontScaleHint: "整体缩放,高分屏可选大档。",
|
||||
fontScaleSmall: "小",
|
||||
@@ -109,6 +137,9 @@ export const zhCN: typeof en = {
|
||||
fontFamilySans: "无衬线",
|
||||
fontFamilySystem: "系统",
|
||||
fontFamilySerif: "衬线",
|
||||
fontFamilyCustom: "自定义",
|
||||
customFontFamily: "自定义字体栈",
|
||||
customFontFamilyHint: "桌面端整体 UI 使用的 CSS font-family 值。",
|
||||
reasoningEffort: "思考深度",
|
||||
reasoningEffortHint: "立即生效。",
|
||||
effortHigh: "high",
|
||||
@@ -166,6 +197,38 @@ export const zhCN: typeof en = {
|
||||
workspaceSection: "工作区",
|
||||
behaviorSection: "行为",
|
||||
apiSection: "DeepSeek API",
|
||||
qqSection: "QQ 通道",
|
||||
qqTitle: "QQ机器人集成",
|
||||
qqConnected: "已连接",
|
||||
qqConnecting: "连接中",
|
||||
qqEnabled: "已启用",
|
||||
qqFailed: "连接失败",
|
||||
qqDisconnected: "已断开",
|
||||
qqSummaryMissing: "尚未配置凭据",
|
||||
qqSummaryAppId: "App ID {appId}",
|
||||
qqSummaryDetail: "{appId} · {environment} · {access}",
|
||||
qqConnect: "连接",
|
||||
qqConfigure: "配置",
|
||||
qqDisconnect: "断开连接",
|
||||
qqConfigureTitle: "配置 QQ 通道",
|
||||
qqConfigureHint: "注册 QQ 机器人以接收和回复消息。",
|
||||
qqEnvironment: "QQ 环境",
|
||||
qqEnvironmentHint: "选择此 QQ Bot 的开放平台环境。测试用沙箱,正式使用选正式。",
|
||||
qqBack: "返回",
|
||||
qqAppId: "App ID",
|
||||
qqAppSecret: "App Secret",
|
||||
qqSandbox: "沙箱",
|
||||
qqProduction: "正式",
|
||||
qqAccess: "访问控制",
|
||||
qqAccessOwner: "所有者 {openId}",
|
||||
qqAccessOwnerWithAllowlist: "所有者 {openId} + 白名单 {count}",
|
||||
qqAccessAllowlist: "白名单 {count}",
|
||||
qqAccessRuntime: "首个私聊用户(仅本次运行,{openId})",
|
||||
qqAccessOpen: "未绑定",
|
||||
qqApplyLabel: "申请 QQ Bot",
|
||||
qqApplyAction: "去申请",
|
||||
qqSave: "保存",
|
||||
qqSaveAndConnect: "保存并连接",
|
||||
mcpSection: "已配置",
|
||||
currentWorkspace: "当前工作目录",
|
||||
notSelected: "未选择",
|
||||
@@ -236,6 +299,13 @@ export const zhCN: typeof en = {
|
||||
autoHint: "命中白名单的命令自动批准",
|
||||
yoloHint: "全部自动批准 · 谨慎使用",
|
||||
},
|
||||
markdown: {
|
||||
filePillTitle: "点击打开 · 右键复制",
|
||||
externalLinkTitle: "在浏览器中打开 {href}",
|
||||
localLinkTitle: "打开 {href}",
|
||||
copy: "复制",
|
||||
copied: "已复制",
|
||||
},
|
||||
preset: {
|
||||
autoDesc: "Flash → Pro 自动升级",
|
||||
flashDesc: "快、便宜、长上下文",
|
||||
@@ -265,6 +335,20 @@ export const zhCN: typeof en = {
|
||||
online: "在线",
|
||||
jobs: "任务",
|
||||
jobsTip: "后台任务 · ⌘J",
|
||||
cacheHit: "缓存命中",
|
||||
cache: "缓存",
|
||||
tokens: "tokens",
|
||||
thisTurn: "本次",
|
||||
switchWorkspace: "切换工作区 · {workspace}",
|
||||
switchCurrency: "切换货币 (CNY / USD)",
|
||||
balance: "余额",
|
||||
switchTheme: "切换主题",
|
||||
themeDark: "深色",
|
||||
themeLight: "浅色",
|
||||
themeStyleGraphite: "Graphite",
|
||||
themeStyleSandstone: "Sandstone",
|
||||
themeStylePorcelain: "Porcelain",
|
||||
themeStyleMidnight: "Midnight",
|
||||
},
|
||||
jobs: {
|
||||
title: "后台任务",
|
||||
@@ -284,6 +368,9 @@ export const zhCN: typeof en = {
|
||||
cleared: "已清空",
|
||||
copied: "已复制",
|
||||
copiedMd: "已复制 Markdown",
|
||||
exportedMd: "已导出 Markdown 文件",
|
||||
exportFailed: "导出失败:{error}",
|
||||
exportDialogTitle: "导出对话",
|
||||
emptySession: "会话为空",
|
||||
langSwitched: "已切换到{lang}",
|
||||
modelSwitched: "已切换到 {model}",
|
||||
@@ -291,7 +378,7 @@ export const zhCN: typeof en = {
|
||||
},
|
||||
cmd: {
|
||||
newSession: "新建会话",
|
||||
clearChat: "清空当前对话",
|
||||
clearChat: "清屏",
|
||||
abort: "中断流式输出",
|
||||
copyLast: "复制最后一条回复",
|
||||
switchModel: "切换模型",
|
||||
@@ -300,6 +387,22 @@ export const zhCN: typeof en = {
|
||||
toggleLang: "切换界面语言 (中 / 英)",
|
||||
exportMd: "复制本会话为 Markdown",
|
||||
help: "查看所有命令",
|
||||
feedback: "在 GitHub 提交反馈(打开浏览器)",
|
||||
compact: "折叠旧轮次为摘要(节省上下文)",
|
||||
retry: "截断并重发你的最后一条消息",
|
||||
btw: "旁路提问(不写入会话上下文)",
|
||||
},
|
||||
skill: {
|
||||
generic: "{scope}技能 · {runAs}",
|
||||
scope: {
|
||||
builtin: "内置",
|
||||
global: "全局",
|
||||
project: "项目",
|
||||
},
|
||||
runAs: {
|
||||
inline: "内联",
|
||||
subagent: "子代理",
|
||||
},
|
||||
},
|
||||
yolo: {
|
||||
banner1: "所有工具调用、shell 命令、文件编辑都会",
|
||||
@@ -308,7 +411,10 @@ export const zhCN: typeof en = {
|
||||
switchBack: "切回 Review",
|
||||
toast: "所有操作将自动批准,不再询问",
|
||||
},
|
||||
btwUsage: "▸ /btw <问题> — 顺便问个题外话,不会写入当前会话上下文。",
|
||||
errorLabel: "错误",
|
||||
warningLabel: "提示",
|
||||
dismissError: "关闭",
|
||||
jumpToBottom: "回到底部",
|
||||
splashSubtitle: "DeepSeek Agents",
|
||||
connecting: "正在连接 reasonix 内核…",
|
||||
@@ -319,8 +425,9 @@ export const zhCN: typeof en = {
|
||||
contextPanel: "上下文面板",
|
||||
more: "更多",
|
||||
commandPalette: "命令面板",
|
||||
copyMd: "复制对话为 Markdown",
|
||||
exportMd: "导出 Markdown",
|
||||
clearChat: "清空对话",
|
||||
clearChat: "清屏",
|
||||
settings: "设置",
|
||||
windowControls: "窗口控制",
|
||||
close: "关闭",
|
||||
@@ -336,8 +443,10 @@ export const zhCN: typeof en = {
|
||||
noWorkspace: "未选择工作区",
|
||||
running: "运行中",
|
||||
clickToSelect: "点击选择工作区",
|
||||
copy: "复制",
|
||||
copyMd: "复制对话为 Markdown",
|
||||
export: "导出",
|
||||
exportMd: "导出为 Markdown 文件",
|
||||
newChat: "新会话",
|
||||
abort: "中断",
|
||||
},
|
||||
@@ -361,12 +470,16 @@ export const zhCN: typeof en = {
|
||||
update: {
|
||||
available: "新版本可用 · {current} → {latest}",
|
||||
installing: "正在安装…",
|
||||
downloading: "下载中 · {downloaded} / {total}({pct}%)",
|
||||
downloadingUnknown: "下载中 · {downloaded}",
|
||||
failed: "安装失败",
|
||||
clickToInstall: "点击安装并重启",
|
||||
install: "安装",
|
||||
later: "稍后",
|
||||
},
|
||||
exportUserLabel: "你",
|
||||
exportReasoningSummary: "思考",
|
||||
exportToolLabel: "工具",
|
||||
session: {
|
||||
new: "{workspace} · 新会话",
|
||||
format: "会话 {month}-{day} {hour}:{minute}",
|
||||
@@ -402,19 +515,155 @@ export const zhCN: typeof en = {
|
||||
memoryCountSuffix: "项",
|
||||
checkpointRewind: "回到此处",
|
||||
planDefaultTitle: "计划",
|
||||
streaming: "流式输出中…",
|
||||
failed: "失败",
|
||||
done: "完成",
|
||||
running: "运行中",
|
||||
error: "错误",
|
||||
result: "结果",
|
||||
hits: "条结果",
|
||||
applied: "已应用",
|
||||
cacheHit: "缓存命中",
|
||||
prompt: "输入",
|
||||
reasoning: "思考",
|
||||
output: "输出",
|
||||
cost: "花费",
|
||||
elapsed: "耗时",
|
||||
},
|
||||
contextPanel: {
|
||||
reservedKey: "预留",
|
||||
usedKey: "已用",
|
||||
filesTab: "文件",
|
||||
toolsTab: "工具",
|
||||
memoryTab: "记忆",
|
||||
rulesTab: "规则",
|
||||
contextTokens: "上下文 · tokens",
|
||||
cacheKey: "缓存",
|
||||
freeKey: "余",
|
||||
filesTitle: "上下文中的文件",
|
||||
filesCount: "{count} 个文件",
|
||||
fileModified: "已修改",
|
||||
fileInContext: "在上下文中",
|
||||
mcpTitle: "MCP 服务器",
|
||||
mcpReadyAll: "{count} 个已就绪",
|
||||
mcpReadySome: "{ready}/{count} 已就绪",
|
||||
mcpEmpty: "未配置 MCP 服务器",
|
||||
mcpTools: "{count} 个工具",
|
||||
mcpReady: "就绪",
|
||||
mcpConnecting: "连接中",
|
||||
mcpDisabled: "已禁用",
|
||||
mcpFailed: "失败",
|
||||
mcpConfigured: "已配置",
|
||||
memoryTitle: "长期记忆",
|
||||
itemCount: "{count} 项",
|
||||
scopeProject: "项目",
|
||||
scopeGlobal: "全局",
|
||||
autoApproveTitle: "自动批准",
|
||||
ruleYolo: "YOLO 模式 · 所有工具调用自动批准",
|
||||
ruleReadOnly: "只读工具自动批准",
|
||||
ruleShellAllowlist: "命中 shell 白名单的命令自动批准",
|
||||
ruleWritesAsk: "写入与未知 shell 命令需确认",
|
||||
ruleReview: "Review 模式 · 每个工具调用都需确认",
|
||||
allow: "ALLOW",
|
||||
ask: "ASK",
|
||||
noFilesMsg: "上下文里还没有文件。",
|
||||
noMemoriesMsg: "还没保存任何记忆。",
|
||||
},
|
||||
sidebarPanel: {
|
||||
sessionTitle: "会话 {month}-{day} {hour}:{minute}",
|
||||
justNow: "刚刚",
|
||||
minutesAgo: "{n} 分钟前",
|
||||
hoursAgo: "{n} 小时前",
|
||||
daysAgo: "{n} 天前",
|
||||
weeksAgo: "{n} 周前",
|
||||
newChat: "新会话",
|
||||
commandPalette: "命令面板",
|
||||
searchSessions: "搜索会话…",
|
||||
recent: "近期",
|
||||
noSessions: "暂无会话",
|
||||
noMatches: "无匹配结果",
|
||||
messageCount: "{count} 条",
|
||||
deleteSession: "删除会话",
|
||||
renameSession: "重命名会话",
|
||||
renamePlaceholder: "会话标题",
|
||||
approvalRules: "审批规则",
|
||||
settings: "设置",
|
||||
cancel: "取消",
|
||||
delete: "删除",
|
||||
},
|
||||
extraCards: {
|
||||
sessionUsage: "会话用量",
|
||||
sessionCost: "花费 {costLabel}",
|
||||
approve: "批准",
|
||||
reject: "拒绝",
|
||||
doctorTitle: "/doctor · 系统体检",
|
||||
balance: "余额 {balanceLabel}",
|
||||
prompt: "输入",
|
||||
reasoning: "思考",
|
||||
output: "输出",
|
||||
cacheHit: "缓存命中",
|
||||
cache: "缓存",
|
||||
contextWindow: "上下文窗口",
|
||||
topToolsUsage: "Top tools — 占用",
|
||||
memoryUser: "USER · 用户偏好",
|
||||
memoryFeedback: "FEEDBACK · 反馈与纠正",
|
||||
memoryProject: "PROJECT · 项目规范",
|
||||
memoryReference: "REFERENCE · 引用资料",
|
||||
unknownKind: "# 未知卡片类型 — fallback 渲染",
|
||||
},
|
||||
thread: {
|
||||
keepSteps: "保留剩余 {n} 步",
|
||||
keepOriginal: "保留原计划",
|
||||
you: "你",
|
||||
planRunning: "计划执行中 · step {step} / {total}",
|
||||
collapse: "收起",
|
||||
activePlan: "活动计划",
|
||||
risk: "risk",
|
||||
planStepCount: "{count} 步",
|
||||
planConfirmationKind: "计划确认",
|
||||
startPlan: "开始执行计划",
|
||||
approve: "批准",
|
||||
cancel: "取消",
|
||||
refine: "细化",
|
||||
checkpointKind: "计划检查点",
|
||||
checkpointTitle: "step {completed} / {total} 完成",
|
||||
checkpointSub: "检查点 · {completed} / {total}",
|
||||
continue: "继续",
|
||||
stop: "停止",
|
||||
revise: "修订",
|
||||
planRevisionKind: "计划修订",
|
||||
rewritePlan: "计划重写",
|
||||
revisionMeta: "原因 · 运行约束",
|
||||
approveRewrite: "批准重写",
|
||||
shellConfirmationKind: "命令确认",
|
||||
runCommand: "运行命令",
|
||||
runBackgroundCommand: "后台运行命令",
|
||||
riskMedium: "risk · 中 · {kind}",
|
||||
execute: "执行",
|
||||
reject: "拒绝",
|
||||
alwaysAllow: "始终允许 \"{prefix}\"",
|
||||
pathAccessKind: "路径访问",
|
||||
read: "读取",
|
||||
write: "写入",
|
||||
readPathOutsideSandbox: "读取沙盒外的路径",
|
||||
writePathOutsideSandbox: "写入沙盒外的路径",
|
||||
allowRead: "允许读取",
|
||||
allowWrite: "允许写入",
|
||||
alwaysAllowPrefix: "始终允许 {prefix}",
|
||||
userChoiceKind: "用户选择",
|
||||
optionCount: "{count} 个选项",
|
||||
subagent: "subagent",
|
||||
copyMessage: "复制这条消息",
|
||||
copyResponse: "复制这条回复",
|
||||
},
|
||||
live: {
|
||||
reasoning: "live · reasoning",
|
||||
running: "运行中",
|
||||
you: "你",
|
||||
secondsAgo: "{seconds}s 前",
|
||||
deliveredWaiting: "已送达 · 等待 agent 响应",
|
||||
},
|
||||
panel: {
|
||||
renderError: "{panel} 面板渲染错误",
|
||||
},
|
||||
};
|
||||
@@ -29,6 +29,7 @@ export const I = {
|
||||
chevR: (p: IconProps) => (<Ic {...p}><path d="m9 6 6 6-6 6" /></Ic>),
|
||||
check: (p: IconProps) => (<Ic {...p}><path d="m5 12 5 5L20 7" /></Ic>),
|
||||
x: (p: IconProps) => (<Ic {...p}><path d="M6 6l12 12M18 6 6 18" /></Ic>),
|
||||
pencil: (p: IconProps) => (<Ic {...p}><path d="m4 20 4-1L20 7l-3-3L5 16l-1 4Z" /><path d="m14 6 4 4" /></Ic>),
|
||||
terminal: (p: IconProps) => (<Ic {...p}><rect x="3" y="4" width="18" height="16" rx="2" /><path d="m7 9 3 3-3 3M13 15h4" /></Ic>),
|
||||
brain: (p: IconProps) => (
|
||||
<Ic {...p}>
|
||||
@@ -75,6 +76,7 @@ export const I = {
|
||||
warn: (p: IconProps) => (<Ic {...p}><path d="M12 3 2 21h20Z" /><path d="M12 10v5M12 18h.01" /></Ic>),
|
||||
help: (p: IconProps) => (<Ic {...p}><circle cx="12" cy="12" r="9" /><path d="M9.5 9a2.5 2.5 0 0 1 5 0c0 1.5-2.5 2-2.5 4M12 17h.01" /></Ic>),
|
||||
refresh: (p: IconProps) => (<Ic {...p}><path d="M3 12a9 9 0 0 1 15-6.7L21 8M21 4v4h-4M21 12a9 9 0 0 1-15 6.7L3 16M3 20v-4h4" /></Ic>),
|
||||
copy: (p: IconProps) => (<Ic {...p}><rect x="8" y="8" width="12" height="12" rx="2" /><path d="M4 16V6a2 2 0 0 1 2-2h10" /></Ic>),
|
||||
};
|
||||
|
||||
export type IconKey = keyof typeof I;
|
||||
@@ -9,19 +9,48 @@ import "@fontsource/inter/400.css";
|
||||
import "@fontsource/inter/500.css";
|
||||
import "@fontsource/inter/600.css";
|
||||
import "@fontsource/inter/700.css";
|
||||
import "katex/dist/katex.min.css";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import { isTheme } from "./theme";
|
||||
import {
|
||||
defaultStyleForTheme,
|
||||
isTheme,
|
||||
isThemeStyle,
|
||||
themeForStyle,
|
||||
} from "./theme";
|
||||
|
||||
const stored = localStorage.getItem("reasonix.theme");
|
||||
if (isTheme(stored)) {
|
||||
const storedStyle = localStorage.getItem("reasonix.themeStyle");
|
||||
if (isThemeStyle(storedStyle)) {
|
||||
document.documentElement.dataset.themeStyle = storedStyle;
|
||||
document.documentElement.dataset.theme = themeForStyle(storedStyle);
|
||||
} else if (isTheme(stored)) {
|
||||
document.documentElement.dataset.theme = stored;
|
||||
document.documentElement.dataset.themeStyle = defaultStyleForTheme(stored);
|
||||
}
|
||||
|
||||
const platform = /Mac|macOS/i.test(navigator.userAgent) ? "macos" : "default";
|
||||
const platform = /Mac|macOS/i.test(navigator.userAgent)
|
||||
? "macos"
|
||||
: /Windows/i.test(navigator.userAgent)
|
||||
? "windows"
|
||||
: "default";
|
||||
document.documentElement.dataset.platform = platform;
|
||||
document.body.dataset.platform = platform;
|
||||
|
||||
// Packaged builds: block F5 / Ctrl+R — webview reload drops React state
|
||||
// and flashes white. Dev keeps the shortcuts for HMR fallback.
|
||||
if (!import.meta.env.DEV) {
|
||||
window.addEventListener(
|
||||
"keydown",
|
||||
(e) => {
|
||||
if (e.key === "F5" || ((e.ctrlKey || e.metaKey) && (e.key === "r" || e.key === "R"))) {
|
||||
e.preventDefault();
|
||||
}
|
||||
},
|
||||
{ capture: true },
|
||||
);
|
||||
}
|
||||
|
||||
const host = document.getElementById("root");
|
||||
if (!host) throw new Error("#root missing");
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ export type PathAccessRequiredEvent = {
|
||||
toolName: string;
|
||||
sandboxRoot: string;
|
||||
allowPrefix: string;
|
||||
prompt?: import("@reasonix/core-utils").ApprovalPrompt;
|
||||
};
|
||||
|
||||
export type ConfirmRequiredEvent = {
|
||||
@@ -16,6 +17,7 @@ export type ConfirmRequiredEvent = {
|
||||
id: number;
|
||||
kind: "run_command" | "run_background";
|
||||
command: string;
|
||||
prompt?: import("@reasonix/core-utils").ApprovalPrompt;
|
||||
};
|
||||
|
||||
export type ConfirmationChoice =
|
||||
@@ -86,10 +88,7 @@ export type RevisionRequiredEvent = {
|
||||
summary?: string;
|
||||
};
|
||||
|
||||
export type RevisionVerdict =
|
||||
| { type: "accepted" }
|
||||
| { type: "rejected" }
|
||||
| { type: "cancelled" };
|
||||
export type RevisionVerdict = { type: "accepted" } | { type: "rejected" } | { type: "cancelled" };
|
||||
|
||||
export type StepCompletedEvent = {
|
||||
type: "$step_completed";
|
||||
@@ -124,6 +123,8 @@ export type MentionPreviewEvent = {
|
||||
export type TabOpenedEvent = {
|
||||
type: "$tab_opened";
|
||||
workspaceDir: string;
|
||||
/** True when the frontend should focus this tab (user-opened, or the restored focused tab). */
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
export type TabClosedEvent = {
|
||||
@@ -168,6 +169,8 @@ export type SkillsEvent = {
|
||||
export type CtxBreakdownEvent = {
|
||||
type: "$ctx_breakdown";
|
||||
reservedTokens: number;
|
||||
/** Current log token count (real-time) — sent after /compact to refresh the meter. */
|
||||
logTokens?: number;
|
||||
};
|
||||
|
||||
export type MemoryEntryInfo = {
|
||||
@@ -181,6 +184,10 @@ export type MemoryEvent = {
|
||||
entries: MemoryEntryInfo[];
|
||||
};
|
||||
|
||||
export type RetryResultEvent = { type: "$retry_result"; text: string };
|
||||
|
||||
export type BtwResultEvent = { type: "$btw_result"; question: string; answer: string };
|
||||
|
||||
export type JobInfo = {
|
||||
id: number;
|
||||
tabId: string;
|
||||
@@ -231,6 +238,12 @@ export type SessionLoadedEvent = {
|
||||
};
|
||||
};
|
||||
|
||||
export type SessionEmptyEvent = {
|
||||
type: "$session_empty";
|
||||
name: string;
|
||||
sizeBytes: number;
|
||||
};
|
||||
|
||||
export type NeedsSetupEvent = {
|
||||
type: "$needs_setup";
|
||||
reason: "no_api_key";
|
||||
@@ -255,6 +268,19 @@ export type SettingsEvent = {
|
||||
version: string;
|
||||
};
|
||||
|
||||
export type QQSettingsEvent = {
|
||||
type: "$qq_settings";
|
||||
appId?: string;
|
||||
appSecret?: string;
|
||||
sandbox: boolean;
|
||||
enabled: boolean;
|
||||
configured: boolean;
|
||||
runtimeState: "disconnected" | "connecting" | "connected" | "failed";
|
||||
lastError?: string;
|
||||
appIdPreview?: string;
|
||||
access: string;
|
||||
};
|
||||
|
||||
export type BalanceEvent = {
|
||||
type: "$balance";
|
||||
currency: string;
|
||||
@@ -272,6 +298,12 @@ export type SettingsPatch = {
|
||||
editor?: string;
|
||||
};
|
||||
|
||||
export type QQConfigPatch = {
|
||||
appId?: string;
|
||||
appSecret?: string;
|
||||
sandbox: boolean;
|
||||
};
|
||||
|
||||
export type UserMessageEvent = {
|
||||
type: "user.message";
|
||||
id: number;
|
||||
@@ -374,8 +406,10 @@ export type IncomingEvent = { tabId?: string } & (
|
||||
| PlanRequiredEvent
|
||||
| SessionsEvent
|
||||
| SessionLoadedEvent
|
||||
| SessionEmptyEvent
|
||||
| NeedsSetupEvent
|
||||
| SettingsEvent
|
||||
| QQSettingsEvent
|
||||
| BalanceEvent
|
||||
| CheckpointRequiredEvent
|
||||
| RevisionRequiredEvent
|
||||
@@ -399,6 +433,8 @@ export type IncomingEvent = { tabId?: string } & (
|
||||
| ToolResultEvent
|
||||
| StatusEvent
|
||||
| KernelErrorEvent
|
||||
| RetryResultEvent
|
||||
| BtwResultEvent
|
||||
);
|
||||
|
||||
export type OutgoingCommand = { tabId?: string } & (
|
||||
@@ -412,15 +448,21 @@ export type OutgoingCommand = { tabId?: string } & (
|
||||
| { cmd: "session_list" }
|
||||
| { cmd: "session_delete"; name: string }
|
||||
| { cmd: "session_load"; name: string }
|
||||
| { cmd: "session_rename"; name: string; title: string }
|
||||
| { cmd: "new_chat" }
|
||||
| { cmd: "setup_save_key"; key: string }
|
||||
| { cmd: "settings_get" }
|
||||
| ({ cmd: "settings_save" } & SettingsPatch)
|
||||
| { cmd: "qq_status_get" }
|
||||
| { cmd: "qq_connect" }
|
||||
| { cmd: "qq_disconnect" }
|
||||
| ({ cmd: "qq_config_save" } & QQConfigPatch)
|
||||
| { cmd: "mention_query"; query: string; nonce: number }
|
||||
| { cmd: "mention_preview"; path: string; nonce: number }
|
||||
| { cmd: "mention_picked"; path: string }
|
||||
| { cmd: "tab_open"; workspaceDir?: string }
|
||||
| { cmd: "tab_close" }
|
||||
| { cmd: "tab_activate"; tabId: string }
|
||||
| { cmd: "mcp_specs_get" }
|
||||
| { cmd: "mcp_specs_add"; spec: string }
|
||||
| { cmd: "mcp_specs_remove"; spec: string }
|
||||
@@ -429,4 +471,7 @@ export type OutgoingCommand = { tabId?: string } & (
|
||||
| { cmd: "jobs_list" }
|
||||
| { cmd: "jobs_stop"; jobId: number }
|
||||
| { cmd: "jobs_stop_all" }
|
||||
| { cmd: "compact_history" }
|
||||
| { cmd: "retry" }
|
||||
| { cmd: "btw"; text: string }
|
||||
);
|
||||
@@ -0,0 +1,63 @@
|
||||
import { t } from "./i18n";
|
||||
|
||||
export interface QQDesktopSettingsState {
|
||||
appId?: string;
|
||||
appSecret?: string;
|
||||
sandbox: boolean;
|
||||
enabled: boolean;
|
||||
configured: boolean;
|
||||
runtimeState: "disconnected" | "connecting" | "connected" | "failed";
|
||||
lastError?: string;
|
||||
appIdPreview?: string;
|
||||
access: string;
|
||||
}
|
||||
|
||||
export function maskQQAppId(appId: string | undefined): string | undefined {
|
||||
if (!appId) return undefined;
|
||||
return appId.length > 6 ? `${appId.slice(0, 6)}...` : appId;
|
||||
}
|
||||
|
||||
export function getQQConnectIntent(qq: QQDesktopSettingsState): "configure" | "connect" {
|
||||
return qq.configured ? "connect" : "configure";
|
||||
}
|
||||
|
||||
export function getQQStatusLabel(qq: QQDesktopSettingsState): string {
|
||||
switch (qq.runtimeState) {
|
||||
case "connected":
|
||||
return t("settings.qqConnected");
|
||||
case "connecting":
|
||||
return t("settings.qqConnecting");
|
||||
case "failed":
|
||||
return t("settings.qqFailed");
|
||||
default:
|
||||
return t("settings.qqDisconnected");
|
||||
}
|
||||
}
|
||||
|
||||
export function describeQQAccessLabel(access: string): string {
|
||||
const owner = /^owner (.+?)(?:, allowlist (\d+))?$/.exec(access);
|
||||
if (owner) {
|
||||
const openId = owner[1] ?? "unknown";
|
||||
const count = owner[2];
|
||||
if (count) return t("settings.qqAccessOwnerWithAllowlist", { openId, count });
|
||||
return t("settings.qqAccessOwner", { openId });
|
||||
}
|
||||
|
||||
const allowlist = /^allowlist (\d+)$/.exec(access);
|
||||
if (allowlist) return t("settings.qqAccessAllowlist", { count: allowlist[1] ?? "0" });
|
||||
|
||||
const runtime = /^first-sender \(runtime only, (.+)\)$/.exec(access);
|
||||
if (runtime) return t("settings.qqAccessRuntime", { openId: runtime[1] ?? "unknown" });
|
||||
|
||||
return t("settings.qqAccessOpen");
|
||||
}
|
||||
|
||||
export function describeQQRowSummary(qq: QQDesktopSettingsState): string {
|
||||
if (!qq.configured) return t("settings.qqSummaryMissing");
|
||||
const appId = qq.appIdPreview ?? maskQQAppId(qq.appId) ?? "unknown";
|
||||
return t("settings.qqSummaryDetail", {
|
||||
appId: t("settings.qqSummaryAppId", { appId }),
|
||||
environment: qq.sandbox ? t("settings.qqSandbox") : t("settings.qqProduction"),
|
||||
access: describeQQAccessLabel(qq.access),
|
||||
});
|
||||
}
|
||||
@@ -51,7 +51,8 @@ body[data-drag-over="1"]::after {
|
||||
/* ---------- THEME TOKENS ---------- */
|
||||
|
||||
:root,
|
||||
[data-theme="dark"] {
|
||||
[data-theme="dark"],
|
||||
[data-theme-style="graphite"] {
|
||||
--bg: oklch(17% 0.005 280);
|
||||
--bg-2: oklch(20% 0.006 275);
|
||||
--panel: oklch(21.5% 0.007 275);
|
||||
@@ -85,7 +86,8 @@ body[data-drag-over="1"]::after {
|
||||
--radius-lg: 12px;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
[data-theme="light"],
|
||||
[data-theme-style="sandstone"] {
|
||||
--bg: oklch(97.5% 0.008 80);
|
||||
--bg-2: oklch(95.5% 0.011 78);
|
||||
--panel: oklch(98.5% 0.005 80);
|
||||
@@ -118,6 +120,70 @@ body[data-drag-over="1"]::after {
|
||||
--shadow-lg: 0 24px 60px -20px oklch(30% 0.05 50 / 0.20);
|
||||
}
|
||||
|
||||
[data-theme-style="porcelain"] {
|
||||
--bg: oklch(99.2% 0.001 100);
|
||||
--bg-2: oklch(96.8% 0.001 100);
|
||||
--panel: oklch(99.8% 0 0);
|
||||
--panel-2: oklch(95.6% 0.001 100);
|
||||
--card: oklch(100% 0 0);
|
||||
--card-hover: oklch(97.6% 0.001 100);
|
||||
--border: oklch(91% 0.002 100);
|
||||
--border-strong: oklch(84% 0.003 100);
|
||||
--fg: oklch(24% 0.006 260);
|
||||
--fg-2: oklch(37% 0.006 260);
|
||||
--muted: oklch(54% 0.005 260);
|
||||
--muted-2: oklch(68% 0.004 260);
|
||||
|
||||
--accent: oklch(58% 0.18 36);
|
||||
--accent-soft: oklch(58% 0.18 36 / 0.1);
|
||||
--accent-strong: oklch(52% 0.2 34);
|
||||
|
||||
--success: oklch(48% 0.13 155);
|
||||
--success-soft: oklch(48% 0.13 155 / 0.1);
|
||||
--warning: oklch(58% 0.14 78);
|
||||
--warning-soft: oklch(58% 0.14 78 / 0.12);
|
||||
--danger: oklch(54% 0.2 22);
|
||||
--danger-soft: oklch(54% 0.2 22 / 0.1);
|
||||
--violet: oklch(56% 0.12 285);
|
||||
--violet-soft: oklch(56% 0.12 285 / 0.1);
|
||||
|
||||
--shadow-sm: 0 1px 0 oklch(20% 0.01 260 / 0.04);
|
||||
--shadow-md: 0 8px 24px -12px oklch(20% 0.01 260 / 0.1);
|
||||
--shadow-lg: 0 24px 60px -22px oklch(20% 0.01 260 / 0.18);
|
||||
}
|
||||
|
||||
[data-theme-style="midnight"] {
|
||||
--bg: oklch(19% 0.024 265);
|
||||
--bg-2: oklch(17% 0.026 260);
|
||||
--panel: oklch(21.5% 0.028 265);
|
||||
--panel-2: oklch(25% 0.03 265);
|
||||
--card: oklch(23% 0.03 265);
|
||||
--card-hover: oklch(27% 0.032 265);
|
||||
--border: oklch(32% 0.032 265);
|
||||
--border-strong: oklch(40% 0.035 265);
|
||||
--fg: oklch(91% 0.02 265);
|
||||
--fg-2: oklch(78% 0.028 265);
|
||||
--muted: oklch(64% 0.03 265);
|
||||
--muted-2: oklch(50% 0.03 265);
|
||||
|
||||
--accent: oklch(73% 0.12 285);
|
||||
--accent-soft: oklch(73% 0.12 285 / 0.16);
|
||||
--accent-strong: oklch(80% 0.13 286);
|
||||
|
||||
--success: oklch(70% 0.13 150);
|
||||
--success-soft: oklch(70% 0.13 150 / 0.14);
|
||||
--warning: oklch(78% 0.13 86);
|
||||
--warning-soft: oklch(78% 0.13 86 / 0.16);
|
||||
--danger: oklch(66% 0.17 25);
|
||||
--danger-soft: oklch(66% 0.17 25 / 0.16);
|
||||
--violet: oklch(76% 0.12 285);
|
||||
--violet-soft: oklch(76% 0.12 285 / 0.16);
|
||||
|
||||
--shadow-sm: 0 1px 0 oklch(0% 0 0 / 0.38);
|
||||
--shadow-md: 0 8px 24px -8px oklch(0% 0 0 / 0.52);
|
||||
--shadow-lg: 0 24px 60px -16px oklch(0% 0 0 / 0.68);
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
@@ -132,6 +198,11 @@ button {
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
@@ -758,7 +829,6 @@ html[data-platform="macos"] .titlebar .tb-left {
|
||||
background: var(--accent-strong);
|
||||
}
|
||||
.side-head .new-btn kbd {
|
||||
margin-left: auto;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
background: oklch(100% 0 0 / 0.18);
|
||||
@@ -766,6 +836,21 @@ html[data-platform="macos"] .titlebar .tb-left {
|
||||
border-radius: 3px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.side-head .new-btn .shortcut {
|
||||
margin-left: auto;
|
||||
}
|
||||
.side-head .new-btn .shortcut kbd {
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: inherit;
|
||||
color: inherit;
|
||||
background: oklch(100% 0 0 / 0.18);
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
box-shadow: none;
|
||||
}
|
||||
.side-head .icon-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
@@ -1061,6 +1146,77 @@ html[data-platform="macos"] .titlebar .tb-left {
|
||||
.session-item .body .meta .sep {
|
||||
color: var(--border-strong);
|
||||
}
|
||||
.session-item .delete-btn {
|
||||
flex-shrink: 0;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 120ms, background 120ms, color 120ms;
|
||||
}
|
||||
.session-item .rename-btn {
|
||||
flex-shrink: 0;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 120ms, background 120ms, color 120ms;
|
||||
}
|
||||
.session-item:hover .delete-btn,
|
||||
.session-item:focus-within .delete-btn,
|
||||
.session-item .delete-btn:focus-visible,
|
||||
.session-item:hover .rename-btn,
|
||||
.session-item:focus-within .rename-btn,
|
||||
.session-item .rename-btn:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
.session-item .delete-btn:hover {
|
||||
background: var(--panel-2, rgba(255, 80, 80, 0.12));
|
||||
color: var(--danger, #e25555);
|
||||
}
|
||||
.session-item .delete-btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--accent-soft);
|
||||
}
|
||||
.session-item .rename-btn:hover {
|
||||
background: var(--panel-2, rgba(255, 255, 255, 0.06));
|
||||
color: var(--fg);
|
||||
}
|
||||
.session-item .rename-btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--accent-soft);
|
||||
}
|
||||
.session-item[data-editing="true"] {
|
||||
cursor: default;
|
||||
}
|
||||
.session-item .body .title-edit {
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
color: var(--fg);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
outline: none;
|
||||
}
|
||||
.session-item .body .title-edit:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent-soft);
|
||||
}
|
||||
|
||||
/* ---- right-click session menu ---- */
|
||||
.session-menu {
|
||||
@@ -1297,6 +1453,32 @@ html[data-platform="macos"] .titlebar .tb-left {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.shortcut {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
vertical-align: baseline;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.shortcut kbd {
|
||||
min-width: 18px;
|
||||
text-align: center;
|
||||
line-height: 1.25;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
color: var(--fg-2);
|
||||
background: linear-gradient(180deg, var(--card), var(--panel));
|
||||
border: 1px solid var(--border);
|
||||
border-bottom-color: var(--border-strong);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
box-shadow: inset 0 1px 0 oklch(100% 0 0 / 0.05);
|
||||
}
|
||||
html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] {
|
||||
min-width: 30px;
|
||||
}
|
||||
|
||||
/* ---------- MAIN ---------- */
|
||||
|
||||
.main {
|
||||
@@ -1642,6 +1824,12 @@ html[data-platform="macos"] .titlebar .tb-left {
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.msg-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.markdown pre {
|
||||
margin: 14px 0;
|
||||
padding: 14px 16px;
|
||||
@@ -1737,28 +1925,29 @@ html[data-platform="macos"] .titlebar .tb-left {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 5px;
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0 2px;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--accent);
|
||||
font-family: "Geist Mono", monospace;
|
||||
font-size: 0.82em;
|
||||
font-size: 0.86em;
|
||||
line-height: 1.35;
|
||||
cursor: pointer;
|
||||
vertical-align: baseline;
|
||||
text-decoration: none;
|
||||
transition: background 0.12s, border-color 0.12s;
|
||||
transition:
|
||||
background 0.12s,
|
||||
color 0.12s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.file-pill:hover {
|
||||
background: var(--accent-soft);
|
||||
border-color: oklch(from var(--accent) l c h / 0.4);
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
.file-pill.done {
|
||||
color: var(--success);
|
||||
border-color: var(--success-soft);
|
||||
background: var(--success-soft);
|
||||
background: transparent;
|
||||
}
|
||||
.file-pill-icon {
|
||||
opacity: 0.6;
|
||||
@@ -2649,8 +2838,8 @@ html[data-platform="macos"] .titlebar .tb-left {
|
||||
padding: 12px 14px 6px;
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
min-height: 24px;
|
||||
max-height: 220px;
|
||||
min-height: calc(5 * 1.55em + 18px);
|
||||
overflow-y: hidden;
|
||||
font-family: inherit;
|
||||
}
|
||||
.composer textarea::placeholder {
|
||||
@@ -2726,6 +2915,7 @@ html[data-platform="macos"] .titlebar .tb-left {
|
||||
background: var(--panel-2);
|
||||
color: var(--muted-2);
|
||||
cursor: not-allowed;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* hint row above composer */
|
||||
@@ -2752,6 +2942,13 @@ html[data-platform="macos"] .titlebar .tb-left {
|
||||
padding: 0 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.hint-row .shortcut kbd {
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
padding: 0 4px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
}
|
||||
/* ModeSwitch inside hint-row — slightly smaller to sit inline with text */
|
||||
.hint-row .mode-switch {
|
||||
font-size: 13px;
|
||||
@@ -2835,8 +3032,12 @@ html[data-platform="macos"] .titlebar .tb-left {
|
||||
margin-right: 6px;
|
||||
}
|
||||
.popup-item .desc {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.popup-item .kb {
|
||||
font-family: inherit;
|
||||
@@ -2846,6 +3047,17 @@ html[data-platform="macos"] .titlebar .tb-left {
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.popup-item .kb .shortcut kbd,
|
||||
.cmdk-row .kb .shortcut kbd {
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
html:not([data-platform="macos"]) .popup-item .kb .shortcut kbd[data-key="mod"],
|
||||
html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] {
|
||||
min-width: 24px;
|
||||
}
|
||||
|
||||
.popup-foot {
|
||||
padding: 6px 12px;
|
||||
@@ -3147,6 +3359,10 @@ html[data-platform="macos"] .titlebar .tb-left {
|
||||
background: var(--panel);
|
||||
color: var(--fg);
|
||||
}
|
||||
.statusbar .seg.theme-trigger.active {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
.statusbar .seg .v {
|
||||
color: var(--fg);
|
||||
}
|
||||
@@ -3191,6 +3407,77 @@ html[data-platform="macos"] .titlebar .tb-left {
|
||||
font-size: 14px;
|
||||
z-index: 20;
|
||||
}
|
||||
.theme-pop {
|
||||
position: fixed;
|
||||
right: 10px;
|
||||
bottom: 32px;
|
||||
width: 280px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 30;
|
||||
overflow: hidden;
|
||||
animation: rise 0.16s ease-out;
|
||||
}
|
||||
.theme-pop-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.theme-pop-head .tt {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--fg);
|
||||
}
|
||||
.theme-pop-head .ss {
|
||||
margin-left: auto;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.theme-pop-list {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
padding: 6px;
|
||||
}
|
||||
.theme-pop-item {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 52px 1fr 16px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
color: var(--fg);
|
||||
text-align: left;
|
||||
}
|
||||
.theme-pop-item:hover,
|
||||
.theme-pop-item[data-on="true"] {
|
||||
background: var(--panel);
|
||||
}
|
||||
.theme-pop-item[data-on="true"] {
|
||||
color: var(--accent);
|
||||
}
|
||||
.theme-pop-item .style-swatches {
|
||||
height: 22px;
|
||||
}
|
||||
.theme-pop-item .txt {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
}
|
||||
.theme-pop-item .nm {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--fg);
|
||||
}
|
||||
.theme-pop-item .md {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* utility */
|
||||
.row {
|
||||
@@ -3220,23 +3507,49 @@ html[data-platform="macos"] .titlebar .tb-left {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.pill-tag.run {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
.pill-tag.ok {
|
||||
background: var(--success-soft);
|
||||
color: var(--success);
|
||||
}
|
||||
.pill-tag.warn {
|
||||
background: var(--warning-soft);
|
||||
color: var(--warning);
|
||||
}
|
||||
.pill-tag.err {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* status icons for card meta — replaces most text pill labels */
|
||||
.spin-meta {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid var(--accent-soft);
|
||||
border-top-color: var(--accent);
|
||||
animation: spin 0.9s linear infinite;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.status-dot.warn { background: var(--warning); }
|
||||
|
||||
.meta-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.meta-dur {
|
||||
font-size: 10px;
|
||||
color: var(--muted-2);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* spinner */
|
||||
.spin {
|
||||
width: 12px;
|
||||
@@ -3436,7 +3749,7 @@ html[data-platform="macos"] .titlebar .tb-left {
|
||||
.skel-card .h .timer {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.skel-card .body {
|
||||
padding: 10px 12px 12px;
|
||||
@@ -3893,6 +4206,121 @@ html[data-platform="macos"] .titlebar .tb-left {
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.theme-style-row {
|
||||
align-items: start;
|
||||
}
|
||||
.style-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(150px, 1fr));
|
||||
gap: 8px;
|
||||
width: min(360px, 42vw);
|
||||
}
|
||||
.style-card {
|
||||
text-align: left;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
color: var(--fg);
|
||||
min-height: 108px;
|
||||
}
|
||||
.style-card[data-on="true"] {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent-soft);
|
||||
background: var(--card);
|
||||
}
|
||||
.style-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.style-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.style-mode {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.style-swatches {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 1fr 0.7fr;
|
||||
gap: 4px;
|
||||
height: 24px;
|
||||
}
|
||||
.style-swatches span {
|
||||
border: 1px solid oklch(0% 0 0 / 0.12);
|
||||
border-radius: 5px;
|
||||
}
|
||||
.style-desc {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.style-card[data-style="graphite"] .style-swatches span:nth-child(1),
|
||||
.theme-pop-item[data-style="graphite"] .style-swatches span:nth-child(1) {
|
||||
background: oklch(17% 0.005 280);
|
||||
}
|
||||
.style-card[data-style="graphite"] .style-swatches span:nth-child(2),
|
||||
.theme-pop-item[data-style="graphite"] .style-swatches span:nth-child(2) {
|
||||
background: oklch(24.5% 0.008 275);
|
||||
}
|
||||
.style-card[data-style="graphite"] .style-swatches span:nth-child(3),
|
||||
.theme-pop-item[data-style="graphite"] .style-swatches span:nth-child(3) {
|
||||
background: oklch(68% 0.16 38);
|
||||
}
|
||||
.style-card[data-style="sandstone"] .style-swatches span:nth-child(1),
|
||||
.theme-pop-item[data-style="sandstone"] .style-swatches span:nth-child(1) {
|
||||
background: oklch(97.5% 0.008 80);
|
||||
}
|
||||
.style-card[data-style="sandstone"] .style-swatches span:nth-child(2),
|
||||
.theme-pop-item[data-style="sandstone"] .style-swatches span:nth-child(2) {
|
||||
background: oklch(93.5% 0.014 76);
|
||||
}
|
||||
.style-card[data-style="sandstone"] .style-swatches span:nth-child(3),
|
||||
.theme-pop-item[data-style="sandstone"] .style-swatches span:nth-child(3) {
|
||||
background: oklch(60% 0.19 38);
|
||||
}
|
||||
.style-card[data-style="porcelain"] .style-swatches span:nth-child(1),
|
||||
.theme-pop-item[data-style="porcelain"] .style-swatches span:nth-child(1) {
|
||||
background: oklch(99.2% 0.001 100);
|
||||
}
|
||||
.style-card[data-style="porcelain"] .style-swatches span:nth-child(2),
|
||||
.theme-pop-item[data-style="porcelain"] .style-swatches span:nth-child(2) {
|
||||
background: oklch(95.6% 0.001 100);
|
||||
}
|
||||
.style-card[data-style="porcelain"] .style-swatches span:nth-child(3),
|
||||
.theme-pop-item[data-style="porcelain"] .style-swatches span:nth-child(3) {
|
||||
background: oklch(56% 0.12 285);
|
||||
}
|
||||
.style-card[data-style="midnight"] .style-swatches span:nth-child(1),
|
||||
.theme-pop-item[data-style="midnight"] .style-swatches span:nth-child(1) {
|
||||
background: oklch(17% 0.026 260);
|
||||
}
|
||||
.style-card[data-style="midnight"] .style-swatches span:nth-child(2),
|
||||
.theme-pop-item[data-style="midnight"] .style-swatches span:nth-child(2) {
|
||||
background: oklch(25% 0.03 265);
|
||||
}
|
||||
.style-card[data-style="midnight"] .style-swatches span:nth-child(3),
|
||||
.theme-pop-item[data-style="midnight"] .style-swatches span:nth-child(3) {
|
||||
background: oklch(73% 0.12 285);
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.theme-style-row,
|
||||
.setting-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.style-grid {
|
||||
width: 100%;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.field {
|
||||
width: 200px;
|
||||
padding: 5px 8px;
|
||||
@@ -3908,6 +4336,87 @@ html[data-platform="macos"] .titlebar .tb-left {
|
||||
.field.mono {
|
||||
font-family: inherit;
|
||||
}
|
||||
.field.font-family-field {
|
||||
width: min(360px, 100%);
|
||||
}
|
||||
|
||||
.qq-setting-row {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.qq-row-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.qq-status-btn {
|
||||
min-width: 88px;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.qq-status-btn.qq-status-on {
|
||||
color: var(--success);
|
||||
background: var(--success-soft);
|
||||
border-color: color-mix(in srgb, var(--success) 40%, var(--border));
|
||||
}
|
||||
|
||||
.qq-status-btn.qq-status-on:hover {
|
||||
background: color-mix(in srgb, var(--success) 18%, transparent);
|
||||
}
|
||||
|
||||
.qq-status-btn.qq-status-off {
|
||||
color: var(--danger);
|
||||
background: var(--danger-soft);
|
||||
border-color: color-mix(in srgb, var(--danger) 38%, var(--border));
|
||||
}
|
||||
|
||||
.qq-status-btn.qq-status-off:hover {
|
||||
background: color-mix(in srgb, var(--danger) 16%, transparent);
|
||||
}
|
||||
|
||||
.qq-status-btn.qq-status-connecting {
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
border-color: color-mix(in srgb, var(--accent) 38%, var(--border));
|
||||
}
|
||||
|
||||
.qq-status-btn.qq-status-failed {
|
||||
color: var(--danger);
|
||||
background: var(--danger-soft);
|
||||
border-color: color-mix(in srgb, var(--danger) 38%, var(--border));
|
||||
}
|
||||
|
||||
.qq-config-card .h {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.qq-config-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--panel);
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.qq-config-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.qq-config-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
/* card list (mcp, skills) */
|
||||
.scard {
|
||||
@@ -4098,13 +4607,13 @@ html[data-platform="macos"] .titlebar .tb-left {
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.mem-edit .scope[data-s="项目"], .mem-edit .scope[data-s="project"] {
|
||||
.mem-edit .scope[data-s="project"] {
|
||||
background: var(--accent-soft); color: var(--accent);
|
||||
}
|
||||
.mem-edit .scope[data-s="用户"], .mem-edit .scope[data-s="user"] {
|
||||
.mem-edit .scope[data-s="user"] {
|
||||
background: var(--violet-soft); color: var(--violet);
|
||||
}
|
||||
.mem-edit .scope[data-s="全局"], .mem-edit .scope[data-s="global"] {
|
||||
.mem-edit .scope[data-s="global"] {
|
||||
background: var(--success-soft); color: var(--success);
|
||||
}
|
||||
.mem-edit .txt {
|
||||
@@ -5893,3 +6402,156 @@ html[data-platform="macos"] .splash {
|
||||
.statusbar .seg {
|
||||
transition: background 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
|
||||
/* ---- about modal ---- */
|
||||
.about-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
animation: fade 0.16s ease-out;
|
||||
}
|
||||
.about-modal {
|
||||
position: relative;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 28px 32px 24px;
|
||||
min-width: 380px;
|
||||
max-width: 480px;
|
||||
color: var(--fg);
|
||||
font-size: 14px;
|
||||
animation: rise 0.18s ease-out;
|
||||
}
|
||||
.about-close {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
.about-close:hover {
|
||||
background: var(--panel);
|
||||
color: var(--fg);
|
||||
}
|
||||
.about-brand {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.about-name {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.2px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.about-tagline {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.about-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 14px 0;
|
||||
border-top: 1px solid var(--border-strong);
|
||||
border-bottom: 1px solid var(--border-strong);
|
||||
}
|
||||
.about-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.about-row .about-label {
|
||||
color: var(--muted);
|
||||
}
|
||||
.about-row .about-value {
|
||||
font-family: "Geist Mono", monospace;
|
||||
font-size: 12px;
|
||||
color: var(--fg);
|
||||
background: var(--panel);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.about-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.about-link:hover {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.about-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
.about-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
color: var(--fg);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, border-color 0.12s;
|
||||
}
|
||||
.about-check:hover:not(:disabled) {
|
||||
background: var(--card);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.about-check:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.about-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.about-status.ok {
|
||||
background: var(--accent-soft);
|
||||
color: var(--fg);
|
||||
}
|
||||
.about-status.warn {
|
||||
background: var(--panel);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--accent);
|
||||
justify-content: space-between;
|
||||
}
|
||||
.about-status.err {
|
||||
background: var(--panel);
|
||||
color: var(--muted);
|
||||
}
|
||||
@@ -9,6 +9,51 @@ export function isTheme(value: unknown): value is Theme {
|
||||
return value === THEME.DARK || value === THEME.LIGHT;
|
||||
}
|
||||
|
||||
export const THEME_STYLE = {
|
||||
GRAPHITE: "graphite",
|
||||
SANDSTONE: "sandstone",
|
||||
PORCELAIN: "porcelain",
|
||||
MIDNIGHT: "midnight",
|
||||
} as const;
|
||||
|
||||
export type ThemeStyle = (typeof THEME_STYLE)[keyof typeof THEME_STYLE];
|
||||
|
||||
export const DEFAULT_THEME_STYLE: Record<Theme, ThemeStyle> = {
|
||||
dark: THEME_STYLE.GRAPHITE,
|
||||
light: THEME_STYLE.SANDSTONE,
|
||||
};
|
||||
|
||||
export const THEME_STYLE_THEME: Record<ThemeStyle, Theme> = {
|
||||
graphite: THEME.DARK,
|
||||
sandstone: THEME.LIGHT,
|
||||
porcelain: THEME.LIGHT,
|
||||
midnight: THEME.DARK,
|
||||
};
|
||||
|
||||
export const THEME_STYLES = [
|
||||
THEME_STYLE.GRAPHITE,
|
||||
THEME_STYLE.SANDSTONE,
|
||||
THEME_STYLE.PORCELAIN,
|
||||
THEME_STYLE.MIDNIGHT,
|
||||
] as const;
|
||||
|
||||
export function isThemeStyle(value: unknown): value is ThemeStyle {
|
||||
return (
|
||||
value === THEME_STYLE.GRAPHITE ||
|
||||
value === THEME_STYLE.SANDSTONE ||
|
||||
value === THEME_STYLE.PORCELAIN ||
|
||||
value === THEME_STYLE.MIDNIGHT
|
||||
);
|
||||
}
|
||||
|
||||
export function themeForStyle(style: ThemeStyle): Theme {
|
||||
return THEME_STYLE_THEME[style];
|
||||
}
|
||||
|
||||
export function defaultStyleForTheme(theme: Theme): ThemeStyle {
|
||||
return DEFAULT_THEME_STYLE[theme];
|
||||
}
|
||||
|
||||
export const FONT_SCALE = {
|
||||
SMALL: "small",
|
||||
MEDIUM: "medium",
|
||||
@@ -31,16 +76,23 @@ export const FONT_FAMILY = {
|
||||
SANS: "sans",
|
||||
SYSTEM: "system",
|
||||
SERIF: "serif",
|
||||
CUSTOM: "custom",
|
||||
} as const;
|
||||
|
||||
export type FontFamily = (typeof FONT_FAMILY)[keyof typeof FONT_FAMILY];
|
||||
|
||||
export function isFontFamily(value: unknown): value is FontFamily {
|
||||
return value === FONT_FAMILY.SANS || value === FONT_FAMILY.SYSTEM || value === FONT_FAMILY.SERIF;
|
||||
return (
|
||||
value === FONT_FAMILY.SANS ||
|
||||
value === FONT_FAMILY.SYSTEM ||
|
||||
value === FONT_FAMILY.SERIF ||
|
||||
value === FONT_FAMILY.CUSTOM
|
||||
);
|
||||
}
|
||||
|
||||
export const FONT_FAMILY_STACK: Record<FontFamily, string> = {
|
||||
sans: '"Geist", system-ui, sans-serif',
|
||||
system: '-apple-system, system-ui, "Segoe UI", Roboto, sans-serif',
|
||||
serif: '"Geist", Georgia, serif',
|
||||
};
|
||||
serif: 'Georgia, "Times New Roman", serif',
|
||||
custom: '"Geist", system-ui, sans-serif',
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { check as checkUpdate } from "@tauri-apps/plugin-updater";
|
||||
import { useCallback, useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { I } from "../icons";
|
||||
|
||||
const REPO_URL = "https://github.com/esengine/DeepSeek-Reasonix";
|
||||
const RELEASES_PAGE = `${REPO_URL}/releases`;
|
||||
|
||||
type CheckState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "checking" }
|
||||
| { kind: "up-to-date"; latest: string }
|
||||
| { kind: "outdated"; latest: string }
|
||||
| { kind: "error"; message: string };
|
||||
|
||||
export function AboutModal({ onClose }: { onClose: () => void }) {
|
||||
const [check, setCheck] = useState<CheckState>({ kind: "idle" });
|
||||
|
||||
const openGitHub = useCallback(() => {
|
||||
void openUrl(REPO_URL).catch(() => undefined);
|
||||
}, []);
|
||||
const openReleases = useCallback(() => {
|
||||
void openUrl(RELEASES_PAGE).catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
const checkForUpdates = useCallback(async () => {
|
||||
setCheck({ kind: "checking" });
|
||||
try {
|
||||
const update = await checkUpdate();
|
||||
if (!update) {
|
||||
setCheck({ kind: "up-to-date", latest: __APP_VERSION__ });
|
||||
} else {
|
||||
setCheck({ kind: "outdated", latest: update.version });
|
||||
}
|
||||
} catch (err) {
|
||||
setCheck({ kind: "error", message: (err as Error).message });
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="about-mask" onClick={onClose}>
|
||||
<div className="about-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button type="button" className="about-close" onClick={onClose} aria-label={t("about.close")}>
|
||||
<I.x size={14} />
|
||||
</button>
|
||||
<div className="about-brand">
|
||||
<div className="about-name">Reasonix</div>
|
||||
<div className="about-tagline">{t("about.tagline")}</div>
|
||||
</div>
|
||||
<div className="about-meta">
|
||||
<div className="about-row">
|
||||
<span className="about-label">{t("about.version")}</span>
|
||||
<code className="about-value">{__APP_VERSION__}</code>
|
||||
</div>
|
||||
<div className="about-row">
|
||||
<span className="about-label">{t("about.repo")}</span>
|
||||
<button type="button" className="about-link" onClick={openGitHub}>
|
||||
<I.link size={12} />
|
||||
<span>esengine/DeepSeek-Reasonix</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="about-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="about-check"
|
||||
onClick={checkForUpdates}
|
||||
disabled={check.kind === "checking"}
|
||||
>
|
||||
<I.rotate size={12} />
|
||||
<span>{check.kind === "checking" ? t("about.checking") : t("about.checkUpdates")}</span>
|
||||
</button>
|
||||
<CheckStatus check={check} onOpenReleases={openReleases} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckStatus({
|
||||
check,
|
||||
onOpenReleases,
|
||||
}: { check: CheckState; onOpenReleases: () => void }) {
|
||||
if (check.kind === "idle" || check.kind === "checking") return null;
|
||||
if (check.kind === "up-to-date") {
|
||||
return (
|
||||
<div className="about-status ok">
|
||||
<I.check size={12} />
|
||||
<span>{t("about.upToDate", { version: check.latest })}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (check.kind === "outdated") {
|
||||
return (
|
||||
<div className="about-status warn">
|
||||
<span>{t("about.updateAvailable", { version: check.latest })}</span>
|
||||
<button type="button" className="about-link" onClick={onOpenReleases}>
|
||||
<I.download size={12} />
|
||||
<span>{t("about.openReleases")}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="about-status err">
|
||||
<span>{t("about.checkFailed", { message: check.message })}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { memo, useState, type ReactNode } from "react";
|
||||
import { I } from "../icons";
|
||||
import { Markdown } from "../Markdown";
|
||||
import { t, useLang } from "../i18n";
|
||||
import { Shortcut } from "./shortcut";
|
||||
|
||||
type Tone = "default" | "success" | "warning" | "danger" | "accent" | "violet";
|
||||
|
||||
@@ -69,12 +70,27 @@ export type PlanItem = {
|
||||
note?: string;
|
||||
};
|
||||
|
||||
function derivePlanBadge(items: PlanItem[]): { cls: "run" | "ok" | "warn" | "err"; label: string } {
|
||||
if (items.some((x) => x.status === "failed")) return { cls: "err", label: t("planBadge.failed") };
|
||||
if (items.some((x) => x.status === "blocked")) return { cls: "warn", label: t("planBadge.blocked") };
|
||||
if (items.some((x) => x.status === "active")) return { cls: "run", label: t("planBadge.running") };
|
||||
if (items.length > 0 && items.every((x) => x.status === "done")) return { cls: "ok", label: t("planBadge.done") };
|
||||
return { cls: "run", label: t("planBadge.pending") };
|
||||
function derivePlanBadge(items: PlanItem[]): { state: "running" | "done" | "failed" | "waiting" | "blocked"; label: string } {
|
||||
if (items.some((x) => x.status === "failed")) return { state: "failed", label: t("planBadge.failed") };
|
||||
if (items.some((x) => x.status === "blocked")) return { state: "blocked", label: t("planBadge.blocked") };
|
||||
if (items.some((x) => x.status === "active")) return { state: "running", label: t("planBadge.running") };
|
||||
if (items.length > 0 && items.every((x) => x.status === "done")) return { state: "done", label: t("planBadge.done") };
|
||||
return { state: "waiting", label: t("planBadge.pending") };
|
||||
}
|
||||
|
||||
function StatusIcon({ state, label }: { state: "running" | "done" | "failed" | "waiting" | "blocked"; label: string }) {
|
||||
switch (state) {
|
||||
case "running":
|
||||
return <span className="spin-meta" role="img" aria-label={label} title={label} />;
|
||||
case "done":
|
||||
return <I.check size={10} style={{ color: "var(--success)" }} aria-label={label} />;
|
||||
case "failed":
|
||||
return <I.x size={10} style={{ color: "var(--danger)" }} aria-label={label} />;
|
||||
case "waiting":
|
||||
return <span className="status-dot warn" role="img" aria-label={label} title={label} />;
|
||||
case "blocked":
|
||||
return <I.slash size={10} style={{ color: "var(--warning)" }} aria-label={label} />;
|
||||
}
|
||||
}
|
||||
|
||||
export function PlanCardView({ items, title }: { items: PlanItem[]; title?: string }) {
|
||||
@@ -93,7 +109,8 @@ export function PlanCardView({ items, title }: { items: PlanItem[]; title?: stri
|
||||
<span>
|
||||
{done}/{items.length}
|
||||
</span>
|
||||
<span className={`pill-tag ${badge.cls}`}>{badge.label}</span>
|
||||
<StatusIcon state={badge.state} label={badge.label} />
|
||||
<span className="meta-label">{badge.label}</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
@@ -150,11 +167,9 @@ export function ReasoningCard({
|
||||
</span>
|
||||
) : null}
|
||||
{streaming ? (
|
||||
<span className="pill-tag warn">
|
||||
<span className="shimmer">streaming…</span>
|
||||
</span>
|
||||
<StatusIcon state="running" label={t("cards.streaming")} />
|
||||
) : (
|
||||
<span className="pill-tag ok">{t("cards.reasoningComplete")}</span>
|
||||
<StatusIcon state="done" label={t("cards.reasoningComplete")} />
|
||||
)}
|
||||
</>
|
||||
}
|
||||
@@ -178,12 +193,12 @@ export function ReasoningCard({
|
||||
<div className="meta">
|
||||
{model ? (
|
||||
<span>
|
||||
<span className="k">model</span> {model}
|
||||
<span className="k">{t("settings.model")}</span> {model}
|
||||
</span>
|
||||
) : null}
|
||||
{tokens !== undefined ? (
|
||||
<span>
|
||||
<span className="k">tokens</span> {tokens.toLocaleString()}
|
||||
<span className="k">{t("statusbar.tokens")}</span> {tokens.toLocaleString()}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -214,7 +229,6 @@ export function ShellCard({
|
||||
}) {
|
||||
useLang();
|
||||
const tone: Tone = state === "failed" ? "danger" : state === "done" ? "success" : "warning";
|
||||
const durationLabel = durationMs ? ` · ${(durationMs / 1000).toFixed(2)}s` : "";
|
||||
return (
|
||||
<Card
|
||||
tone={tone}
|
||||
@@ -223,15 +237,20 @@ export function ShellCard({
|
||||
name="shell"
|
||||
compact
|
||||
meta={
|
||||
state === "await" ? (
|
||||
<span className="pill-tag warn">{t("cards.shellAwaiting")}</span>
|
||||
) : state === "running" ? (
|
||||
<span className="pill-tag run">{t("cards.shellRunning")}</span>
|
||||
) : state === "failed" ? (
|
||||
<span className="pill-tag err">failed{durationLabel}</span>
|
||||
) : (
|
||||
<span className="pill-tag ok">done{durationLabel}</span>
|
||||
)
|
||||
<>
|
||||
{state === "await" ? (
|
||||
<StatusIcon state="waiting" label={t("cards.shellAwaiting")} />
|
||||
) : state === "running" ? (
|
||||
<StatusIcon state="running" label={t("cards.shellRunning")} />
|
||||
) : state === "failed" ? (
|
||||
<StatusIcon state="failed" label={t("cards.failed")} />
|
||||
) : (
|
||||
<StatusIcon state="done" label={t("cards.done")} />
|
||||
)}
|
||||
{(state === "done" || state === "failed") && durationMs ? (
|
||||
<span className="meta-dur">{(durationMs / 1000).toFixed(2)}s</span>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="shell">
|
||||
@@ -271,11 +290,11 @@ export function ShellCard({
|
||||
) : null}
|
||||
{onReject ? (
|
||||
<button type="button" className="btn" onClick={onReject}>
|
||||
{t("cards.shellReject")} <kbd>⌘.</kbd>
|
||||
{t("cards.shellReject")} <Shortcut keys={["mod", "."]} />
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" className="btn primary" onClick={onApprove}>
|
||||
{t("cards.shellRun")} <kbd>⌘⏎</kbd>
|
||||
{t("cards.shellRun")} <Shortcut keys={["mod", "enter"]} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -300,9 +319,9 @@ export function ToolCard({
|
||||
ok?: boolean;
|
||||
durationMs?: number;
|
||||
}) {
|
||||
useLang();
|
||||
const running = result === undefined;
|
||||
const tone: Tone = running ? "default" : ok === false ? "danger" : "success";
|
||||
const dur = durationMs ? `${durationMs} ms` : "—";
|
||||
return (
|
||||
<Card
|
||||
tone={tone}
|
||||
@@ -312,13 +331,18 @@ export function ToolCard({
|
||||
defaultOpen={false}
|
||||
compact
|
||||
meta={
|
||||
running ? (
|
||||
<span className="pill-tag run">running</span>
|
||||
) : ok === false ? (
|
||||
<span className="pill-tag err">error · {dur}</span>
|
||||
) : (
|
||||
<span className="pill-tag ok">done · {dur}</span>
|
||||
)
|
||||
<>
|
||||
{running ? (
|
||||
<StatusIcon state="running" label={t("cards.running")} />
|
||||
) : ok === false ? (
|
||||
<StatusIcon state="failed" label={t("cards.error")} />
|
||||
) : (
|
||||
<StatusIcon state="done" label={t("cards.done")} />
|
||||
)}
|
||||
{!running && durationMs !== undefined ? (
|
||||
<span className="meta-dur">{durationMs} ms</span>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="tool-call">
|
||||
@@ -332,7 +356,7 @@ export function ToolCard({
|
||||
) : null}
|
||||
{result !== undefined ? (
|
||||
<div className="row">
|
||||
<span className="k">{ok === false ? "error" : "result"}</span>
|
||||
<span className="k">{ok === false ? t("cards.error") : t("cards.result")}</span>
|
||||
<span className="v">
|
||||
<span className={ok === false ? "num" : "str"}>
|
||||
{result.length > 1200 ? `${result.slice(0, 1200)}…` : result}
|
||||
@@ -379,7 +403,11 @@ export function DiffCard({
|
||||
<>
|
||||
<span style={{ color: "var(--success)" }}>+{adds}</span>
|
||||
<span style={{ color: "var(--danger)" }}>−{rms}</span>
|
||||
{applied ? <span className="pill-tag ok">applied</span> : <span className="pill-tag warn">{t("cards.diffAwaiting")}</span>}
|
||||
{applied ? (
|
||||
<StatusIcon state="done" label={t("cards.applied")} />
|
||||
) : (
|
||||
<StatusIcon state="waiting" label={t("cards.diffAwaiting")} />
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
@@ -420,7 +448,7 @@ export function DiffCard({
|
||||
) : null}
|
||||
{onApply ? (
|
||||
<button type="button" className="btn primary" onClick={onApply}>
|
||||
{t("cards.diffApply")} <kbd>⌘⏎</kbd>
|
||||
{t("cards.diffApply")} <Shortcut keys={["mod", "enter"]} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -466,7 +494,7 @@ export function WebSearchCard({ query, results }: { query: string; results: Sear
|
||||
meta={
|
||||
<>
|
||||
<span>"{query}"</span>
|
||||
<span className="pill-tag ok">{results.length} hits</span>
|
||||
<span className="pill-tag ok">{results.length} {t("cards.hits")}</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
@@ -518,11 +546,11 @@ export function SubagentCard({
|
||||
{done} / {children.length} {t("cards.subagentDoneProgress")}
|
||||
</span>
|
||||
{status === "done" ? (
|
||||
<span className="pill-tag ok">{t("cards.subagentDone")}</span>
|
||||
<StatusIcon state="done" label={t("cards.subagentDone")} />
|
||||
) : status === "failed" ? (
|
||||
<span className="pill-tag err">{t("cards.subagentFailed")}</span>
|
||||
<StatusIcon state="failed" label={t("cards.subagentFailed")} />
|
||||
) : (
|
||||
<span className="pill-tag run">{t("cards.subagentRunning")}</span>
|
||||
<StatusIcon state="running" label={t("cards.subagentRunning")} />
|
||||
)}
|
||||
</>
|
||||
}
|
||||
@@ -586,6 +614,7 @@ export function AttachCard({
|
||||
meta: string;
|
||||
preview?: string;
|
||||
}) {
|
||||
useLang();
|
||||
return (
|
||||
<Card
|
||||
tone="default"
|
||||
@@ -628,32 +657,32 @@ export function MetricStrip({
|
||||
{cacheHit !== undefined ? (
|
||||
<span className="item">
|
||||
<I.zap size={11} style={{ color: "var(--accent)" }} />
|
||||
<span>cache_hit</span>
|
||||
<span>{t("cards.cacheHit")}</span>
|
||||
<span className="v acc">{cacheHit}%</span>
|
||||
</span>
|
||||
) : null}
|
||||
{promptTokens !== undefined ? (
|
||||
<span className="item">
|
||||
<span>prompt</span>
|
||||
<span>{t("cards.prompt")}</span>
|
||||
<span className="v">{promptTokens.toLocaleString()} t</span>
|
||||
</span>
|
||||
) : null}
|
||||
{outputTokens !== undefined ? (
|
||||
<span className="item">
|
||||
<span>output</span>
|
||||
<span>{t("cards.output")}</span>
|
||||
<span className="v">{outputTokens.toLocaleString()} t</span>
|
||||
</span>
|
||||
) : null}
|
||||
{costLabel ? (
|
||||
<span className="item">
|
||||
<I.coin size={11} />
|
||||
<span>cost</span>
|
||||
<span>{t("cards.cost")}</span>
|
||||
<span className="v ok">{costLabel}</span>
|
||||
</span>
|
||||
) : null}
|
||||
{elapsed ? (
|
||||
<span className="item">
|
||||
<span>elapsed</span>
|
||||
<span>{t("cards.elapsed")}</span>
|
||||
<span className="v">{elapsed}</span>
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
export const DEFAULT_COMPOSER_ROWS = 5;
|
||||
export const MAX_COMPOSER_ROWS = 15;
|
||||
|
||||
export type ComposerTextareaSizing = {
|
||||
heightPx: number;
|
||||
overflowY: "hidden" | "auto";
|
||||
};
|
||||
|
||||
export function getComposerTextareaSizing({
|
||||
contentRows,
|
||||
lineHeightPx,
|
||||
verticalPaddingPx,
|
||||
}: {
|
||||
contentRows: number;
|
||||
lineHeightPx: number;
|
||||
verticalPaddingPx: number;
|
||||
}): ComposerTextareaSizing {
|
||||
const safeRows = Math.max(1, Math.ceil(contentRows));
|
||||
const visibleRows = Math.min(Math.max(safeRows, DEFAULT_COMPOSER_ROWS), MAX_COMPOSER_ROWS);
|
||||
|
||||
return {
|
||||
heightPx: visibleRows * lineHeightPx + verticalPaddingPx,
|
||||
overflowY: safeRows > MAX_COMPOSER_ROWS ? "auto" : "hidden",
|
||||
};
|
||||
}
|
||||
|
||||
export function applyComposerTextareaAutosize(textarea: HTMLTextAreaElement) {
|
||||
const style = window.getComputedStyle(textarea);
|
||||
const lineHeightPx = Number.parseFloat(style.lineHeight);
|
||||
const paddingTopPx = Number.parseFloat(style.paddingTop);
|
||||
const paddingBottomPx = Number.parseFloat(style.paddingBottom);
|
||||
const verticalPaddingPx = paddingTopPx + paddingBottomPx;
|
||||
const measuredLineHeight = Number.isFinite(lineHeightPx) ? lineHeightPx : 20;
|
||||
|
||||
textarea.style.height = "auto";
|
||||
const contentRows = Math.ceil(
|
||||
Math.max(textarea.scrollHeight - verticalPaddingPx, measuredLineHeight) / measuredLineHeight,
|
||||
);
|
||||
const sizing = getComposerTextareaSizing({
|
||||
contentRows,
|
||||
lineHeightPx: measuredLineHeight,
|
||||
verticalPaddingPx,
|
||||
});
|
||||
textarea.style.height = `${sizing.heightPx}px`;
|
||||
textarea.style.overflowY = sizing.overflowY;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
type KeyboardEvent,
|
||||
type RefObject,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -11,24 +12,29 @@ import type React from "react";
|
||||
import { open as openFileDialog } from "@tauri-apps/plugin-dialog";
|
||||
import { t, type TKey } from "../i18n";
|
||||
import { I } from "../icons";
|
||||
import {
|
||||
DEFAULT_COMPOSER_ROWS,
|
||||
applyComposerTextareaAutosize,
|
||||
} from "./composer-sizing";
|
||||
import { fmtElapsed } from "./live";
|
||||
import { Shortcut } from "./shortcut";
|
||||
|
||||
export type PresetName = "auto" | "flash" | "pro";
|
||||
export type EditMode = "review" | "auto" | "yolo";
|
||||
|
||||
type PresetEntry = { label: string; badge: string; desc: TKey };
|
||||
type ModeEntry = { k: EditMode; label: string; icon: React.ReactNode; hint: TKey };
|
||||
type ModeEntry = { k: EditMode; label: TKey; icon: React.ReactNode; hint: TKey };
|
||||
|
||||
const PRESET_INFO: Record<PresetName, PresetEntry> = {
|
||||
auto: { label: "auto", badge: "AUTO", desc: "preset.autoDesc" },
|
||||
flash: { label: "deepseek-v4-flash", badge: "FLASH", desc: "preset.flashDesc" },
|
||||
pro: { label: "deepseek-v4-pro", badge: "PRO", desc: "preset.proDesc" },
|
||||
flash: { label: "v4-flash", badge: "FLASH", desc: "preset.flashDesc" },
|
||||
pro: { label: "v4-pro", badge: "PRO", desc: "preset.proDesc" },
|
||||
};
|
||||
|
||||
const MODE_INFO: ModeEntry[] = [
|
||||
{ k: "review", label: "Review", icon: <I.shield size={11} />, hint: "editMode.reviewHint" },
|
||||
{ k: "auto", label: "Auto", icon: <I.zap size={11} />, hint: "editMode.autoHint" },
|
||||
{ k: "yolo", label: "YOLO", icon: <I.warn size={11} />, hint: "editMode.yoloHint" },
|
||||
{ k: "review", label: "editMode.review", icon: <I.shield size={11} />, hint: "editMode.reviewHint" },
|
||||
{ k: "auto", label: "editMode.auto", icon: <I.zap size={11} />, hint: "editMode.autoHint" },
|
||||
{ k: "yolo", label: "editMode.yolo", icon: <I.warn size={11} />, hint: "editMode.yoloHint" },
|
||||
];
|
||||
|
||||
export function ModeSwitch({
|
||||
@@ -51,7 +57,7 @@ export function ModeSwitch({
|
||||
title={t(m.hint)}
|
||||
>
|
||||
{m.icon}
|
||||
<span>{m.label}</span>
|
||||
<span>{t(m.label)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -63,6 +69,7 @@ export type SlashCmd = {
|
||||
desc: string;
|
||||
run: () => void;
|
||||
kb?: string;
|
||||
insertOnly?: boolean;
|
||||
};
|
||||
export type MentionItem = {
|
||||
name: string;
|
||||
@@ -173,6 +180,12 @@ export function Composer({
|
||||
const nonceRef = useRef(0);
|
||||
const modelWrapRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
applyComposerTextareaAutosize(textarea);
|
||||
});
|
||||
|
||||
// Programmatic draft transitions to "/" (e.g. /help suggestion in EmptyState, #929) must open the slash popup, since handleChange only fires on actual user input.
|
||||
const prevDraftRef = useRef(draft);
|
||||
useEffect(() => {
|
||||
@@ -248,7 +261,7 @@ export function Composer({
|
||||
base.unshift({
|
||||
name: "..",
|
||||
kind: "dir",
|
||||
desc: parent ? `↑ ${parent}` : "↑ workspace root",
|
||||
desc: parent ? `↑ ${parent}` : `↑ ${t("composer.workspaceRoot")}`,
|
||||
});
|
||||
}
|
||||
return base;
|
||||
@@ -291,10 +304,17 @@ export function Composer({
|
||||
if (!it || !popup) return;
|
||||
if (popup.kind === "slash") {
|
||||
const cmd = (it as SlashCmd).cmd;
|
||||
const next = draft.replace(/[/@][^\s]*$/, "").trimEnd();
|
||||
setDraft(next);
|
||||
setChips((c) => [...c, { kind: "slash", label: cmd.replace(/^\//, "") }]);
|
||||
(it as SlashCmd).run();
|
||||
const insertOnly = (it as SlashCmd).insertOnly === true;
|
||||
if (insertOnly) {
|
||||
const next = draft.replace(/[/@][^\s]*$/, "").trimEnd();
|
||||
setDraft(next ? `${next} ${cmd} ` : `${cmd} `);
|
||||
setChips((c) => [...c, { kind: "slash", label: cmd.replace(/^\//, "") }]);
|
||||
} else {
|
||||
const next = draft.replace(/[/@][^\s]*$/, "").trimEnd();
|
||||
setDraft(next);
|
||||
setChips((c) => [...c, { kind: "slash", label: cmd.replace(/^\//, "") }]);
|
||||
(it as SlashCmd).run();
|
||||
}
|
||||
} else {
|
||||
const mention = it as MentionItem;
|
||||
if (mention.name === "..") {
|
||||
@@ -418,20 +438,23 @@ export function Composer({
|
||||
<ModeSwitch mode={editMode} onChange={onEditModeChange} />
|
||||
<span className="hint-sep" />
|
||||
<span>
|
||||
<kbd>⏎</kbd> {t("composer.queue")} · <kbd>esc</kbd> {t("composer.interrupt")}
|
||||
<Shortcut keys={["enter"]} /> {t("composer.queue")} · {" "}
|
||||
<Shortcut keys={["esc"]} /> {t("composer.interrupt")}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>
|
||||
<kbd>/</kbd> {t("composer.commands")} · <kbd>@</kbd> {t("composer.mentionFiles")}
|
||||
· <kbd>⌘K</kbd> {t("composer.commandPalette")}
|
||||
<Shortcut keys={["/"]} /> {t("composer.commands")} · {" "}
|
||||
<Shortcut keys={["@"]} /> {t("composer.mentionFiles")}
|
||||
· <Shortcut keys={["mod", "K"]} /> {t("composer.commandPalette")}
|
||||
</span>
|
||||
<span className="grow" />
|
||||
<ModeSwitch mode={editMode} onChange={onEditModeChange} />
|
||||
<span className="hint-sep" />
|
||||
<span>
|
||||
<kbd>⏎</kbd> {t("composer.send")} <kbd>⇧⏎</kbd> {t("composer.newline")}
|
||||
<Shortcut keys={["enter"]} /> {t("composer.send")} {" "}
|
||||
<Shortcut keys={["shift", "enter"]} /> {t("composer.newline")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
@@ -467,7 +490,7 @@ export function Composer({
|
||||
placeholder={t("composer.placeholder")}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={2}
|
||||
rows={DEFAULT_COMPOSER_ROWS}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
@@ -500,7 +523,7 @@ export function Composer({
|
||||
<span className="ico">
|
||||
<I.slash size={14} />
|
||||
</span>
|
||||
<span className="label">commands</span>
|
||||
<span className="label">{t("composer.commandsLabel")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -513,7 +536,7 @@ export function Composer({
|
||||
<span className="ico">
|
||||
<I.at size={14} />
|
||||
</span>
|
||||
<span className="label">mention</span>
|
||||
<span className="label">{t("composer.mentionLabel")}</span>
|
||||
</button>
|
||||
|
||||
<span className="grow" />
|
||||
@@ -604,6 +627,15 @@ function Popup({
|
||||
onClose: () => void;
|
||||
onHover: (i: number, item: SlashCmd | MentionItem) => void;
|
||||
}) {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const el = listRef.current?.querySelector<HTMLElement>(`[data-active="true"]`);
|
||||
el?.scrollIntoView({ block: "nearest" });
|
||||
});
|
||||
}, [activeIdx]);
|
||||
|
||||
return (
|
||||
<div className="popup" onMouseDown={(e) => e.preventDefault()}>
|
||||
<div className="ph">
|
||||
@@ -618,7 +650,7 @@ function Popup({
|
||||
<I.x size={11} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="popup-list">
|
||||
<div className="popup-list" ref={listRef}>
|
||||
{items.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
@@ -667,13 +699,13 @@ function Popup({
|
||||
</div>
|
||||
<div className="popup-foot">
|
||||
<span>
|
||||
<kbd>↑↓</kbd> {t("composer.select")}
|
||||
<Shortcut keys={["updown"]} /> {t("composer.select")}
|
||||
</span>
|
||||
<span>
|
||||
<kbd>⏎</kbd> {t("composer.confirm")}
|
||||
<Shortcut keys={["enter"]} /> {t("composer.confirm")}
|
||||
</span>
|
||||
<span>
|
||||
<kbd>esc</kbd> {t("composer.close")}
|
||||
<Shortcut keys={["esc"]} /> {t("composer.close")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { SessionFile, Settings, UsageStats } from "../App";
|
||||
import { t, useLang } from "../i18n";
|
||||
import { I } from "../icons";
|
||||
import type { McpSpecInfo, MemoryEntryInfo } from "../protocol";
|
||||
import { PanelErrorBoundary } from "./error-boundary";
|
||||
|
||||
type Tab = "files" | "tools" | "memory" | "rules";
|
||||
|
||||
@@ -39,23 +40,23 @@ export function ContextPanel({
|
||||
<aside className="ctx">
|
||||
<div className="ctx-tabs">
|
||||
<div className="ctx-tab" data-active={tab === "files"} onClick={() => setTab("files")}>
|
||||
文件
|
||||
{t("contextPanel.filesTab")}
|
||||
</div>
|
||||
<div className="ctx-tab" data-active={tab === "tools"} onClick={() => setTab("tools")}>
|
||||
工具
|
||||
{t("contextPanel.toolsTab")}
|
||||
</div>
|
||||
<div className="ctx-tab" data-active={tab === "memory"} onClick={() => setTab("memory")}>
|
||||
记忆
|
||||
{t("contextPanel.memoryTab")}
|
||||
</div>
|
||||
<div className="ctx-tab" data-active={tab === "rules"} onClick={() => setTab("rules")}>
|
||||
规则
|
||||
{t("contextPanel.rulesTab")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ctx-body">
|
||||
<div className="ctx-block">
|
||||
<div className="h">
|
||||
<span>上下文 · tokens</span>
|
||||
<span>{t("contextPanel.contextTokens")}</span>
|
||||
<span className="right">
|
||||
{(reserved + used + cached).toLocaleString()} /{" "}
|
||||
{CONTEXT_MAX_TOKENS.toLocaleString()}
|
||||
@@ -73,22 +74,24 @@ export function ContextPanel({
|
||||
</span>
|
||||
<span className="l">
|
||||
<span className="sw c" />
|
||||
缓存 <span className="v">{cached.toLocaleString()}</span>
|
||||
{t("contextPanel.cacheKey")} <span className="v">{cached.toLocaleString()}</span>
|
||||
</span>
|
||||
<span className="l">
|
||||
<span className="sw u" />
|
||||
{t("contextPanel.usedKey")} <span className="v">{used.toLocaleString()}</span>
|
||||
</span>
|
||||
<span className="l">
|
||||
余 <span className="v">{free.toLocaleString()}</span>
|
||||
{t("contextPanel.freeKey")} <span className="v">{free.toLocaleString()}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tab === "files" && <CtxFiles files={sessionFiles} />}
|
||||
{tab === "tools" && <CtxTools specs={mcpSpecs} bridged={mcpBridged} />}
|
||||
{tab === "memory" && <CtxMemory entries={memory} />}
|
||||
{tab === "rules" && <CtxRules settings={settings} />}
|
||||
<PanelErrorBoundary key={tab} label={tab}>
|
||||
{tab === "files" && <CtxFiles files={sessionFiles} />}
|
||||
{tab === "tools" && <CtxTools specs={mcpSpecs} bridged={mcpBridged} />}
|
||||
{tab === "memory" && <CtxMemory entries={memory} />}
|
||||
{tab === "rules" && <CtxRules settings={settings} />}
|
||||
</PanelErrorBoundary>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
@@ -133,8 +136,10 @@ function CtxFiles({ files }: { files: SessionFile[] }) {
|
||||
return (
|
||||
<div className="ctx-block">
|
||||
<div className="h">
|
||||
<span>上下文中的文件</span>
|
||||
<span className="right">{files.length === 0 ? "—" : `${files.length} files`}</span>
|
||||
<span>{t("contextPanel.filesTitle")}</span>
|
||||
<span className="right">
|
||||
{files.length === 0 ? "—" : t("contextPanel.filesCount", { count: files.length })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="tree">
|
||||
{files.length === 0 ? (
|
||||
@@ -163,7 +168,7 @@ function CtxFiles({ files }: { files: SessionFile[] }) {
|
||||
<span
|
||||
className="dot"
|
||||
data-s={n.status}
|
||||
title={n.status === "m" ? "modified" : "in context"}
|
||||
title={n.status === "m" ? t("contextPanel.fileModified") : t("contextPanel.fileInContext")}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
@@ -179,17 +184,17 @@ function CtxTools({ specs, bridged }: { specs: McpSpecInfo[]; bridged: boolean }
|
||||
return (
|
||||
<div className="ctx-block">
|
||||
<div className="h">
|
||||
<span>MCP 服务器</span>
|
||||
<span>{t("contextPanel.mcpTitle")}</span>
|
||||
<span className="right">
|
||||
{specs.length === 0
|
||||
? "—"
|
||||
: bridged
|
||||
? `${specs.length} ready`
|
||||
: `${readyCount}/${specs.length} ready`}
|
||||
? t("contextPanel.mcpReadyAll", { count: specs.length })
|
||||
: t("contextPanel.mcpReadySome", { ready: readyCount, count: specs.length })}
|
||||
</span>
|
||||
</div>
|
||||
{specs.length === 0 ? (
|
||||
<div className="ctx-empty">未配置 MCP 服务器</div>
|
||||
<div className="ctx-empty">{t("contextPanel.mcpEmpty")}</div>
|
||||
) : (
|
||||
specs.map((s) => {
|
||||
const dot =
|
||||
@@ -202,15 +207,15 @@ function CtxTools({ specs, bridged }: { specs: McpSpecInfo[]; bridged: boolean }
|
||||
? ` · ${s.statusReason}`
|
||||
: s.status === "connected"
|
||||
? typeof s.toolCount === "number"
|
||||
? ` · ${s.toolCount} tools`
|
||||
: " · ready"
|
||||
? ` · ${t("contextPanel.mcpTools", { count: s.toolCount })}`
|
||||
: ` · ${t("contextPanel.mcpReady")}`
|
||||
: s.status === "handshake"
|
||||
? " · connecting"
|
||||
? ` · ${t("contextPanel.mcpConnecting")}`
|
||||
: s.status === "disabled"
|
||||
? " · disabled"
|
||||
? ` · ${t("contextPanel.mcpDisabled")}`
|
||||
: s.status === "failed"
|
||||
? " · failed"
|
||||
: " · configured";
|
||||
? ` · ${t("contextPanel.mcpFailed")}`
|
||||
: ` · ${t("contextPanel.mcpConfigured")}`;
|
||||
return (
|
||||
<div className="mcp-row" key={s.raw}>
|
||||
<span className="ico">
|
||||
@@ -236,8 +241,10 @@ function CtxMemory({ entries }: { entries: MemoryEntryInfo[] }) {
|
||||
return (
|
||||
<div className="ctx-block">
|
||||
<div className="h">
|
||||
<span>长期记忆</span>
|
||||
<span className="right">{entries.length === 0 ? "—" : `${entries.length} 项`}</span>
|
||||
<span>{t("contextPanel.memoryTitle")}</span>
|
||||
<span className="right">
|
||||
{entries.length === 0 ? "—" : t("contextPanel.itemCount", { count: entries.length })}
|
||||
</span>
|
||||
</div>
|
||||
{entries.length === 0 ? (
|
||||
<div className="ctx-empty">{t("contextPanel.noMemoriesMsg")}</div>
|
||||
@@ -246,7 +253,7 @@ function CtxMemory({ entries }: { entries: MemoryEntryInfo[] }) {
|
||||
{entries.map((m) => (
|
||||
<div className="mem-row" key={`${m.scope}/${m.name}`}>
|
||||
<span className="scope" data-s={m.scope}>
|
||||
{m.scope === "project" ? "项目" : "全局"}
|
||||
{m.scope === "project" ? t("contextPanel.scopeProject") : t("contextPanel.scopeGlobal")}
|
||||
</span>
|
||||
<span className="txt">{m.description || m.name}</span>
|
||||
</div>
|
||||
@@ -261,27 +268,29 @@ function CtxRules({ settings }: { settings: Settings | null }) {
|
||||
const editMode = settings?.editMode ?? "review";
|
||||
const items: { p: string; allow: boolean; desc: string }[] =
|
||||
editMode === "yolo"
|
||||
? [{ p: "*", allow: true, desc: "YOLO 模式 · 所有工具调用自动批准" }]
|
||||
? [{ p: "*", allow: true, desc: t("contextPanel.ruleYolo") }]
|
||||
: editMode === "auto"
|
||||
? [
|
||||
{ p: "read_file, list_directory, search_files, *", allow: true, desc: "只读工具自动批准" },
|
||||
{ p: "run_command (allowlist)", allow: true, desc: "命中 shell 白名单的命令自动批准" },
|
||||
{ p: "edit_file, write_file, run_command (其他)", allow: false, desc: "写入与未知 shell 命令需确认" },
|
||||
{ p: "read_file, list_directory, search_files, *", allow: true, desc: t("contextPanel.ruleReadOnly") },
|
||||
{ p: "run_command (allowlist)", allow: true, desc: t("contextPanel.ruleShellAllowlist") },
|
||||
{ p: "edit_file, write_file, run_command (other)", allow: false, desc: t("contextPanel.ruleWritesAsk") },
|
||||
]
|
||||
: [
|
||||
{ p: "*", allow: false, desc: "Review 模式 · 每个工具调用都需确认" },
|
||||
{ p: "*", allow: false, desc: t("contextPanel.ruleReview") },
|
||||
];
|
||||
return (
|
||||
<div className="ctx-block">
|
||||
<div className="h">
|
||||
<span>自动批准</span>
|
||||
<span>{t("contextPanel.autoApproveTitle")}</span>
|
||||
<span className="right">{editMode}</span>
|
||||
</div>
|
||||
{items.map((r) => (
|
||||
<div className="rule" key={r.p}>
|
||||
<div className="top">
|
||||
<span className={`pat ${r.allow ? "" : "deny"}`}>{r.p}</span>
|
||||
<span className={`sw ${r.allow ? "" : "deny"}`}>{r.allow ? "ALLOW" : "ASK"}</span>
|
||||
<span className={`sw ${r.allow ? "" : "deny"}`}>
|
||||
{r.allow ? t("contextPanel.allow") : t("contextPanel.ask")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="desc">{r.desc}</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Component, type ReactNode } from "react";
|
||||
import { t } from "../i18n";
|
||||
|
||||
type Props = { label: string; children: ReactNode };
|
||||
type State = { error: Error | null };
|
||||
|
||||
export class PanelErrorBoundary extends Component<Props, State> {
|
||||
state: State = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: { componentStack?: string | null }): void {
|
||||
console.error(`[panel:${this.props.label}] render error`, error, info);
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (!this.state.error) return this.props.children;
|
||||
return (
|
||||
<div
|
||||
className="ctx-empty"
|
||||
style={{
|
||||
color: "var(--danger, #e25555)",
|
||||
padding: 12,
|
||||
fontSize: 12,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{t("panel.renderError", { panel: this.props.label })}
|
||||
{": "}
|
||||
{this.state.error.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { I } from "../icons";
|
||||
import { t } from "../i18n";
|
||||
import { t, useLang } from "../i18n";
|
||||
|
||||
export type ApprovalTone = "ok" | "warn" | "danger" | "info" | "brand" | "ghost";
|
||||
|
||||
@@ -12,8 +12,8 @@ export function ApprovalCard({
|
||||
body,
|
||||
preview,
|
||||
meta,
|
||||
primaryLabel = "批准",
|
||||
secondaryLabel = "拒绝",
|
||||
primaryLabel,
|
||||
secondaryLabel,
|
||||
tertiaryLabel,
|
||||
onPrimary,
|
||||
onSecondary,
|
||||
@@ -33,6 +33,7 @@ export function ApprovalCard({
|
||||
onSecondary?: () => void;
|
||||
onTertiary?: () => void;
|
||||
}) {
|
||||
useLang();
|
||||
return (
|
||||
<div className="approval" data-tone={tone}>
|
||||
<div className="ap-head">
|
||||
@@ -50,12 +51,12 @@ export function ApprovalCard({
|
||||
<div className="ap-foot">
|
||||
{onPrimary ? (
|
||||
<button type="button" className="btn primary" onClick={onPrimary}>
|
||||
{primaryLabel}
|
||||
{primaryLabel ?? t("extraCards.approve")}
|
||||
</button>
|
||||
) : null}
|
||||
{onSecondary ? (
|
||||
<button type="button" className="btn ghost" onClick={onSecondary}>
|
||||
{secondaryLabel}
|
||||
{secondaryLabel ?? t("extraCards.reject")}
|
||||
</button>
|
||||
) : null}
|
||||
{onTertiary && tertiaryLabel ? (
|
||||
@@ -89,6 +90,7 @@ export function TaskCard({
|
||||
subtitle?: string;
|
||||
steps: TaskStepView[];
|
||||
}) {
|
||||
useLang();
|
||||
const done = steps.filter((x) => x.state === "done").length;
|
||||
const pct = steps.length ? (done / steps.length) * 100 : 0;
|
||||
return (
|
||||
@@ -183,6 +185,7 @@ export function TipCard({
|
||||
export type DoctorRow = { s: "ok" | "warn" | "fail"; nm: string; sub: string; v: string };
|
||||
|
||||
export function DoctorCard({ rows, headerSubtitle }: { rows: DoctorRow[]; headerSubtitle?: string }) {
|
||||
useLang();
|
||||
const c = { ok: 0, warn: 0, fail: 0 };
|
||||
for (const r of rows) c[r.s]++;
|
||||
return (
|
||||
@@ -192,7 +195,7 @@ export function DoctorCard({ rows, headerSubtitle }: { rows: DoctorRow[]; header
|
||||
<I.shield size={13} />
|
||||
</span>
|
||||
<div>
|
||||
<div className="tt">/doctor · 系统体检</div>
|
||||
<div className="tt">{t("extraCards.doctorTitle")}</div>
|
||||
{headerSubtitle ? (
|
||||
<div
|
||||
className="ss"
|
||||
@@ -252,6 +255,7 @@ export function UsageFull({
|
||||
balanceLabel?: string;
|
||||
range?: string;
|
||||
}) {
|
||||
useLang();
|
||||
const fmt = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`);
|
||||
const total = promptTokens + reasoningTokens + outputTokens + cacheHitTokens || 1;
|
||||
const pct = (n: number) => Math.round((n / total) * 100);
|
||||
@@ -266,22 +270,22 @@ export function UsageFull({
|
||||
</div>
|
||||
<div className="ub">
|
||||
<div className="ucol">
|
||||
<div className="l">prompt</div>
|
||||
<div className="l">{t("extraCards.prompt")}</div>
|
||||
<div className="v acc">{fmt(promptTokens)}</div>
|
||||
<div className="pct">{pct(promptTokens)}%</div>
|
||||
</div>
|
||||
<div className="ucol">
|
||||
<div className="l">reasoning</div>
|
||||
<div className="l">{t("extraCards.reasoning")}</div>
|
||||
<div className="v vio">{fmt(reasoningTokens)}</div>
|
||||
<div className="pct">{pct(reasoningTokens)}%</div>
|
||||
</div>
|
||||
<div className="ucol">
|
||||
<div className="l">output</div>
|
||||
<div className="l">{t("extraCards.output")}</div>
|
||||
<div className="v ok">{fmt(outputTokens)}</div>
|
||||
<div className="pct">{pct(outputTokens)}%</div>
|
||||
</div>
|
||||
<div className="ucol">
|
||||
<div className="l">cache hit</div>
|
||||
<div className="l">{t("extraCards.cacheHit")}</div>
|
||||
<div className="v">{fmt(cacheHitTokens)}</div>
|
||||
<div className="pct">{pct(cacheHitTokens)}%</div>
|
||||
</div>
|
||||
@@ -295,23 +299,23 @@ export function UsageFull({
|
||||
<div className="uf">
|
||||
<span className="x">
|
||||
<span className="sw" style={{ background: "var(--accent)" }} />
|
||||
prompt
|
||||
{t("extraCards.prompt")}
|
||||
</span>
|
||||
<span className="x">
|
||||
<span className="sw" style={{ background: "var(--violet)" }} />
|
||||
reasoning
|
||||
{t("extraCards.reasoning")}
|
||||
</span>
|
||||
<span className="x">
|
||||
<span className="sw" style={{ background: "var(--tone-ok)" }} />
|
||||
output
|
||||
{t("extraCards.output")}
|
||||
</span>
|
||||
<span className="x">
|
||||
<span className="sw" style={{ background: "var(--border-strong)" }} />
|
||||
cache
|
||||
{t("extraCards.cache")}
|
||||
</span>
|
||||
<span style={{ marginLeft: "auto" }}>
|
||||
{t("extraCards.sessionCost", { costLabel })}
|
||||
{balanceLabel ? ` · 余额 ${balanceLabel}` : ""}
|
||||
{balanceLabel ? ` · ${t("extraCards.balance", { balanceLabel })}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -332,10 +336,11 @@ export function CtxCard({
|
||||
parts: CtxPart[];
|
||||
topTools: CtxTopRow[];
|
||||
}) {
|
||||
useLang();
|
||||
return (
|
||||
<div className="ctx-card">
|
||||
<div className="h">
|
||||
<span className="tt">上下文窗口</span>
|
||||
<span className="tt">{t("extraCards.contextWindow")}</span>
|
||||
<span className="grow" />
|
||||
<span className="v">{totalLabel}</span>
|
||||
</div>
|
||||
@@ -365,7 +370,7 @@ export function CtxCard({
|
||||
</div>
|
||||
{topTools.length > 0 ? (
|
||||
<div className="ttop">
|
||||
<div className="stt">Top tools — 占用</div>
|
||||
<div className="stt">{t("extraCards.topToolsUsage")}</div>
|
||||
{topTools.map((t, i) => (
|
||||
<div className="row" key={i}>
|
||||
<span className="n">{t.name}</span>
|
||||
@@ -387,14 +392,15 @@ export type MemGroupKey = "user" | "feedback" | "project" | "reference";
|
||||
export type MemEntry = { text: string; meta?: string };
|
||||
export type MemGroups = Partial<Record<MemGroupKey, MemEntry[]>>;
|
||||
|
||||
const GROUP_LABELS: Record<MemGroupKey, string> = {
|
||||
user: "USER · 用户偏好",
|
||||
feedback: "FEEDBACK · 反馈与纠正",
|
||||
project: "PROJECT · 项目规范",
|
||||
reference: "REFERENCE · 引用资料",
|
||||
const GROUP_LABELS: Record<MemGroupKey, Parameters<typeof t>[0]> = {
|
||||
user: "extraCards.memoryUser",
|
||||
feedback: "extraCards.memoryFeedback",
|
||||
project: "extraCards.memoryProject",
|
||||
reference: "extraCards.memoryReference",
|
||||
};
|
||||
|
||||
export function MemoryGroups({ data }: { data: MemGroups }) {
|
||||
useLang();
|
||||
const keys: MemGroupKey[] = ["user", "feedback", "project", "reference"];
|
||||
return (
|
||||
<div className="mem-groups">
|
||||
@@ -405,7 +411,7 @@ export function MemoryGroups({ data }: { data: MemGroups }) {
|
||||
<div key={g}>
|
||||
<div className="gh" data-g={g}>
|
||||
<span className="sw" />
|
||||
<span>{GROUP_LABELS[g]}</span>
|
||||
<span>{t(GROUP_LABELS[g])}</span>
|
||||
<span className="grow" />
|
||||
<span className="cnt">{rows.length}</span>
|
||||
</div>
|
||||
@@ -426,9 +432,10 @@ export function MemoryGroups({ data }: { data: MemGroups }) {
|
||||
// ---- Fallback ----
|
||||
|
||||
export function FallbackCard({ kindLabel, payload }: { kindLabel: string; payload: Record<string, string> }) {
|
||||
useLang();
|
||||
return (
|
||||
<div className="fallback-card">
|
||||
<div className="hd"># unknown card kind — fallback render</div>
|
||||
<div className="hd">{t("extraCards.unknownKind")}</div>
|
||||
<div className="kv">
|
||||
<span className="k">kind</span>
|
||||
<span className="v">"{kindLabel}"</span>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { I } from "../icons";
|
||||
import { t, useLang } from "../i18n";
|
||||
import type { JobInfo } from "../protocol";
|
||||
import { Shortcut } from "./shortcut";
|
||||
|
||||
export function JobsPop({
|
||||
open,
|
||||
@@ -96,7 +97,8 @@ export function JobsPop({
|
||||
</span>
|
||||
<span className="grow" />
|
||||
<span>
|
||||
<kbd>⌘J</kbd> {t("jobs.kbToggle")} · <kbd>esc</kbd> {t("jobs.kbClose")}
|
||||
<Shortcut keys={["mod", "J"]} /> {t("jobs.kbToggle")} ·{" "}
|
||||
<Shortcut keys={["esc"]} /> {t("jobs.kbClose")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { I } from "../icons";
|
||||
import { t, useLang } from "../i18n";
|
||||
|
||||
export function fmtElapsed(ms: number): string {
|
||||
const s = ms / 1000;
|
||||
@@ -55,10 +56,11 @@ export function ThinkingPill({
|
||||
}
|
||||
|
||||
export function LiveReasoning({ lines }: { lines: string[] }) {
|
||||
useLang();
|
||||
return (
|
||||
<div className="live-reason">
|
||||
<div className="head">
|
||||
<span className="dot" /> live · reasoning
|
||||
<span className="dot" /> {t("live.reasoning")}
|
||||
</div>
|
||||
{lines.map((line, i) => (
|
||||
<div key={i}>
|
||||
@@ -81,6 +83,7 @@ export function ToolRunningCard({
|
||||
elapsedMs: number;
|
||||
logLines?: { text: string; tone?: "ok" | "dim" }[];
|
||||
}) {
|
||||
useLang();
|
||||
const ic =
|
||||
kind === "shell" ? <I.terminal size={12} /> : kind === "fetch" ? <I.globe size={12} /> : kind === "search" ? <I.search size={12} /> : <I.wrench size={12} />;
|
||||
return (
|
||||
@@ -90,7 +93,7 @@ export function ToolRunningCard({
|
||||
<span className="kind">{kind}</span>
|
||||
<span style={{ color: "var(--fg)", fontWeight: 500 }}>{name}</span>
|
||||
<span className="grow" />
|
||||
<span className="pill-tag run">running</span>
|
||||
<span className="spin-meta" role="img" aria-label={t("live.running")} title={t("live.running")} />
|
||||
<span className="timer">{fmtElapsed(elapsedMs)}</span>
|
||||
</div>
|
||||
{logLines && logLines.length > 0 ? (
|
||||
@@ -117,18 +120,21 @@ export function ToolRunningCard({
|
||||
}
|
||||
|
||||
export function PendingUserMsg({ text, elapsedMs }: { text: string; elapsedMs: number }) {
|
||||
useLang();
|
||||
return (
|
||||
<div className="msg user">
|
||||
<div className="avatar">YOU</div>
|
||||
<div className="body">
|
||||
<div className="who">
|
||||
<span className="name">你</span>
|
||||
<span className="time">{(elapsedMs / 1000).toFixed(1)}s ago</span>
|
||||
<span className="name">{t("live.you")}</span>
|
||||
<span className="time">
|
||||
{t("live.secondsAgo", { seconds: (elapsedMs / 1000).toFixed(1) })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="msg-text user-pending">{text}</div>
|
||||
<div className="user-status">
|
||||
<span className="spin" />
|
||||
<span>已送达 · 等待 agent 响应</span>
|
||||
<span>{t("live.deliveredWaiting")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||