diff --git a/.gitignore b/.gitignore
index bdb2433..f499a46 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,6 @@ out/
.env
.env.local
release/
+preview-shots/
+*.tsbuildinfo
+capture-log.txt
diff --git a/.npmrc b/.npmrc
deleted file mode 100644
index 8084498..0000000
--- a/.npmrc
+++ /dev/null
@@ -1,2 +0,0 @@
-save-exact=true
-engine-strict=false
diff --git a/README.md b/README.md
deleted file mode 100644
index f8a06b8..0000000
--- a/README.md
+++ /dev/null
@@ -1,28 +0,0 @@
-# agent-store-operations
-
-店铺运营 Agent 桌面工具 —— Electron for [pi agent](https://pi.dev/)。
-
-## 仓库结构
-
-```
-apps/desktop/ Electron 客户端(React + Tailwind + GSAP,主进程内嵌 pi SDK)
-docs/ 架构决策、pi 集成笔记、参考项目取舍、路线图与版本自省
-```
-
-> 后端(NestJS)、后台(Ant Design Pro)、数据库尚未设计,按约定不创建。见 `docs/architecture.md`。
-
-## 开发
-
-```bash
-pnpm install
-pnpm dev # 启动 desktop(electron-vite HMR)
-pnpm typecheck
-```
-
-## 文档
-
-- [architecture.md](docs/architecture.md) — 架构决策记录(ADR)
-- [pi-sdk-notes.md](docs/pi-sdk-notes.md) — pi SDK 集成笔记
-- [references.md](docs/references.md) — 参考项目取舍(pi-web / Proma / beautifului)
-- [tech-stack.md](docs/tech-stack.md) — 技术栈
-- [roadmap.md](docs/roadmap.md) — 版本路线图与自省日志
diff --git a/apps/desktop/build/icon.ico b/apps/desktop/build/icon.ico
new file mode 100644
index 0000000..64c6c40
Binary files /dev/null and b/apps/desktop/build/icon.ico differ
diff --git a/apps/desktop/build/icon.png b/apps/desktop/build/icon.png
new file mode 100644
index 0000000..574f0ab
Binary files /dev/null and b/apps/desktop/build/icon.png differ
diff --git a/apps/desktop/build/icon.svg b/apps/desktop/build/icon.svg
new file mode 100644
index 0000000..3564271
--- /dev/null
+++ b/apps/desktop/build/icon.svg
@@ -0,0 +1,14 @@
+
diff --git a/apps/desktop/components.json b/apps/desktop/components.json
new file mode 100644
index 0000000..25ea240
--- /dev/null
+++ b/apps/desktop/components.json
@@ -0,0 +1,19 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "new-york",
+ "rsc": false,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "src/renderer/src/styles/globals.css",
+ "baseColor": "zinc",
+ "cssVariables": true
+ },
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ }
+}
diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts
index 3bbdeca..4e8b8e6 100644
--- a/apps/desktop/electron.vite.config.ts
+++ b/apps/desktop/electron.vite.config.ts
@@ -48,6 +48,7 @@ export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
+ '@': resolve(__dirname, 'src/renderer/src'),
'@renderer': resolve(__dirname, 'src/renderer/src'),
'@shared': resolve(__dirname, 'src/shared')
}
diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json
new file mode 100644
index 0000000..9801e9e
--- /dev/null
+++ b/apps/desktop/package-lock.json
@@ -0,0 +1,2365 @@
+{
+ "name": "@aso/desktop",
+ "version": "0.5.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "@aso/desktop",
+ "version": "0.5.0",
+ "dependencies": {
+ "@earendil-works/pi-ai": "0.85.0",
+ "@earendil-works/pi-coding-agent": "0.85.0",
+ "@earendil-works/pi-server": "0.85.0",
+ "@llm-ui/code": "^0.13.3",
+ "@llm-ui/markdown": "^0.13.3",
+ "@llm-ui/react": "^0.13.3",
+ "class-variance-authority": "0.7.1",
+ "clsx": "2.1.1",
+ "gsap": "3.15.0",
+ "react": "19.2.8",
+ "react-dom": "19.2.8",
+ "react-icons": "5.7.0",
+ "react-markdown": "^10.1.0",
+ "remark-gfm": "^4.0.1",
+ "shadow-plugin": "2.1.0",
+ "shiki": "^1.29.2",
+ "tailwind-merge": "3.6.0"
+ },
+ "devDependencies": {
+ "@tailwindcss/vite": "4.3.3",
+ "@types/node": "26.4.1",
+ "@types/react": "19.2.18",
+ "@types/react-dom": "19.2.7",
+ "@vitejs/plugin-react": "6.1.1",
+ "electron": "44.2.0",
+ "electron-vite": "5.0.0",
+ "tailwindcss": "4.3.3",
+ "typescript": "7.0.2",
+ "vite": "8.2.2"
+ }
+ },
+ "../../node_modules/.pnpm/@earendil-works+pi-ai@0.85.0_ws@8.21.3/node_modules/@earendil-works/pi-ai": {
+ "version": "0.85.0",
+ "license": "MIT",
+ "dependencies": {
+ "@anthropic-ai/sdk": "0.123.0",
+ "@aws-sdk/client-bedrock-runtime": "3.1048.0",
+ "@earendil-works/pi-telemetry": "^0.85.0",
+ "@google/genai": "1.52.0",
+ "@smithy/node-http-handler": "4.7.3",
+ "http-proxy-agent": "7.0.2",
+ "https-proxy-agent": "7.0.6",
+ "openai": "6.40.0",
+ "partial-json": "0.1.7",
+ "typebox": "1.3.7"
+ },
+ "bin": {
+ "pi-ai": "dist/cli.js"
+ },
+ "devDependencies": {
+ "@types/node": "22.19.19",
+ "canvas": "3.2.3",
+ "vitest": "4.1.9"
+ },
+ "engines": {
+ "node": ">=22.19.0"
+ }
+ },
+ "../../node_modules/.pnpm/@earendil-works+pi-coding-agent@0.85.0_ws@8.21.3/node_modules/@earendil-works/pi-coding-agent": {
+ "version": "0.85.0",
+ "license": "MIT",
+ "dependencies": {
+ "@earendil-works/chord": "^0.85.0",
+ "@earendil-works/pi-agent-core": "^0.85.0",
+ "@earendil-works/pi-ai": "^0.85.0",
+ "@earendil-works/pi-client": "^0.85.0",
+ "@earendil-works/pi-protocol": "^0.85.0",
+ "@earendil-works/pi-tui": "^0.85.0",
+ "@silvia-odwyer/photon-node": "0.3.4",
+ "chalk": "5.6.2",
+ "cross-spawn": "7.0.6",
+ "diff": "8.0.4",
+ "grok-mermaid": "0.2.2",
+ "highlight.js": "10.7.3",
+ "hosted-git-info": "9.0.3",
+ "ignore": "7.0.5",
+ "jiti": "2.7.0",
+ "minimatch": "10.2.5",
+ "proper-lockfile": "4.1.2",
+ "semver": "7.8.0",
+ "typebox": "1.3.7",
+ "undici": "8.9.0",
+ "yaml": "2.9.0"
+ },
+ "bin": {
+ "pi": "dist/bundle/cli.js"
+ },
+ "devDependencies": {
+ "@types/cross-spawn": "6.0.6",
+ "@types/hosted-git-info": "3.0.5",
+ "@types/node": "22.19.19",
+ "@types/proper-lockfile": "4.1.4",
+ "@types/semver": "7.7.1",
+ "shx": "0.4.0",
+ "typescript": "5.9.3",
+ "vitest": "4.1.9"
+ },
+ "engines": {
+ "node": ">=22.19.0"
+ },
+ "optionalDependencies": {
+ "@mariozechner/clipboard": "0.3.9"
+ }
+ },
+ "../../node_modules/.pnpm/@earendil-works+pi-server@0.85.0_ws@8.21.3/node_modules/@earendil-works/pi-server": {
+ "version": "0.85.0",
+ "license": "MIT",
+ "dependencies": {
+ "@earendil-works/chord": "^0.85.0",
+ "@earendil-works/pi-agent-core": "^0.85.0",
+ "@earendil-works/pi-protocol": "^0.85.0"
+ },
+ "devDependencies": {
+ "shx": "0.4.0",
+ "vitest": "4.1.9"
+ },
+ "engines": {
+ "node": ">=22.19.0"
+ }
+ },
+ "../../node_modules/.pnpm/@tailwindcss+vite@4.3.3_vit_a9eca87458f3ee6e74129ebf668d736e/node_modules/@tailwindcss/vite": {
+ "version": "4.3.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@tailwindcss/node": "4.3.3",
+ "@tailwindcss/oxide": "4.3.3",
+ "tailwindcss": "4.3.3"
+ },
+ "devDependencies": {
+ "@types/node": "22.19.21",
+ "vite": "^8.1.2"
+ },
+ "peerDependencies": {
+ "vite": "^5.2.0 || ^6 || ^7 || ^8"
+ }
+ },
+ "../../node_modules/.pnpm/@types+node@26.4.1/node_modules/@types/node": {
+ "version": "26.4.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~8.3.0"
+ }
+ },
+ "../../node_modules/.pnpm/@types+react-dom@19.2.7_@types+react@19.2.18/node_modules/@types/react-dom": {
+ "version": "19.2.7",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "../../node_modules/.pnpm/@types+react@19.2.18/node_modules/@types/react": {
+ "version": "19.2.18",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "../../node_modules/.pnpm/@vitejs+plugin-react@6.1.1__53224dc688df1e7fe4782d8236195344/node_modules/@vitejs/plugin-react": {
+ "version": "6.1.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rolldown/pluginutils": "^1.0.1"
+ },
+ "devDependencies": {
+ "@babel/core": "8.0.1",
+ "@rolldown/plugin-babel": "^0.2.3",
+ "@vitejs/react-common": "workspace:*",
+ "babel-plugin-react-compiler": "^1.0.0",
+ "oxc-transform-react": "^0.147.0",
+ "react": "^19.2.8",
+ "react-dom": "^19.2.8",
+ "rolldown": "^1.2.6",
+ "tsdown": "^0.22.14",
+ "vite": "^8.2.2"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
+ "babel-plugin-react-compiler": "^1.0.0",
+ "oxc-transform-react": "^0.145.0",
+ "vite": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@rolldown/plugin-babel": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ },
+ "oxc-transform-react": {
+ "optional": true
+ }
+ }
+ },
+ "../../node_modules/.pnpm/class-variance-authority@0.7.1/node_modules/class-variance-authority": {
+ "version": "0.7.1",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "clsx": "^2.1.1"
+ },
+ "devDependencies": {
+ "@swc/cli": "0.3.12",
+ "@swc/core": "1.4.16",
+ "@types/node": "20.12.7",
+ "@types/react": "18.2.79",
+ "@types/react-dom": "18.2.25",
+ "bundlesize": "0.18.2",
+ "npm-run-all": "4.1.5",
+ "react": "18.2.0",
+ "react-dom": "18.2.0",
+ "ts-node": "10.9.2",
+ "typescript": "5.4.5"
+ },
+ "funding": {
+ "url": "https://polar.sh/cva"
+ }
+ },
+ "../../node_modules/.pnpm/clsx@2.1.1/node_modules/clsx": {
+ "version": "2.1.1",
+ "license": "MIT",
+ "devDependencies": {
+ "esm": "3.2.25",
+ "terser": "4.8.0",
+ "uvu": "0.5.4"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "../../node_modules/.pnpm/electron-vite@5.0.0_vite@8._18387c8b1a9e0f53f139a78b83265a64/node_modules/electron-vite": {
+ "version": "5.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.28.4",
+ "@babel/plugin-transform-arrow-functions": "^7.27.1",
+ "cac": "^6.7.14",
+ "esbuild": "^0.25.11",
+ "magic-string": "^0.30.19",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "electron-vite": "bin/electron-vite.js"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.37.0",
+ "@rollup/plugin-json": "^6.1.0",
+ "@rollup/plugin-node-resolve": "^16.0.3",
+ "@rollup/plugin-typescript": "^12.1.4",
+ "@swc/core": "^1.13.5",
+ "@types/babel__core": "^7.20.5",
+ "@types/node": "^22.18.11",
+ "eslint": "^9.37.0",
+ "eslint-config-prettier": "^10.1.8",
+ "eslint-plugin-prettier": "^5.5.4",
+ "globals": "^16.4.0",
+ "lint-staged": "^16.2.4",
+ "prettier": "^3.6.2",
+ "rollup": "^4.52.4",
+ "rollup-plugin-dts": "^6.2.3",
+ "rollup-plugin-rm": "^1.0.2",
+ "simple-git-hooks": "^2.13.1",
+ "tslib": "^2.8.1",
+ "typescript": "^5.9.3",
+ "typescript-eslint": "^8.46.1",
+ "vite": "^7.1.10"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "@swc/core": "^1.0.0",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@swc/core": {
+ "optional": true
+ }
+ }
+ },
+ "../../node_modules/.pnpm/electron@44.2.0/node_modules/electron": {
+ "version": "44.2.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@electron-internal/extract-zip": "^1.0.1",
+ "@electron/get": "^5.0.0",
+ "@types/node": "^24.9.0"
+ },
+ "bin": {
+ "electron": "cli.js",
+ "install-electron": "install.js"
+ },
+ "engines": {
+ "node": ">= 22.12.0"
+ }
+ },
+ "../../node_modules/.pnpm/gsap@3.15.0/node_modules/gsap": {
+ "version": "3.15.0",
+ "license": "Standard 'no charge' license: https://gsap.com/standard-license."
+ },
+ "../../node_modules/.pnpm/react-dom@19.2.8_react@19.2.8/node_modules/react-dom": {
+ "version": "19.2.8",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.8"
+ }
+ },
+ "../../node_modules/.pnpm/react-icons@5.7.0_react@19.2.8/node_modules/react-icons": {
+ "version": "5.7.0",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "*"
+ }
+ },
+ "../../node_modules/.pnpm/react@19.2.8/node_modules/react": {
+ "version": "19.2.8",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "../../node_modules/.pnpm/shadow-plugin@2.1.0/node_modules/shadow-plugin": {
+ "version": "2.1.0",
+ "license": "MIT",
+ "devDependencies": {
+ "@tailwindcss/cli": "^4.3.3",
+ "tailwindcss": "^4.3.3"
+ }
+ },
+ "../../node_modules/.pnpm/tailwind-merge@3.6.0/node_modules/tailwind-merge": {
+ "version": "3.6.0",
+ "license": "MIT",
+ "devDependencies": {
+ "@babel/core": "^7.29.0",
+ "@babel/preset-env": "^7.29.3",
+ "@codspeed/vitest-plugin": "^5.4.0",
+ "@rollup/plugin-babel": "^7.0.0",
+ "@rollup/plugin-node-resolve": "^16.0.3",
+ "@rollup/plugin-typescript": "^12.3.0",
+ "@types/node": "^24.12.2",
+ "@vitest/coverage-v8": "^4.1.5",
+ "@vitest/eslint-plugin": "^1.6.16",
+ "babel-plugin-annotate-pure-calls": "^0.5.0",
+ "babel-plugin-polyfill-regenerator": "^0.6.8",
+ "eslint": "^9.39.4",
+ "eslint-plugin-import": "^2.32.0",
+ "globby": "^16.2.0",
+ "prettier": "^3.8.3",
+ "rollup": "^4.60.2",
+ "rollup-plugin-delete": "^3.0.2",
+ "rollup-plugin-dts": "^6.4.1",
+ "tslib": "^2.8.1",
+ "typescript": "^5.9.3",
+ "typescript-eslint": "^8.59.1",
+ "vitest": "^4.1.5",
+ "zx": "^8.8.5"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/dcastil"
+ }
+ },
+ "../../node_modules/.pnpm/tailwindcss@4.3.3/node_modules/tailwindcss": {
+ "version": "4.3.3",
+ "dev": true,
+ "license": "MIT",
+ "devDependencies": {
+ "@jridgewell/remapping": "^2.3.5",
+ "@tailwindcss/oxide": "^4.3.3",
+ "@types/node": "22.19.21",
+ "dedent": "1.7.2",
+ "lightningcss": "1.32.0",
+ "magic-string": "^0.30.21",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "../../node_modules/.pnpm/typescript@7.0.2/node_modules/typescript": {
+ "version": "7.0.2",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc"
+ },
+ "engines": {
+ "node": ">=16.20.0"
+ },
+ "optionalDependencies": {
+ "@typescript/typescript-aix-ppc64": "7.0.2",
+ "@typescript/typescript-darwin-arm64": "7.0.2",
+ "@typescript/typescript-darwin-x64": "7.0.2",
+ "@typescript/typescript-freebsd-arm64": "7.0.2",
+ "@typescript/typescript-freebsd-x64": "7.0.2",
+ "@typescript/typescript-linux-arm": "7.0.2",
+ "@typescript/typescript-linux-arm64": "7.0.2",
+ "@typescript/typescript-linux-loong64": "7.0.2",
+ "@typescript/typescript-linux-mips64el": "7.0.2",
+ "@typescript/typescript-linux-ppc64": "7.0.2",
+ "@typescript/typescript-linux-riscv64": "7.0.2",
+ "@typescript/typescript-linux-s390x": "7.0.2",
+ "@typescript/typescript-linux-x64": "7.0.2",
+ "@typescript/typescript-netbsd-arm64": "7.0.2",
+ "@typescript/typescript-netbsd-x64": "7.0.2",
+ "@typescript/typescript-openbsd-arm64": "7.0.2",
+ "@typescript/typescript-openbsd-x64": "7.0.2",
+ "@typescript/typescript-sunos-x64": "7.0.2",
+ "@typescript/typescript-win32-arm64": "7.0.2",
+ "@typescript/typescript-win32-x64": "7.0.2"
+ }
+ },
+ "../../node_modules/.pnpm/vite@8.2.2_@types+node@26.4_9aec1c9b231ec5c84774cec00150d533/node_modules/vite": {
+ "version": "8.2.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.33.0",
+ "picomatch": "^4.0.5",
+ "postcss": "^8.5.26",
+ "rolldown": "~1.2.4",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "devDependencies": {
+ "@babel/parser": "^7.29.8",
+ "@jridgewell/remapping": "^2.3.5",
+ "@jridgewell/trace-mapping": "^0.3.31",
+ "@polka/compression": "^1.0.0-next.25",
+ "@rollup/plugin-alias": "^6.0.0",
+ "@rollup/plugin-dynamic-import-vars": "2.1.4",
+ "@rollup/pluginutils": "^5.4.0",
+ "@types/escape-html": "^1.0.4",
+ "@types/pnpapi": "^0.0.5",
+ "@vercel/detect-agent": "^1.2.5",
+ "@vitejs/devtools": "^0.4.12",
+ "@vitest/utils": "4.1.10",
+ "@voidzero-dev/vite-task-client": "^0.2.0",
+ "artichokie": "^0.4.4",
+ "baseline-browser-mapping": "^2.11.14",
+ "cac": "^7.0.0",
+ "chokidar": "^3.6.0",
+ "connect": "^3.7.0",
+ "convert-source-map": "^2.0.0",
+ "cors": "^2.8.6",
+ "cross-spawn": "^7.0.6",
+ "dotenv-expand": "^13.0.0",
+ "es-module-lexer": "^2.3.1",
+ "esbuild": "^0.28.2",
+ "escape-html": "^1.0.3",
+ "estree-walker": "^3.0.3",
+ "etag": "^1.8.1",
+ "fresh-import": "^0.2.1",
+ "host-validation-middleware": "^0.1.4",
+ "http-proxy-3": "^1.23.3",
+ "launch-editor-middleware": "^2.14.1",
+ "magic-string": "^1.2.0",
+ "mlly": "^1.8.2",
+ "mrmime": "^2.0.1",
+ "nanoid": "^5.1.16",
+ "obug": "^1.0.2",
+ "open": "^10.2.0",
+ "parse5": "^8.0.1",
+ "pathe": "^2.0.3",
+ "periscopic": "^4.0.3",
+ "picocolors": "^1.1.1",
+ "postcss-import": "^16.2.0",
+ "postcss-load-config": "^6.0.1",
+ "postcss-modules": "^9.0.1",
+ "premove": "^4.0.0",
+ "resolve.exports": "^2.0.3",
+ "rolldown-plugin-dts": "^0.28.2",
+ "rollup": "^4.59.0",
+ "rollup-plugin-license": "^3.7.1",
+ "sass": "^1.102.0",
+ "sass-embedded": "^1.102.0",
+ "sirv": "^3.0.2",
+ "strip-literal": "^4.0.0",
+ "terser": "^5.50.0",
+ "ufo": "^1.6.4",
+ "ws": "^8.21.3"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.4.0 || ^0.5.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@earendil-works/pi-ai": {
+ "resolved": "../../node_modules/.pnpm/@earendil-works+pi-ai@0.85.0_ws@8.21.3/node_modules/@earendil-works/pi-ai",
+ "link": true
+ },
+ "node_modules/@earendil-works/pi-coding-agent": {
+ "resolved": "../../node_modules/.pnpm/@earendil-works+pi-coding-agent@0.85.0_ws@8.21.3/node_modules/@earendil-works/pi-coding-agent",
+ "link": true
+ },
+ "node_modules/@earendil-works/pi-server": {
+ "resolved": "../../node_modules/.pnpm/@earendil-works+pi-server@0.85.0_ws@8.21.3/node_modules/@earendil-works/pi-server",
+ "link": true
+ },
+ "node_modules/@llm-ui/code": {
+ "version": "0.13.3",
+ "resolved": "https://registry.npmjs.org/@llm-ui/code/-/code-0.13.3.tgz",
+ "integrity": "sha512-bqHjHanYbNetuDJaAmVwIg4vRhH6Cth4Ljo2YxHZNEpDtEqLHonDaAcAq8//mibFGVwhspxIQYClnT8K4Zxlqg==",
+ "license": "MIT",
+ "dependencies": {
+ "@llm-ui/react": "0.13.3",
+ "@llm-ui/shared": "0.13.3"
+ },
+ "peerDependencies": {
+ "react": "^18.0.0",
+ "shiki": "^1.6.0"
+ }
+ },
+ "node_modules/@llm-ui/markdown": {
+ "version": "0.13.3",
+ "resolved": "https://registry.npmjs.org/@llm-ui/markdown/-/markdown-0.13.3.tgz",
+ "integrity": "sha512-7wXte9RXQosSFKneYnVwnxUvwIgov7bE04nqA6PjMBDqxxeSI3FD5TFzrpBMj6+TN2Urraj5iJWaL4IdCQNtsA==",
+ "license": "MIT",
+ "dependencies": {
+ "@llm-ui/react": "0.13.3",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-gfm": "^3.0.0",
+ "mdast-util-to-markdown": "^2.1.0",
+ "micromark-extension-gfm": "^3.0.0",
+ "remeda": "^1.57.0",
+ "unist-util-visit": "^5.0.0"
+ },
+ "peerDependencies": {
+ "react": "^18.0.0"
+ }
+ },
+ "node_modules/@llm-ui/react": {
+ "version": "0.13.3",
+ "resolved": "https://registry.npmjs.org/@llm-ui/react/-/react-0.13.3.tgz",
+ "integrity": "sha512-b5UtUl8KZp+P4VyAypNqGnSv8jZVJlyYrH8wvOQNIa9x/Y3H4zWiSNTV5BYk5rOJ7WPDCruvLJX/G87+HR1gog==",
+ "license": "MIT",
+ "dependencies": {
+ "remeda": "^1.57.0"
+ },
+ "peerDependencies": {
+ "react": "^18.0.0"
+ }
+ },
+ "node_modules/@llm-ui/shared": {
+ "version": "0.13.3",
+ "resolved": "https://registry.npmjs.org/@llm-ui/shared/-/shared-0.13.3.tgz",
+ "integrity": "sha512-+vt6uHnYXKqCtA+rNxCRwxdLFft3muMFA0JYMxf1b11gSMriO0Jgvwwx72dn1YWmX4gF1YHGRbMPWBe7V6CQCA==",
+ "license": "MIT",
+ "dependencies": {
+ "@llm-ui/react": "0.13.3"
+ }
+ },
+ "node_modules/@shikijs/core": {
+ "version": "1.29.2",
+ "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-1.29.2.tgz",
+ "integrity": "sha512-vju0lY9r27jJfOY4Z7+Rt/nIOjzJpZ3y+nYpqtUZInVoXQ/TJZcfGnNOGnKjFdVZb8qexiCuSlZRKcGfhhTTZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/engine-javascript": "1.29.2",
+ "@shikijs/engine-oniguruma": "1.29.2",
+ "@shikijs/types": "1.29.2",
+ "@shikijs/vscode-textmate": "^10.0.1",
+ "@types/hast": "^3.0.4",
+ "hast-util-to-html": "^9.0.4"
+ }
+ },
+ "node_modules/@shikijs/engine-javascript": {
+ "version": "1.29.2",
+ "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-1.29.2.tgz",
+ "integrity": "sha512-iNEZv4IrLYPv64Q6k7EPpOCE/nuvGiKl7zxdq0WFuRPF5PAE9PRo2JGq/d8crLusM59BRemJ4eOqrFrC4wiQ+A==",
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/types": "1.29.2",
+ "@shikijs/vscode-textmate": "^10.0.1",
+ "oniguruma-to-es": "^2.2.0"
+ }
+ },
+ "node_modules/@shikijs/engine-oniguruma": {
+ "version": "1.29.2",
+ "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-1.29.2.tgz",
+ "integrity": "sha512-7iiOx3SG8+g1MnlzZVDYiaeHe7Ez2Kf2HrJzdmGwkRisT7r4rak0e655AcM/tF9JG/kg5fMNYlLLKglbN7gBqA==",
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/types": "1.29.2",
+ "@shikijs/vscode-textmate": "^10.0.1"
+ }
+ },
+ "node_modules/@shikijs/langs": {
+ "version": "1.29.2",
+ "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-1.29.2.tgz",
+ "integrity": "sha512-FIBA7N3LZ+223U7cJDUYd5shmciFQlYkFXlkKVaHsCPgfVLiO+e12FmQE6Tf9vuyEsFe3dIl8qGWKXgEHL9wmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/types": "1.29.2"
+ }
+ },
+ "node_modules/@shikijs/themes": {
+ "version": "1.29.2",
+ "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-1.29.2.tgz",
+ "integrity": "sha512-i9TNZlsq4uoyqSbluIcZkmPL9Bfi3djVxRnofUHwvx/h6SRW3cwgBC5SML7vsDcWyukY0eCzVN980rqP6qNl9g==",
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/types": "1.29.2"
+ }
+ },
+ "node_modules/@shikijs/types": {
+ "version": "1.29.2",
+ "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-1.29.2.tgz",
+ "integrity": "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw==",
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/vscode-textmate": "^10.0.1",
+ "@types/hast": "^3.0.4"
+ }
+ },
+ "node_modules/@shikijs/vscode-textmate": {
+ "version": "10.0.2",
+ "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz",
+ "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
+ "license": "MIT"
+ },
+ "node_modules/@tailwindcss/vite": {
+ "resolved": "../../node_modules/.pnpm/@tailwindcss+vite@4.3.3_vit_a9eca87458f3ee6e74129ebf668d736e/node_modules/@tailwindcss/vite",
+ "link": true
+ },
+ "node_modules/@types/debug": {
+ "version": "4.1.13",
+ "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
+ "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/ms": "*"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/estree-jsx": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz",
+ "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "*"
+ }
+ },
+ "node_modules/@types/hast": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz",
+ "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
+ "node_modules/@types/mdast": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
+ "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
+ "node_modules/@types/ms": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
+ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "resolved": "../../node_modules/.pnpm/@types+node@26.4.1/node_modules/@types/node",
+ "link": true
+ },
+ "node_modules/@types/react": {
+ "resolved": "../../node_modules/.pnpm/@types+react@19.2.18/node_modules/@types/react",
+ "link": true
+ },
+ "node_modules/@types/react-dom": {
+ "resolved": "../../node_modules/.pnpm/@types+react-dom@19.2.7_@types+react@19.2.18/node_modules/@types/react-dom",
+ "link": true
+ },
+ "node_modules/@types/unist": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
+ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
+ "license": "MIT"
+ },
+ "node_modules/@ungap/structured-clone": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz",
+ "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==",
+ "license": "ISC"
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "resolved": "../../node_modules/.pnpm/@vitejs+plugin-react@6.1.1__53224dc688df1e7fe4782d8236195344/node_modules/@vitejs/plugin-react",
+ "link": true
+ },
+ "node_modules/bail": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
+ "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/ccount": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
+ "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
+ "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities-html4": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz",
+ "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities-legacy": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz",
+ "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-reference-invalid": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz",
+ "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/class-variance-authority": {
+ "resolved": "../../node_modules/.pnpm/class-variance-authority@0.7.1/node_modules/class-variance-authority",
+ "link": true
+ },
+ "node_modules/clsx": {
+ "resolved": "../../node_modules/.pnpm/clsx@2.1.1/node_modules/clsx",
+ "link": true
+ },
+ "node_modules/comma-separated-tokens": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
+ "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decode-named-character-reference": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
+ "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==",
+ "license": "MIT",
+ "dependencies": {
+ "character-entities": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/devlop": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
+ "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
+ "license": "MIT",
+ "dependencies": {
+ "dequal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/electron": {
+ "resolved": "../../node_modules/.pnpm/electron@44.2.0/node_modules/electron",
+ "link": true
+ },
+ "node_modules/electron-vite": {
+ "resolved": "../../node_modules/.pnpm/electron-vite@5.0.0_vite@8._18387c8b1a9e0f53f139a78b83265a64/node_modules/electron-vite",
+ "link": true
+ },
+ "node_modules/emoji-regex-xs": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz",
+ "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==",
+ "license": "MIT"
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
+ "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/estree-util-is-identifier-name": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz",
+ "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/extend": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
+ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
+ "license": "MIT"
+ },
+ "node_modules/gsap": {
+ "resolved": "../../node_modules/.pnpm/gsap@3.15.0/node_modules/gsap",
+ "link": true
+ },
+ "node_modules/hast-util-to-html": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz",
+ "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/unist": "^3.0.0",
+ "ccount": "^2.0.0",
+ "comma-separated-tokens": "^2.0.0",
+ "hast-util-whitespace": "^3.0.0",
+ "html-void-elements": "^3.0.0",
+ "mdast-util-to-hast": "^13.0.0",
+ "property-information": "^7.0.0",
+ "space-separated-tokens": "^2.0.0",
+ "stringify-entities": "^4.0.0",
+ "zwitch": "^2.0.4"
+ },
+ "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",
+ "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/unist": "^3.0.0",
+ "comma-separated-tokens": "^2.0.0",
+ "devlop": "^1.0.0",
+ "estree-util-is-identifier-name": "^3.0.0",
+ "hast-util-whitespace": "^3.0.0",
+ "mdast-util-mdx-expression": "^2.0.0",
+ "mdast-util-mdx-jsx": "^3.0.0",
+ "mdast-util-mdxjs-esm": "^2.0.0",
+ "property-information": "^7.0.0",
+ "space-separated-tokens": "^2.0.0",
+ "style-to-js": "^1.0.0",
+ "unist-util-position": "^5.0.0",
+ "vfile-message": "^4.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",
+ "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.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",
+ "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/html-void-elements": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz",
+ "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/inline-style-parser": {
+ "version": "0.2.7",
+ "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
+ "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
+ "license": "MIT"
+ },
+ "node_modules/is-alphabetical": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
+ "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-alphanumerical": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz",
+ "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==",
+ "license": "MIT",
+ "dependencies": {
+ "is-alphabetical": "^2.0.0",
+ "is-decimal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-decimal": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz",
+ "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-hexadecimal": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz",
+ "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-plain-obj": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
+ "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/longest-streak": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
+ "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/markdown-table": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
+ "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/mdast-util-find-and-replace": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
+ "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "escape-string-regexp": "^5.0.0",
+ "unist-util-is": "^6.0.0",
+ "unist-util-visit-parents": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-from-markdown": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz",
+ "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-to-string": "^4.0.0",
+ "micromark": "^4.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-decode-string": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0",
+ "unist-util-stringify-position": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz",
+ "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==",
+ "license": "MIT",
+ "dependencies": {
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-gfm-autolink-literal": "^2.0.0",
+ "mdast-util-gfm-footnote": "^2.0.0",
+ "mdast-util-gfm-strikethrough": "^2.0.0",
+ "mdast-util-gfm-table": "^2.0.0",
+ "mdast-util-gfm-task-list-item": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-autolink-literal": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz",
+ "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "ccount": "^2.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-find-and-replace": "^3.0.0",
+ "micromark-util-character": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-footnote": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz",
+ "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.1.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-strikethrough": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz",
+ "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-table": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz",
+ "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "markdown-table": "^3.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-task-list-item": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz",
+ "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.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",
+ "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-mdx-jsx": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz",
+ "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "ccount": "^2.0.0",
+ "devlop": "^1.1.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0",
+ "parse-entities": "^4.0.0",
+ "stringify-entities": "^4.0.0",
+ "unist-util-stringify-position": "^4.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-mdxjs-esm": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz",
+ "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-phrasing": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz",
+ "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "unist-util-is": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-hast": {
+ "version": "13.2.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
+ "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "@ungap/structured-clone": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "trim-lines": "^3.0.0",
+ "unist-util-position": "^5.0.0",
+ "unist-util-visit": "^5.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-markdown": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz",
+ "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "longest-streak": "^3.0.0",
+ "mdast-util-phrasing": "^4.0.0",
+ "mdast-util-to-string": "^4.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-decode-string": "^2.0.0",
+ "unist-util-visit": "^5.0.0",
+ "zwitch": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz",
+ "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
+ "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@types/debug": "^4.0.0",
+ "debug": "^4.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-core-commonmark": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-combine-extensions": "^2.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-encode": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "micromark-util-subtokenize": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-core-commonmark": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz",
+ "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-factory-destination": "^2.0.0",
+ "micromark-factory-label": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-factory-title": "^2.0.0",
+ "micromark-factory-whitespace": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-html-tag-name": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-subtokenize": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-extension-gfm": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz",
+ "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==",
+ "license": "MIT",
+ "dependencies": {
+ "micromark-extension-gfm-autolink-literal": "^2.0.0",
+ "micromark-extension-gfm-footnote": "^2.0.0",
+ "micromark-extension-gfm-strikethrough": "^2.0.0",
+ "micromark-extension-gfm-table": "^2.0.0",
+ "micromark-extension-gfm-tagfilter": "^2.0.0",
+ "micromark-extension-gfm-task-list-item": "^2.0.0",
+ "micromark-util-combine-extensions": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-autolink-literal": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz",
+ "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==",
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-sanitize-uri": "^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-extension-gfm-footnote": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz",
+ "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-core-commonmark": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-sanitize-uri": "^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-extension-gfm-strikethrough": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz",
+ "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-resolve-all": "^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-extension-gfm-table": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz",
+ "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.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-extension-gfm-tagfilter": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz",
+ "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==",
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-task-list-item": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz",
+ "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.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",
+ "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-label": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz",
+ "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-space": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz",
+ "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-title": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz",
+ "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-whitespace": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz",
+ "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-character": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz",
+ "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-chunked": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz",
+ "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-classify-character": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz",
+ "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-combine-extensions": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz",
+ "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-decode-numeric-character-reference": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz",
+ "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-decode-string": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz",
+ "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decode-named-character-reference": "^1.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-encode": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz",
+ "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-html-tag-name": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz",
+ "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-normalize-identifier": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz",
+ "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-resolve-all": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz",
+ "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-sanitize-uri": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz",
+ "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-encode": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-subtokenize": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz",
+ "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-symbol": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz",
+ "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-types": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz",
+ "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/oniguruma-to-es": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-2.3.0.tgz",
+ "integrity": "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex-xs": "^1.0.0",
+ "regex": "^5.1.1",
+ "regex-recursion": "^5.1.1"
+ }
+ },
+ "node_modules/parse-entities": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
+ "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^2.0.0",
+ "character-entities-legacy": "^3.0.0",
+ "character-reference-invalid": "^2.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "is-alphanumerical": "^2.0.0",
+ "is-decimal": "^2.0.0",
+ "is-hexadecimal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/parse-entities/node_modules/@types/unist": {
+ "version": "2.0.11",
+ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
+ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
+ "license": "MIT"
+ },
+ "node_modules/property-information": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz",
+ "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/react": {
+ "resolved": "../../node_modules/.pnpm/react@19.2.8/node_modules/react",
+ "link": true
+ },
+ "node_modules/react-dom": {
+ "resolved": "../../node_modules/.pnpm/react-dom@19.2.8_react@19.2.8/node_modules/react-dom",
+ "link": true
+ },
+ "node_modules/react-icons": {
+ "resolved": "../../node_modules/.pnpm/react-icons@5.7.0_react@19.2.8/node_modules/react-icons",
+ "link": true
+ },
+ "node_modules/react-markdown": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
+ "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "hast-util-to-jsx-runtime": "^2.0.0",
+ "html-url-attributes": "^3.0.0",
+ "mdast-util-to-hast": "^13.0.0",
+ "remark-parse": "^11.0.0",
+ "remark-rehype": "^11.0.0",
+ "unified": "^11.0.0",
+ "unist-util-visit": "^5.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18",
+ "react": ">=18"
+ }
+ },
+ "node_modules/regex": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/regex/-/regex-5.1.1.tgz",
+ "integrity": "sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw==",
+ "license": "MIT",
+ "dependencies": {
+ "regex-utilities": "^2.3.0"
+ }
+ },
+ "node_modules/regex-recursion": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-5.1.1.tgz",
+ "integrity": "sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w==",
+ "license": "MIT",
+ "dependencies": {
+ "regex": "^5.1.1",
+ "regex-utilities": "^2.3.0"
+ }
+ },
+ "node_modules/regex-utilities": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz",
+ "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==",
+ "license": "MIT"
+ },
+ "node_modules/remark-gfm": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
+ "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-gfm": "^3.0.0",
+ "micromark-extension-gfm": "^3.0.0",
+ "remark-parse": "^11.0.0",
+ "remark-stringify": "^11.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",
+ "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "micromark-util-types": "^2.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-rehype": {
+ "version": "11.1.2",
+ "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz",
+ "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "mdast-util-to-hast": "^13.0.0",
+ "unified": "^11.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-stringify": {
+ "version": "11.0.0",
+ "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz",
+ "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-to-markdown": "^2.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remeda": {
+ "version": "1.61.0",
+ "resolved": "https://registry.npmjs.org/remeda/-/remeda-1.61.0.tgz",
+ "integrity": "sha512-caKfSz9rDeSKBQQnlJnVW3mbVdFgxgGWQKq1XlFokqjf+hQD5gxutLGTTY2A/x24UxVyJe9gH5fAkFI63ULw4A==",
+ "license": "MIT"
+ },
+ "node_modules/shadow-plugin": {
+ "resolved": "../../node_modules/.pnpm/shadow-plugin@2.1.0/node_modules/shadow-plugin",
+ "link": true
+ },
+ "node_modules/shiki": {
+ "version": "1.29.2",
+ "resolved": "https://registry.npmjs.org/shiki/-/shiki-1.29.2.tgz",
+ "integrity": "sha512-njXuliz/cP+67jU2hukkxCNuH1yUi4QfdZZY+sMr5PPrIyXSu5iTb/qYC4BiWWB0vZ+7TbdvYUCeL23zpwCfbg==",
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/core": "1.29.2",
+ "@shikijs/engine-javascript": "1.29.2",
+ "@shikijs/engine-oniguruma": "1.29.2",
+ "@shikijs/langs": "1.29.2",
+ "@shikijs/themes": "1.29.2",
+ "@shikijs/types": "1.29.2",
+ "@shikijs/vscode-textmate": "^10.0.1",
+ "@types/hast": "^3.0.4"
+ }
+ },
+ "node_modules/space-separated-tokens": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz",
+ "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/stringify-entities": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
+ "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==",
+ "license": "MIT",
+ "dependencies": {
+ "character-entities-html4": "^2.0.0",
+ "character-entities-legacy": "^3.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/style-to-js": {
+ "version": "1.1.21",
+ "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
+ "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==",
+ "license": "MIT",
+ "dependencies": {
+ "style-to-object": "1.0.14"
+ }
+ },
+ "node_modules/style-to-object": {
+ "version": "1.0.14",
+ "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz",
+ "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==",
+ "license": "MIT",
+ "dependencies": {
+ "inline-style-parser": "0.2.7"
+ }
+ },
+ "node_modules/tailwind-merge": {
+ "resolved": "../../node_modules/.pnpm/tailwind-merge@3.6.0/node_modules/tailwind-merge",
+ "link": true
+ },
+ "node_modules/tailwindcss": {
+ "resolved": "../../node_modules/.pnpm/tailwindcss@4.3.3/node_modules/tailwindcss",
+ "link": true
+ },
+ "node_modules/trim-lines": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
+ "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/trough": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz",
+ "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/typescript": {
+ "resolved": "../../node_modules/.pnpm/typescript@7.0.2/node_modules/typescript",
+ "link": true
+ },
+ "node_modules/unified": {
+ "version": "11.0.5",
+ "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
+ "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "bail": "^2.0.0",
+ "devlop": "^1.0.0",
+ "extend": "^3.0.0",
+ "is-plain-obj": "^4.0.0",
+ "trough": "^2.0.0",
+ "vfile": "^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",
+ "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-position": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz",
+ "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.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",
+ "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-visit": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz",
+ "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0",
+ "unist-util-visit-parents": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-visit-parents": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz",
+ "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/vfile": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
+ "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "vfile-message": "^4.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",
+ "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-stringify-position": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/vite": {
+ "resolved": "../../node_modules/.pnpm/vite@8.2.2_@types+node@26.4_9aec1c9b231ec5c84774cec00150d533/node_modules/vite",
+ "link": true
+ },
+ "node_modules/zwitch": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
+ "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ }
+ }
+}
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index e2a95f2..0aceca9 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -2,7 +2,7 @@
"name": "@aso/desktop",
"private": true,
"version": "0.5.0",
- "description": "店铺运营 Agent 桌面端(Electron for pi agent)",
+ "description": "Inkreach Agent 桌面端(Electron for pi agent)",
"type": "module",
"main": "out/main/index.mjs",
"scripts": {
@@ -16,10 +16,20 @@
"@earendil-works/pi-ai": "0.85.0",
"@earendil-works/pi-coding-agent": "0.85.0",
"@earendil-works/pi-server": "0.85.0",
+ "@llm-ui/code": "^0.13.3",
+ "@llm-ui/markdown": "^0.13.3",
+ "@llm-ui/react": "^0.13.3",
+ "class-variance-authority": "0.7.1",
+ "clsx": "2.1.1",
"gsap": "3.15.0",
- "lucide-react": "1.41.0",
"react": "19.2.8",
- "react-dom": "19.2.8"
+ "react-dom": "19.2.8",
+ "react-icons": "5.7.0",
+ "react-markdown": "^10.1.0",
+ "remark-gfm": "^4.0.1",
+ "shadow-plugin": "2.1.0",
+ "shiki": "^1.29.2",
+ "tailwind-merge": "3.6.0"
},
"devDependencies": {
"@tailwindcss/vite": "4.3.3",
diff --git a/apps/desktop/preview/index.html b/apps/desktop/preview/index.html
new file mode 100644
index 0000000..38f5a77
--- /dev/null
+++ b/apps/desktop/preview/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ beautifului primitives preview
+
+
+
+
+
+
diff --git a/apps/desktop/preview/main.tsx b/apps/desktop/preview/main.tsx
new file mode 100644
index 0000000..e567358
--- /dev/null
+++ b/apps/desktop/preview/main.tsx
@@ -0,0 +1,210 @@
+// beautifului.dev 组件视觉验收页(静态构建,模拟聊天流内的真实用法)
+import { useRef, useState } from 'react'
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import './preview.css'
+import { Thinking } from '../src/renderer/src/components/Thinking'
+import { StreamingText } from '../src/renderer/src/components/StreamingText'
+import { ApprovalStack } from '../src/renderer/src/components/ApprovalCard'
+import { ToolChips } from '../src/renderer/src/components/ToolChips'
+import { SidebarNav } from '../src/renderer/src/components/SidebarNav'
+import { Search } from '../src/renderer/src/components/Search'
+import { CodeBlock } from '../src/renderer/src/components/CodeBlock'
+import { SelectionActions } from '../src/renderer/src/components/SelectionActions'
+import { Loading } from '../src/renderer/src/components/Loading'
+import { Markdown } from '../src/renderer/src/components/Markdown'
+import type { ApprovalRequest, SessionMeta } from '../src/shared/types'
+
+const thinkingRows = [
+ { primary: '用户想了解今天的品类热点,我先查一下最近的趋势数据。' },
+ { primary: '需要调用 query_trend 工具获取近 7 天的热点词,再按类目分组。' },
+ { primary: '结果里先给 TOP3 品类,附上建议的标题关键词。' }
+]
+
+const toolCalls = [
+ {
+ id: 'call-1',
+ name: 'query_trend',
+ tool: {
+ toolName: 'query_trend',
+ args: { category: 'home', days: 7 },
+ status: 'done' as const,
+ result: { content: [{ type: 'text', text: '{ "top": ["收纳", "香薰", "桌布"], "lift": [0.32, 0.21, 0.18] }' }] }
+ }
+ },
+ {
+ id: 'call-2',
+ name: 'draft_title',
+ tool: {
+ toolName: 'draft_title',
+ args: { keywords: ['收纳', '灵感'] },
+ status: 'error' as const,
+ result: { content: [{ type: 'text', text: 'Error: rate limited, retry after 30s' }] }
+ }
+ },
+ {
+ id: 'call-3',
+ name: 'update_price',
+ pendingApproval: { args: { sku: 'SKU-1024', from: 39.9, to: 35.9 } }
+ }
+]
+
+const approvalRequest: ApprovalRequest = {
+ id: 'approval-1',
+ toolName: 'update_price',
+ args: { sku: 'SKU-1024', from: 39.9, to: 35.9 }
+}
+
+const sessions: SessionMeta[] = [
+ { id: 's1', path: 'a.json', name: '供应商记录整理', firstMessage: '帮我整理供应商', created: '', modified: new Date().toISOString(), messageCount: 12 },
+ { id: 's2', path: 'b.json', name: '', firstMessage: '根据热点词起草一个商品标题方案', created: '', modified: new Date(Date.now() - 86400000).toISOString(), messageCount: 5 },
+ { id: 's3', path: 'c.json', name: '', firstMessage: '查询最近一次上架任务的执行结果', created: '', modified: new Date(Date.now() - 3 * 86400000).toISOString(), messageCount: 8 }
+]
+
+const CODE_SNIPPET = `export async function churnBatch() {
+ const flavor = await getFlavor("pistachio");
+ const base = await dairy.fetch({ flavor });
+ await freezer.store(base, { temp: "-16C" });
+ if (!base.approved) return null;
+ return base.gallons;
+}`
+
+const DIFF_SNIPPET = `@@ -1,4 +1,5 @@
+ export const hero = "mint-chip";
+-const windows = slots;
++const windows = slots.filter(
++ (s) => s.temp <= -12,
++);
+ schedule(windows);`
+
+const PARA =
+ '百香果风味在整个周末保持热销榜首。周六一早就要开始搅拌,让批次在下午高峰前有足够时间固化。同时需要预留两个晚间冷冻窗口,避免与常规生产冲突。'
+
+function PreviewCard({ title, children }: { title: string; children: React.ReactNode }) {
+ return (
+
+ )
+}
+
+function App() {
+ const [searchOpen, setSearchOpen] = useState(false)
+ const [applied, setApplied] = useState('')
+ const selectionBoxRef = useRef(null)
+
+ // 调试分区:URL hash 指定只渲染某一节(#thinking / #streaming / #tools /
+ // #approval / #code / #selection / #loading / #sidebar / #none),空 hash 全渲染
+ const only = window.location.hash.slice(1)
+ const show = (id: string): boolean => only === '' || only === id
+
+ return (
+
+
beautifului.dev · 8 组件直连验收(light)
+
+
+
+ {show('thinking') && (
+
+
+
+
+
+
+ )}
+
+ {show('streaming') && (
+
+ setApplied(q)}>
+
+
+
+ )}
+
+ {show('tools') && (
+
+
+
+
+
+ )}
+
+ {show('approval') && (
+
+ setApplied(approved ? '已允许' : '已拒绝')} />
+
+
+ )}
+
+ {show('code') && (
+
+
+
+
+
+
+ )}
+
+ {show('selection') && (
+
+
+
{PARA}
+
setApplied(`${instruction}:${selection.slice(0, 18)}…`)} />
+
+ {applied && onApply → {applied}
}
+
+ )}
+
+ {show('loading') && (
+
+
+
+ )}
+
+
+ {show('sidebar') && (
+
+
+
+ undefined}
+ onOpenWorkspace={() => undefined}
+ onNewSession={() => setApplied('新会话')}
+ onOpenSession={() => setApplied('打开会话')}
+ onRenameSession={() => setApplied('重命名')}
+ onDeleteSession={() => setApplied('删除')}
+ onRefreshSessions={() => setApplied('刷新')}
+ onOpenSearch={() => setSearchOpen(true)}
+ onOpenSettings={() => setApplied('设置')}
+ />
+
+
+
+ )}
+
+
+ {show('sidebar') && (
+
setSearchOpen(false)}
+ sessions={sessions}
+ onNewSession={() => setApplied('新会话')}
+ onRefreshSessions={() => setApplied('刷新')}
+ onOpenSettings={() => setApplied('设置')}
+ onOpenSession={() => setApplied('打开会话')}
+ />
+ )}
+
+ )
+}
+
+createRoot(document.getElementById('root')!).render(
+
+
+
+)
diff --git a/apps/desktop/preview/preview.css b/apps/desktop/preview/preview.css
new file mode 100644
index 0000000..815d52b
--- /dev/null
+++ b/apps/desktop/preview/preview.css
@@ -0,0 +1,6 @@
+/* 预览页专用入口:复用应用全套样式(tailwind + beautifului foundation + 字体),
+ * 并显式声明组件源码目录参与工具类扫描(vite root 在 preview/ 下,
+ * 自动检测覆盖不到 ../src)。 */
+@import "../src/renderer/src/styles/globals.css";
+
+@source "../src/renderer/src";
diff --git a/apps/desktop/scripts/capture-app.cjs b/apps/desktop/scripts/capture-app.cjs
new file mode 100644
index 0000000..9c345ed
--- /dev/null
+++ b/apps/desktop/scripts/capture-app.cjs
@@ -0,0 +1,263 @@
+// 真实 App 渲染层截图(离屏,注入假 IPC 数据):
+// npx electron scripts/capture-app.cjs
+// 产出 preview-shots/app-*.png:展开态 / 收起态 / markdown 渲染
+const { app, BrowserWindow, ipcMain } = require('electron')
+const { mkdirSync, writeFileSync, existsSync } = require('node:fs')
+const { join } = require('node:path')
+
+app.commandLine.appendSwitch('disable-gpu')
+
+const ROOT = join(__dirname, '..')
+const OUT = join(ROOT, 'preview-shots')
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
+
+const MARKDOWN_SAMPLE = [
+ '## 1. "这是演示数据,仅用于验证链路"',
+ '',
+ '我配置的 `hotword_lookup` 工具目前连的是**演示实现(demo)**,[项目主页](https://example.com)。',
+ '',
+ '| 说明 | 结论 |',
+ '| :--- | ---: |',
+ '| **工具可用** | 工具这个"壳"是好的 —— 链路没有问题 |',
+ '| **数据源是空的** | 它背后的"仓库"是空的,查询返回"无匹配项" |',
+ '',
+ '- 列表项一,包含 `inline code`',
+ '- 列表项二,**加粗** 与 ~~删除线~~',
+ '',
+ '```python',
+ "print('Hello llm-ui!')",
+ 'for i in range(3):',
+ ' print(i)',
+ '```',
+ '',
+ '```rust',
+ 'fn main() {',
+ ' println!("Hello, World!");',
+ '}',
+ '```'
+].join('\n')
+
+const FAKE_STATE = {
+ sessionId: 's1',
+ sessionName: 'hi',
+ streaming: false,
+ activeTools: [],
+ messageCount: 2,
+ workspace: 'C:\\fake\\workspace'
+}
+
+const FAKE_SESSIONS = [
+ { id: 's1', path: 'C:\\fake\\s1.jsonl', name: 'hi', firstMessage: '帮我写一个hello world', created: '2026-01-01', modified: '2026-01-02', messageCount: 2 },
+ { id: 's2', path: 'C:\\fake\\s2.jsonl', name: 'hello world', firstMessage: '帮我写一个hello world', created: '2026-01-01', modified: '2026-01-02', messageCount: 1 }
+]
+
+function registerFakeIpc() {
+ ipcMain.handle('agent:state', () => FAKE_STATE)
+ ipcMain.handle('sessions:list', () => FAKE_SESSIONS)
+ ipcMain.handle('workspaces:list', () => [{ name: 'Default', path: 'C:\\fake\\workspace' }])
+ ipcMain.handle('models:catalog', () => ({ providers: [] }))
+ ipcMain.handle('settings:get', () => ({ systemPrompt: '' }))
+ ipcMain.handle('app:versions', () => ({ app: 'dev', electron: 'x', node: 'x', chrome: 'x', user: 'x' }))
+ ipcMain.handle('agent:send', () => undefined)
+ ipcMain.handle('chat:resources', () => ({
+ commands: [{ name: 'daily-report', description: '生成每日经营日报', kind: 'prompt' }],
+ skills: [{ name: 'hotword-analysis', description: '分析近 7 天类目热点词' }]
+ }))
+ ipcMain.handle('files:list', () => [
+ { path: 'data/products.csv', name: 'products.csv', isDir: false },
+ { path: 'docs/sop.md', name: 'sop.md', isDir: false },
+ { path: 'hello_world.py', name: 'hello_world.py', isDir: false },
+ { path: 'assets', name: 'assets', isDir: true }
+ ])
+}
+
+async function capture(win, name) {
+ // 离屏窗口 capturePage 常给旧帧:连拍多张,取最后一张(大概率已提交新帧)
+ let image = null
+ for (let k = 0; k < 4; k += 1) {
+ win.webContents.invalidate()
+ await sleep(320)
+ image = await win.webContents.capturePage()
+ }
+ writeFileSync(join(OUT, name), image.toPNG())
+ console.log('saved', name, `${image.getSize().width}x${image.getSize().height}`)
+}
+
+async function run() {
+ mkdirSync(OUT, { recursive: true })
+ registerFakeIpc()
+
+ const preload = join(ROOT, 'out', 'preload', 'index.cjs')
+ const win = new BrowserWindow({
+ width: 1280,
+ height: 900,
+ show: false,
+ webPreferences: {
+ paintWhenInitiallyHidden: true,
+ backgroundThrottling: false,
+ preload: existsSync(preload) ? preload : undefined,
+ contextIsolation: true
+ }
+ })
+ await win.loadFile(join(ROOT, 'out', 'renderer', 'index.html'))
+ // 清掉历史 localStorage(收起态持久化),再重载让侧栏以展开态启动
+ await win.webContents.executeJavaScript('try { localStorage.clear() } catch {}')
+ await win.reload()
+ await sleep(1200)
+
+ // 注入一条含 markdown 的会话(user + assistant)
+ win.webContents.send('agent:event', {
+ type: 'session_switched',
+ state: FAKE_STATE,
+ messages: [
+ { role: 'user', content: '解释一下那两句话的意思' },
+ { role: 'assistant', content: [{ type: 'text', text: MARKDOWN_SAMPLE }] }
+ ],
+ activeTools: {}
+ })
+ await sleep(1200)
+ console.log(
+ 'hl-diag:',
+ await win.webContents.executeJavaScript(
+ `(() => { const codes = [...document.querySelectorAll('code')]; return JSON.stringify({ ready: window.__shikiReady, codes: codes.map((c) => c.querySelectorAll('span[style]').length) }) })()`
+ )
+ )
+ await capture(win, 'app-markdown.png')
+
+ // 注入排队消息场景(streaming + 队列两条)
+ win.webContents.send('agent:event', { type: 'agent_start' })
+ win.webContents.send('agent:event', {
+ type: 'queue_update',
+ steering: ['设置里面做成这样,分成基础设置、Agent 能力'],
+ followUp: ['随后帮我补上skills、命令和附件的支持,chatbox里面/调起命令或skill,@则是引用文件,加号是附件']
+ })
+ await sleep(900)
+ win.webContents.invalidate()
+ await sleep(300)
+ FAKE_STATE.permissionMode = 'confirm'
+ await capture(win, 'app-queue.png')
+
+ // 权限模式菜单
+ await win.webContents.executeJavaScript(`window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))`)
+ win.webContents.send('agent:event', { type: 'agent_end' })
+ win.webContents.send('agent:event', { type: 'queue_update', steering: [], followUp: [] })
+ await sleep(500)
+ await win.webContents.executeJavaScript(
+ `[...document.querySelectorAll('button')].find((b) => b.title.startsWith('权限模式'))?.click()`
+ )
+ await sleep(500)
+ win.webContents.invalidate()
+ await sleep(300)
+ console.log(
+ 'perm-diag:',
+ await win.webContents.executeJavaScript(
+ `(() => { const panels = [...document.querySelectorAll('div.fixed.z-40')]; const m = panels.find((d) => d.textContent.includes('减少确认次数')); if (!m) return 'closed'; const r = m.getBoundingClientRect(); const cs = getComputedStyle(m); return JSON.stringify({ x: r.x, y: r.y, w: r.width, h: r.height, opacity: cs.opacity, display: cs.display, zIndex: cs.zIndex, buttons: m.querySelectorAll('button').length }) })()`
+ )
+ )
+ await capture(win, 'app-permissions.png')
+ // 结束流式,恢复常规状态
+ win.webContents.send('agent:event', { type: 'agent_end' })
+ win.webContents.send('agent:event', { type: 'queue_update', steering: [], followUp: [] })
+ await sleep(400)
+
+ // "/" 命令/技能面板
+ const typeInPrompt = (text) =>
+ win.webContents.executeJavaScript(
+ `(() => { const ta = document.querySelector('textarea[aria-label="Prompt"]'); if (!ta) return 'no-ta'; const set = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; set.call(ta, ${JSON.stringify(text)}); ta.dispatchEvent(new Event('input', { bubbles: true })); return 'ok' })()`
+ )
+ await typeInPrompt('/')
+ await sleep(600)
+ win.webContents.invalidate()
+ await sleep(300)
+ await capture(win, 'app-slash-menu.png')
+ // "@ " 文件引用
+ await typeInPrompt('帮我看看 @')
+ await sleep(600)
+ win.webContents.invalidate()
+ await sleep(300)
+ await capture(win, 'app-at-menu.png')
+ await typeInPrompt('')
+
+ // 注入审批卡
+ win.webContents.send('agent:event', {
+ type: 'approval_request',
+ request: {
+ id: 'approval-1',
+ toolName: 'write',
+ args: { path: 'hello_world.py', content: 'def main():\n print("Hello world")\n\nmain()' }
+ }
+ })
+ await sleep(800)
+ win.webContents.invalidate()
+ await sleep(400)
+ await capture(win, 'app-approval.png')
+
+ // 行内操作浮层:悬停操作按钮点击后弹出(重命名 / 删除)
+ await win.webContents.executeJavaScript(
+ `[...document.querySelectorAll('button')].find((b) => b.title === '重命名会话')?.click()`
+ )
+ await sleep(500)
+ win.webContents.invalidate()
+ await sleep(300)
+ await capture(win, 'app-row-rename.png')
+ await win.webContents.executeJavaScript(
+ `[...document.querySelectorAll('button')].find((b) => b.title === '删除会话')?.click()`
+ )
+ await sleep(500)
+ win.webContents.invalidate()
+ await sleep(300)
+ await capture(win, 'app-row-delete.png')
+ await win.webContents.executeJavaScript(`document.body.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))`)
+
+ // 设置页:Ctrl+, 打开,截「模型设置」与「常规」
+ await win.webContents.executeJavaScript(
+ `window.dispatchEvent(new KeyboardEvent('keydown', { key: ',', ctrlKey: true, bubbles: true }))`
+ )
+ await sleep(600)
+ win.webContents.invalidate()
+ await sleep(300)
+ await capture(win, 'app-settings-model.png')
+ await win.webContents.executeJavaScript(
+ `[...document.querySelectorAll('button')].find((b) => b.textContent.trim() === '常规')?.click()`
+ )
+ await sleep(400)
+ win.webContents.invalidate()
+ await sleep(300)
+ await capture(win, 'app-settings-general.png')
+ await win.webContents.executeJavaScript(`window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))`)
+
+ console.log(
+ 'diag:',
+ await win.webContents.executeJavaScript(
+ `(() => { const btn = document.querySelector('button[aria-label="拒绝并关闭"]'); if (!btn) return 'missing'; const card = btn.closest('div[class*="rounded-card"]') ?? btn.parentElement; const r = card.getBoundingClientRect(); return JSON.stringify({ x: r.x, y: r.y, w: r.width, h: r.height, winH: innerHeight, opacity: getComputedStyle(card).opacity }) })()`
+ )
+ )
+
+ // 侧栏收起回归检查
+ const collapsed = await win.webContents.executeJavaScript(
+ `(() => { const b = document.querySelector('[aria-label="折叠侧栏"]'); if (!b) return 'no-button'; b.click(); return 'clicked' })()`
+ )
+ console.log('collapse:', collapsed)
+ await sleep(800)
+ const geo = await win.webContents.executeJavaScript(
+ `(() => {
+ const aside = document.querySelector('aside[aria-label="工作区导航"]')
+ const row = document.querySelector('.sidebar-row')
+ const icon = row ? row.querySelector('span') : null
+ if (!aside || !row || !icon) return 'missing'
+ const a = aside.getBoundingClientRect()
+ const i = icon.getBoundingClientRect()
+ return JSON.stringify({ rail: Math.round(a.width), iconCenter: Math.round(i.left - a.left + i.width / 2) })
+ })()`
+ )
+ console.log('collapsed-geo:', geo)
+ await capture(win, 'app-collapsed.png')
+
+ app.exit(0)
+}
+
+app.whenReady().then(run).catch((err) => {
+ console.error(err)
+ app.exit(1)
+})
diff --git a/apps/desktop/scripts/capture-preview.cjs b/apps/desktop/scripts/capture-preview.cjs
new file mode 100644
index 0000000..2353970
--- /dev/null
+++ b/apps/desktop/scripts/capture-preview.cjs
@@ -0,0 +1,95 @@
+// 预览页视觉验收截图(Electron 离屏捕获):
+// npx electron scripts/capture-preview.cjs
+// 产出 preview-shots/*.png:整页 / 侧栏折叠 / 搜索面板 / 选区工具条
+const { app, BrowserWindow } = require('electron')
+const { mkdirSync, writeFileSync } = require('node:fs')
+const { join } = require('node:path')
+
+// 离屏捕获在 GPU 合成异常时会得到空白帧,改用软件合成
+app.commandLine.appendSwitch('disable-gpu')
+
+const URL = 'http://localhost:4173/'
+const OUT = join(__dirname, '..', 'preview-shots')
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
+
+async function capture(win, name) {
+ try {
+ const image = await win.webContents.capturePage()
+ writeFileSync(join(OUT, name), image.toPNG())
+ console.log('saved', name, `${image.getSize().width}x${image.getSize().height}`)
+ } catch (e) {
+ console.log('capture failed', name, e.message)
+ }
+}
+
+async function js(win, code) {
+ try {
+ return await win.webContents.executeJavaScript(code)
+ } catch (e) {
+ console.log('js failed:', e.message)
+ return undefined
+ }
+}
+
+async function run() {
+ mkdirSync(OUT, { recursive: true })
+ const win = new BrowserWindow({
+ width: 1280,
+ height: 960,
+ show: false,
+ webPreferences: { paintWhenInitiallyHidden: true }
+ })
+ await win.loadURL(URL)
+ await sleep(1500)
+
+ // 1) 整页(上半)
+ await capture(win, '01-full.png')
+
+ // 2) 折叠侧栏(等宽度过渡)
+ await js(win, `document.querySelector('[aria-label="折叠侧栏"]')?.click()`)
+ await sleep(700)
+ await capture(win, '02-sidebar-collapsed.png')
+ await js(win, `document.querySelector('[aria-label="展开侧栏"]')?.click()`)
+ await sleep(700)
+
+ // 3) 搜索面板(侧栏「搜索」行)+ 输入过滤
+ await js(win, `[...document.querySelectorAll('button')].find((b) => b.textContent.trim() === '搜索')?.click()`)
+ await sleep(600)
+ await js(win, `(() => { const i = document.querySelector('input[aria-label="搜索会话或命令"]'); if (!i) return 'no-input'; const set = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; set.call(i, '供应'); i.dispatchEvent(new Event('input', { bubbles: true })); return 'ok' })()`)
+ await sleep(400)
+ await capture(win, '03-search-filtered.png')
+ await js(win, `document.querySelector('input[aria-label="搜索会话或命令"]')?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))`)
+ await sleep(400)
+
+ // 4) 划选文本 → Selection Actions 工具条(展开「更多」)
+ await js(win, `(() => {
+ const p = [...document.querySelectorAll('p')].find((el) => el.textContent.includes('百香果'));
+ if (!p) return 'no-p';
+ const textNode = p.firstChild;
+ const range = document.createRange();
+ range.setStart(textNode, 0);
+ range.setEnd(textNode, 30);
+ const sel = window.getSelection();
+ sel.removeAllRanges();
+ sel.addRange(range);
+ document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
+ return 'ok';
+ })()`)
+ await sleep(600)
+ await js(win, `document.querySelector('[aria-label="更多操作"]')?.click()`)
+ await sleep(500)
+ await capture(win, '04-selection-actions.png')
+
+ // 5) 审批卡交互:选择「允许执行」(radio 自动前进 480ms 后提交)
+ await js(win, `[...document.querySelectorAll('button')].find((b) => b.textContent.trim() === '允许执行')?.click()`)
+ await sleep(900)
+ await capture(win, '05-approval-approved.png')
+
+ app.exit(0)
+}
+
+app.whenReady().then(run).catch((err) => {
+ console.error(err)
+ app.exit(1)
+})
diff --git a/apps/desktop/scripts/debug-collapse.cjs b/apps/desktop/scripts/debug-collapse.cjs
new file mode 100644
index 0000000..bfdb5b8
--- /dev/null
+++ b/apps/desktop/scripts/debug-collapse.cjs
@@ -0,0 +1,21 @@
+// 验证侧栏折叠交互
+const { app, BrowserWindow } = require('electron')
+app.commandLine.appendSwitch('disable-gpu')
+app.commandLine.appendSwitch('disable-software-rasterizer')
+
+app.whenReady().then(async () => {
+ const win = new BrowserWindow({ width: 1280, height: 900, show: false, webPreferences: { offscreen: true } })
+ await win.loadURL('http://127.0.0.1:4173/')
+ await new Promise((r) => setTimeout(r, 1200))
+ const found = await win.webContents.executeJavaScript(`document.querySelector('[aria-label="折叠侧栏"]') ? 'found' : 'NOT-FOUND'`)
+ console.log('collapse btn:', found)
+ await win.webContents.executeJavaScript(`document.querySelector('[aria-label="折叠侧栏"]').click()`)
+ await new Promise((r) => setTimeout(r, 600))
+ console.log(
+ 'after click: collapsed=',
+ await win.webContents.executeJavaScript(`document.querySelector('aside')?.getAttribute('data-sidebar-collapsed')`),
+ 'width=',
+ await win.webContents.executeJavaScript(`document.querySelector('aside')?.style.width`)
+ )
+ app.exit(0)
+})
diff --git a/apps/desktop/scripts/debug-preview.cjs b/apps/desktop/scripts/debug-preview.cjs
new file mode 100644
index 0000000..6d8d806
--- /dev/null
+++ b/apps/desktop/scripts/debug-preview.cjs
@@ -0,0 +1,41 @@
+// 预览页控制台诊断:node 调试某一分区
+// npx electron scripts/debug-preview.cjs "#thinking"
+const { app, BrowserWindow } = require('electron')
+
+const hash = process.argv[2] ?? ''
+const URL = `http://127.0.0.1:4173/${hash}`
+
+// 兜底退出(渲染进程卡死时 executeJavaScript 永不返回)
+const guard = setTimeout(() => {
+ console.log('[guard] hard exit (page busy or crashed)')
+ app.exit(2)
+}, 12000)
+
+app.whenReady().then(async () => {
+ const win = new BrowserWindow({
+ width: 1280,
+ height: 900,
+ show: false,
+ webPreferences: { offscreen: true }
+ })
+ win.webContents.on('console-message', (_e, level, message, line, source) => {
+ console.log(`[console:${level}]`, message, `(${source}:${line})`)
+ })
+ win.webContents.on('did-fail-load', (_e, code, desc, url) => {
+ console.log(`[did-fail-load] ${code} ${desc} ${url}`)
+ })
+ win.webContents.on('render-process-gone', (_e, details) => {
+ console.log('[crashed]', details.reason)
+ })
+ await win.loadURL(URL)
+ setTimeout(async () => {
+ try {
+ const text = await win.webContents.executeJavaScript('document.body.innerText.slice(0, 120)')
+ console.log('[body]', JSON.stringify(text))
+ } catch (e) {
+ console.log('[executeJavaScript failed]', e.message)
+ }
+ clearTimeout(guard)
+ app.exit(0)
+ }, 2500)
+})
diff --git a/apps/desktop/scripts/inspect-bundle.cjs b/apps/desktop/scripts/inspect-bundle.cjs
new file mode 100644
index 0000000..f281903
--- /dev/null
+++ b/apps/desktop/scripts/inspect-bundle.cjs
@@ -0,0 +1,17 @@
+// 对比 out/renderer 旧 bundle 与当前源码的 reducer 逻辑
+const fs = require('fs')
+const t = fs.readFileSync('apps/desktop/out/renderer/assets/index-DJXyfP9T.js', 'utf8')
+
+console.log('has message_end:', t.includes('message_end'))
+console.log('has optimistic-user:', t.includes('optimistic-user'))
+console.log('has session_switched:', t.includes('session_switched'))
+console.log('has agent_settled:', t.includes('agent_settled'))
+console.log('has approval_request:', t.includes('approval_request'))
+
+// reducer 里 message_end 附近的代码
+let i = t.indexOf('"message_end"')
+if (i < 0) i = t.indexOf('message_end')
+if (i >= 0) {
+ console.log('\n=== around first message_end ===')
+ console.log(t.slice(i - 500, i + 900))
+}
diff --git a/apps/desktop/scripts/make-icons.cjs b/apps/desktop/scripts/make-icons.cjs
new file mode 100644
index 0000000..2122495
--- /dev/null
+++ b/apps/desktop/scripts/make-icons.cjs
@@ -0,0 +1,87 @@
+// 生成应用图标(build/icon.ico + build/icon.png):
+// npx electron scripts/make-icons.cjs
+// 用 Electron 离屏渲染 build/icon.svg(居中于方形画布、留 9% 边距),
+// 输出多尺寸 PNG 并打包为 PNG 内嵌式 ICO(Vista+ 支持)。
+const { app, BrowserWindow } = require('electron')
+const { readFileSync, writeFileSync } = require('node:fs')
+const { join } = require('node:path')
+
+app.commandLine.appendSwitch('disable-gpu')
+
+const SIZES = [16, 24, 32, 48, 64, 128, 256]
+const ROOT = join(__dirname, '..')
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
+
+/** PNG 内嵌式 ICO 打包 */
+function buildIco(pngs) {
+ const count = pngs.length
+ const header = Buffer.alloc(6)
+ header.writeUInt16LE(0, 0) // reserved
+ header.writeUInt16LE(1, 2) // type: icon
+ header.writeUInt16LE(count, 4)
+ const entries = Buffer.alloc(16 * count)
+ const blobs = []
+ let offset = 6 + 16 * count
+ pngs.forEach((png, i) => {
+ const size = png.size
+ entries.writeUInt8(size >= 256 ? 0 : size, i * 16 + 0) // width
+ entries.writeUInt8(size >= 256 ? 0 : size, i * 16 + 1) // height
+ entries.writeUInt8(0, i * 16 + 2) // palette
+ entries.writeUInt8(0, i * 16 + 3) // reserved
+ entries.writeUInt16LE(1, i * 16 + 4) // planes
+ entries.writeUInt16LE(32, i * 16 + 6) // bpp
+ entries.writeUInt32LE(png.data.length, i * 16 + 8)
+ entries.writeUInt32LE(offset, i * 16 + 12)
+ offset += png.data.length
+ blobs.push(png.data)
+ })
+ return Buffer.concat([header, entries, ...blobs])
+}
+
+async function renderSize(win, svgDataUrl, size) {
+ await win.loadURL(
+ 'data:text/html,' +
+ encodeURIComponent(
+ `` +
+ `` +
+ `

` +
+ `
`
+ )
+ )
+ await sleep(350)
+ const image = await win.webContents.capturePage({ x: 0, y: 0, width: size, height: size })
+ return image.toPNG()
+}
+
+async function run() {
+ const svg = readFileSync(join(ROOT, 'build', 'icon.svg'), 'utf8')
+ const svgDataUrl = 'data:image/svg+xml;base64,' + Buffer.from(svg, 'utf8').toString('base64')
+
+ const win = new BrowserWindow({
+ width: 256,
+ height: 256,
+ show: false,
+ frame: false,
+ transparent: true,
+ webPreferences: { paintWhenInitiallyHidden: true, offscreen: true }
+ })
+
+ const pngs = []
+ for (const size of SIZES) {
+ await win.setSize ? win.setSize(size, size) : null
+ const png = await renderSize(win, svgDataUrl, size)
+ pngs.push({ size, data: png })
+ console.log('rendered', size, `${png.length}B`)
+ }
+
+ writeFileSync(join(ROOT, 'build', 'icon.png'), pngs.find((p) => p.size === 256).data)
+ writeFileSync(join(ROOT, 'build', 'icon.ico'), buildIco(pngs))
+ console.log('written build/icon.ico & build/icon.png')
+ app.exit(0)
+}
+
+app.whenReady().then(run).catch((err) => {
+ console.error(err)
+ app.exit(1)
+})
diff --git a/apps/desktop/scripts/read-dts.cjs b/apps/desktop/scripts/read-dts.cjs
new file mode 100644
index 0000000..535f5ab
--- /dev/null
+++ b/apps/desktop/scripts/read-dts.cjs
@@ -0,0 +1,15 @@
+// 打印 pi SDK 关键类型定义(skills / slash-commands / prompt 参数)
+const fs = require('node:fs')
+const files = [
+ 'dist/core/skills.d.ts',
+ 'dist/core/slash-commands.d.ts',
+ 'dist/modes/interactive/components/skill-invocation-message.d.ts'
+]
+for (const f of files) {
+ console.log('=====', f)
+ try {
+ console.log(fs.readFileSync(f, 'utf8'))
+ } catch (e) {
+ console.log('READ FAIL', e.message)
+ }
+}
diff --git a/apps/desktop/scripts/read-dts2.cjs b/apps/desktop/scripts/read-dts2.cjs
new file mode 100644
index 0000000..af57092
--- /dev/null
+++ b/apps/desktop/scripts/read-dts2.cjs
@@ -0,0 +1,22 @@
+// 打印 ImageContent / PromptTemplate / AgentSessionServices 类型
+const fs = require('node:fs')
+const root = 'C:/Users/21976/Desktop/Develop/agent-store-operations/apps/desktop/node_modules'
+const files = [
+ [root + '/@earendil-works/pi-ai/dist/compat.d.ts', /export (interface|type) (ImageContent|TextContent)[\s\S]*?\n\}/],
+ [root + '/@earendil-works/pi-coding-agent/dist/core/prompt-templates.d.ts', null],
+ [root + '/@earendil-works/pi-coding-agent/dist/core/sdk.d.ts', /export interface AgentSessionServices[\s\S]*?\n\}/]
+]
+for (const [f, re] of files) {
+ console.log('=====', f.split('/').slice(-2).join('/'))
+ try {
+ const s = fs.readFileSync(f, 'utf8')
+ if (re) {
+ const m = s.match(re)
+ console.log(m ? m[0] : '(no match)')
+ } else {
+ console.log(s.slice(0, 2500))
+ }
+ } catch (e) {
+ console.log('READ FAIL', e.message)
+ }
+}
diff --git a/apps/desktop/scripts/repro-empty-workspace.mjs b/apps/desktop/scripts/repro-empty-workspace.mjs
new file mode 100644
index 0000000..6aabd83
--- /dev/null
+++ b/apps/desktop/scripts/repro-empty-workspace.mjs
@@ -0,0 +1,21 @@
+// 验证 pi SDK 对空目录(无会话的工作区)的行为:
+// node scripts/repro-empty-workspace.mjs "C:\path\to\empty\dir"
+import { SessionManager, getAgentDir } from '@earendil-works/pi-coding-agent'
+import { mkdirSync, mkdtempSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+
+const target = process.argv[2] ?? mkdtempSync(join(tmpdir(), 'ws-empty-'))
+mkdirSync(target, { recursive: true })
+console.log('target cwd:', target)
+
+try {
+ const sm = SessionManager.continueRecent(target)
+ console.log('continueRecent OK, sessionFile:', sm.sessionFile ?? '(none)')
+ const list = await SessionManager.list(target)
+ console.log('list count:', list.length)
+} catch (err) {
+ console.error('continueRecent FAILED:', err instanceof Error ? err.message : err)
+ process.exit(1)
+}
+console.log('agentDir:', getAgentDir())
diff --git a/apps/desktop/scripts/repro-event-flow.mjs b/apps/desktop/scripts/repro-event-flow.mjs
new file mode 100644
index 0000000..fc3a8b3
--- /dev/null
+++ b/apps/desktop/scripts/repro-event-flow.mjs
@@ -0,0 +1,103 @@
+// 复现渲染层"输出一闪而过":用与 agent-service.ts 相同的 SDK 调用链,
+// 订阅全部事件并打印事件序列与消息对象结构。
+import os from 'node:os'
+import {
+ createAgentSessionRuntime,
+ createAgentSessionServices,
+ createAgentSessionFromServices,
+ getAgentDir,
+ SessionManager
+} from '@earendil-works/pi-coding-agent'
+
+const cwd = os.homedir()
+const agentDir = getAgentDir()
+
+const services = await createAgentSessionServices({ cwd, agentDir })
+const factory = async (opts) => {
+ const result = await createAgentSessionFromServices({
+ services,
+ sessionManager: opts.sessionManager,
+ sessionStartEvent: opts.sessionStartEvent
+ })
+ return { ...result, services, diagnostics: services.diagnostics }
+}
+
+const sessionManager = SessionManager.continueRecent(cwd)
+const runtime = await createAgentSessionRuntime(factory, { cwd, agentDir, sessionManager })
+const session = runtime.session
+
+let rebindCount = 0
+runtime.setRebindSession(async (s) => {
+ rebindCount++
+ console.log(`\n>>> REBIND fired (#${rebindCount}) — 渲染层会收到 session_switched,messages.length=${s.messages.length}`)
+})
+
+session.subscribe((event) => {
+ switch (event.type) {
+ case 'message_start':
+ console.log('EVT message_start role=', JSON.stringify(event.message?.role))
+ break
+ case 'message_update': {
+ const inner = event.assistantMessageEvent
+ if (inner) {
+ if (inner.type.endsWith('_delta')) {
+ process.stdout.write(`.`)
+ } else {
+ console.log(`EVT message_update ${inner.type}`)
+ }
+ }
+ break
+ }
+ case 'message_end': {
+ const m = event.message
+ console.log('\nEVT message_end role=', m?.role)
+ console.log(' message keys:', Object.keys(m ?? {}))
+ try {
+ JSON.stringify(m)
+ console.log(' JSON.stringify: OK, length=', JSON.stringify(m).length)
+ } catch (err) {
+ console.log(' JSON.stringify FAILED ->', err.message, '(toCloneable 会变成 null → 渲染层消息变空!)')
+ }
+ if (m?.role === 'assistant') {
+ console.log(' stopReason=', m.stopReason, ' errorMessage=', m.errorMessage)
+ console.log(' content types=', (m.content ?? []).map((b) => b.type).join(','))
+ for (const b of m.content ?? []) {
+ if (b.type === 'text') console.log(' text block:', JSON.stringify(b.text).slice(0, 200))
+ }
+ }
+ break
+ }
+ case 'agent_end':
+ console.log(`\nEVT agent_end willRetry=${event.willRetry} messages=${event.messages?.length}`)
+ break
+ default:
+ console.log('EVT', event.type)
+ }
+})
+
+console.log('--- session:', session.sessionId, 'messages:', session.messages.length)
+console.log('--- model:', session.model?.provider + '/' + session.model?.id)
+console.log('--- sending "hi" ...')
+
+try {
+ await session.prompt('hi')
+ console.log('\n--- prompt resolved')
+} catch (err) {
+ console.log('\n--- prompt THREW:', err?.message)
+}
+
+// 等待 agent_settled 之后的异步收尾
+await new Promise((r) => setTimeout(r, 1500))
+
+console.log('\n=== final session.messages ===')
+for (const [i, m] of session.messages.entries()) {
+ const blocks = Array.isArray(m.content)
+ ? m.content.map((b) => b.type).join(',')
+ : typeof m.content === 'string'
+ ? 'str:' + m.content.slice(0, 30)
+ : '??'
+ console.log(`#${i} ${m.role} [${blocks}]`)
+}
+
+console.log('\nrebindCount=', rebindCount)
+process.exit(0)
diff --git a/apps/desktop/scripts/repro-markdown.mjs b/apps/desktop/scripts/repro-markdown.mjs
new file mode 100644
index 0000000..d37c46a
--- /dev/null
+++ b/apps/desktop/scripts/repro-markdown.mjs
@@ -0,0 +1,41 @@
+// 验证 Markdown.tsx 的 parseBlocks 是否产出空内容
+const INLINE = /^(\s*)([-*+]\s+|\d+[.)]\s+|>\s?|(#{1,3}\s+))?(.*)$/
+
+function parseBlocks(text) {
+ const blocks = []
+ const lines = text.split('\n')
+ let current = null
+ const flush = () => {
+ if (current) blocks.push(current)
+ current = null
+ }
+ for (const line of lines) {
+ const m = line.match(INLINE)
+ if (!m) continue
+ const [, indent, marker, content] = m
+ const isListItem = Boolean(marker && /^([-*+]\s+|\d+[.)]\s+)/.test(marker))
+ const kind = marker
+ ? marker.startsWith('#')
+ ? 'h1'
+ : isListItem
+ ? /^\d/.test(marker) ? 'ol' : 'ul'
+ : marker.startsWith('>') ? 'quote' : null
+ : 'p'
+ if (kind === 'p') {
+ if (!current || current.kind !== 'p') {
+ flush()
+ current = { kind: 'p', lines: [] }
+ }
+ current.lines.push(content)
+ continue
+ }
+ flush()
+ }
+ return blocks
+}
+
+for (const t of ['pong', 'Hello world', '# 标题', '- 列表项', '> 引用', '1. 有序']) {
+ const blocks = parseBlocks(t)
+ const rendered = blocks.map((b) => b.lines.join('\n'))
+ console.log(JSON.stringify(t), '->', JSON.stringify(rendered))
+}
diff --git a/apps/desktop/scripts/repro-render-logic.mjs b/apps/desktop/scripts/repro-render-logic.mjs
new file mode 100644
index 0000000..63834d1
--- /dev/null
+++ b/apps/desktop/scripts/repro-render-logic.mjs
@@ -0,0 +1,81 @@
+// 验证渲染层对 session_switched 历史消息的渲染逻辑:
+// 用与主进程一致的 session.messages 源,跑 MessageItem 的 readBlocks + AssistantBody 判断。
+import os from 'node:os'
+import { createAgentSessionServices, getAgentDir } from '@earendil-works/pi-coding-agent'
+
+const cwd = os.homedir()
+const services = await createAgentSessionServices({ cwd, agentDir: getAgentDir() })
+
+// SessionManager.list + 加载最近会话的等价做法:直接用 services 里加载当前会话
+const session = services.sessionManager
+ ? null
+ : null
+
+// 走 SessionManager 拿最近会话并加载
+const { SessionManager } = await import('@earendil-works/pi-coding-agent')
+const sm = SessionManager.continueRecent(cwd)
+const smAny = sm
+// SessionManager.continueRecent 返回的 manager 需要挂到 session 上才有 messages;
+// 更直接:读它指向的会话文件并解析 message 条目
+const entries = []
+if (typeof smAny.loadEntries === 'function') {
+ entries.push(...(await smAny.loadEntries()))
+}
+console.log('loadEntries available:', typeof smAny.loadEntries === 'function', 'entries:', entries.length)
+
+// 退路:直接解析磁盘上的最近 jsonl(与主进程 toCloneable 后的消息同构)
+import { readdirSync, readFileSync, statSync } from 'node:fs'
+import { join } from 'node:path'
+const dir = join(cwd, '.pi', 'agent', 'sessions', '--C--Users-21976--')
+const files = readdirSync(dir).filter((f) => f.endsWith('.jsonl'))
+files.sort((a, b) => statSync(join(dir, b)).mtimeMs - statSync(join(dir, a)).mtimeMs)
+const latest = files[0]
+console.log('latest session file:', latest)
+
+const lines = readFileSync(join(dir, latest), 'utf8').trim().split('\n')
+const messages = []
+for (const line of lines) {
+ let entry
+ try {
+ entry = JSON.parse(line)
+ } catch {
+ continue
+ }
+ if (entry.type === 'message' && entry.message) messages.push(entry.message)
+}
+
+// ==== 渲染层逻辑复刻(与 MessageItem.tsx 一致)====
+function readBlocks(raw) {
+ if (!raw || typeof raw !== 'object') return []
+ const content = raw.content
+ if (typeof content === 'string') return [{ type: 'text', text: content }]
+ if (Array.isArray(content)) {
+ return content.filter((c) => typeof c === 'object' && c !== null)
+ }
+ return []
+}
+
+for (const [i, m] of messages.entries()) {
+ console.log(`\n=== history msg #${i} role=${m.role} ===`)
+ const blocks = readBlocks(m)
+ let rendered = 0
+ for (const block of blocks) {
+ if (block.type === 'thinking' && typeof block.text === 'string') {
+ console.log(' -> ThinkingBlock (text field)')
+ rendered++
+ continue
+ }
+ if (block.type === 'text' && block.text) {
+ console.log(` -> Markdown("${String(block.text).slice(0, 60)}…")`)
+ rendered++
+ continue
+ }
+ if (block.type === 'toolCall' && block.id) {
+ console.log(' -> ToolCard', block.name)
+ rendered++
+ continue
+ }
+ console.log(' -> **被丢弃** block type =', block.type, 'keys =', Object.keys(block).join(','))
+ }
+ console.log(` rendered ${rendered}/${blocks.length} blocks`)
+}
diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts
index f2fd79d..3b21028 100644
--- a/apps/desktop/src/main/index.ts
+++ b/apps/desktop/src/main/index.ts
@@ -1,14 +1,35 @@
-import { app, BrowserWindow, shell } from 'electron'
+import { app, BrowserWindow, ipcMain, Menu, shell } from 'electron'
+import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { registerIpc } from './ipc'
process.env.APP_ROOT = join(__dirname, '..')
-export const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL
+export const VITE_DEV_SERVER_URL = process.env.ELECTRON_RENDERER_URL ?? process.env.VITE_DEV_SERVER_URL
export const RENDERER_DIST = join(process.env.APP_ROOT, 'renderer')
+/** 应用图标(build/icon.ico,由 scripts/make-icons.cjs 从 build/icon.svg 生成) */
+const APP_ICON = join(app.getAppPath(), 'build', 'icon.ico')
+
const agentService = registerIpc()
+/** 窗口控制(自绘标题栏按钮走这三个 IPC;不使用系统 WCO,
+ * 否则 overlay 的原生白色背景会盖住标题栏分隔线) */
+function registerWindowControls(): void {
+ ipcMain.handle('win:minimize', (event) => {
+ BrowserWindow.fromWebContents(event.sender)?.minimize()
+ })
+ ipcMain.handle('win:toggleMaximize', (event) => {
+ const win = BrowserWindow.fromWebContents(event.sender)
+ if (!win) return
+ if (win.isMaximized()) win.unmaximize()
+ else win.maximize()
+ })
+ ipcMain.handle('win:close', (event) => {
+ BrowserWindow.fromWebContents(event.sender)?.close()
+ })
+}
+
async function createWindow(): Promise {
const win = new BrowserWindow({
width: 1280,
@@ -16,8 +37,12 @@ async function createWindow(): Promise {
minWidth: 960,
minHeight: 640,
show: false,
- backgroundColor: '#09090b',
- title: '店铺运营 Agent',
+ backgroundColor: '#ffffff',
+ title: 'Inkreach Agent',
+ // 窗口/任务栏图标(Windows/Linux;macOS 打包时由 build 配置提供 icns)
+ ...(existsSync(APP_ICON) ? { icon: APP_ICON } : {}),
+ // 无原生标题栏/菜单栏,标题栏与窗口控制按钮全部由渲染层 TitleBar 自绘
+ titleBarStyle: 'hidden',
webPreferences: {
preload: join(__dirname, '../preload/index.cjs'),
sandbox: false,
@@ -26,6 +51,18 @@ async function createWindow(): Promise {
}
})
+ // 最大化状态同步给渲染层(切换「最大化/还原」字形)
+ const sendWinState = (): void => win.webContents.send('win:state', win.isMaximized())
+ win.on('maximize', sendWinState)
+ win.on('unmaximize', sendWinState)
+
+ // 默认应用菜单已移除(File/Edit/View 不再显示),F12 补回 DevTools 开关
+ win.webContents.on('before-input-event', (_event, input) => {
+ if (input.type === 'keyDown' && input.key === 'F12') {
+ win.webContents.toggleDevTools()
+ }
+ })
+
win.on('ready-to-show', () => {
win.show()
// pi 会话初始化放在窗口就绪后,错误可以第一时间在 UI 上呈现
@@ -46,8 +83,8 @@ async function createWindow(): Promise {
})
// 渲染层日志/加载失败转发到主进程 stdout,便于排查白屏/黑屏问题
- win.webContents.on('console-message', (_e, level, message, line, sourceId) => {
- console.log(`[renderer:${level}] ${message} (${sourceId}:${line})`)
+ win.webContents.on('console-message', (details) => {
+ console.log(`[renderer:${details.level}] ${details.message} (${details.sourceId}:${details.lineNumber})`)
})
win.webContents.on('did-fail-load', (_e, code, desc, url) => {
console.log(`[renderer:load-fail] ${code} ${desc} ${url}`)
@@ -79,6 +116,11 @@ if (!app.requestSingleInstanceLock()) {
})
void app.whenReady().then(() => {
+ // Windows 任务栏归组的 AppUserModelID
+ if (process.platform === 'win32') {
+ app.setAppUserModelId('com.inkreach.agent')
+ }
+ Menu.setApplicationMenu(null)
void createWindow()
app.on('activate', () => {
@@ -89,4 +131,6 @@ if (!app.requestSingleInstanceLock()) {
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
+
+ registerWindowControls()
}
diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts
index abf53df..eb3bc21 100644
--- a/apps/desktop/src/main/ipc.ts
+++ b/apps/desktop/src/main/ipc.ts
@@ -1,4 +1,5 @@
-import { BrowserWindow, ipcMain } from 'electron'
+import { BrowserWindow, dialog, ipcMain } from 'electron'
+import { userInfo } from 'node:os'
import { AgentService } from './pi/agent-service'
/**
@@ -16,11 +17,20 @@ export function registerIpc(): AgentService {
}
})
- ipcMain.handle('agent:send', (_event, text: unknown) => {
+ ipcMain.handle('agent:send', (_event, text: unknown, images: unknown) => {
if (typeof text !== 'string' || !text.trim()) {
throw new Error('empty prompt')
}
- return service.send(text)
+ const sendImages = Array.isArray(images)
+ ? images.filter(
+ (img): img is { mimeType: string; data: string } =>
+ typeof img === 'object' &&
+ img !== null &&
+ typeof (img as { mimeType?: unknown }).mimeType === 'string' &&
+ typeof (img as { data?: unknown }).data === 'string'
+ )
+ : undefined
+ return service.send(text, sendImages)
})
ipcMain.handle('agent:steer', (_event, text: unknown) => {
@@ -105,6 +115,39 @@ export function registerIpc(): AgentService {
return pass(() => service.setSystemPrompt(text))
})
+ // ---- 工作区 ----
+ ipcMain.handle('workspaces:list', () =>
+ service.listWorkspaces().catch((err: unknown) => {
+ throw new Error(err instanceof Error ? err.message : String(err))
+ })
+ )
+
+ ipcMain.handle('workspaces:create', (_event, name: unknown) => {
+ if (typeof name !== 'string' || !name.trim()) throw new Error('invalid workspace name')
+ return service.createWorkspace(name).catch((err: unknown) => {
+ throw new Error(err instanceof Error ? err.message : String(err))
+ })
+ })
+
+ ipcMain.handle('workspaces:open', (_event, path: unknown) => {
+ if (typeof path !== 'string' || !path.trim()) throw new Error('invalid workspace path')
+ return service.openWorkspace(path).catch((err: unknown) => {
+ throw new Error(err instanceof Error ? err.message : String(err))
+ })
+ })
+
+ // 调起系统目录选择器;取消返回 null
+ ipcMain.handle('workspaces:browse', async () => {
+ const win = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0]
+ const result = await dialog.showOpenDialog(win, {
+ title: '选择工作区目录',
+ defaultPath: service.workspacesRoot,
+ properties: ['openDirectory', 'createDirectory']
+ })
+ if (result.canceled || result.filePaths.length === 0) return null
+ return result.filePaths[0]
+ })
+
// ---- 工具审批 ----
ipcMain.handle('approval:resolve', (_event, id: unknown, approved: unknown) => {
if (typeof id !== 'string' || typeof approved !== 'boolean') throw new Error('invalid approval')
@@ -117,8 +160,39 @@ export function registerIpc(): AgentService {
app: process.env.npm_package_version ?? '0.0.0',
electron: process.versions.electron,
node: process.versions.node,
- chrome: process.versions.chrome
+ chrome: process.versions.chrome,
+ user: userInfo().username
}))
+ // ---- Chatbox 资源(/ 命令 + 技能、@ 工作区文件、+ 附件)----
+ ipcMain.handle('chat:resources', () =>
+ service.listChatResources().catch((err: unknown) => {
+ throw new Error(err instanceof Error ? err.message : String(err))
+ })
+ )
+
+ ipcMain.handle('queue:set', (_event, steering: unknown, followUp: unknown) => {
+ const norm = (value: unknown): string[] =>
+ Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
+ return service.setQueue(norm(steering), norm(followUp))
+ })
+
+ ipcMain.handle('permissions:set', (_event, mode: unknown) => {
+ const valid: ReadonlySet = new Set(['confirm', 'autoEdit', 'plan', 'full'])
+ if (typeof mode !== 'string' || !valid.has(mode)) throw new Error('invalid permission mode')
+ service.setPermissionMode(mode as never)
+ })
+
+ ipcMain.handle('files:list', () =>
+ service.listWorkspaceFiles().catch((err: unknown) => {
+ throw new Error(err instanceof Error ? err.message : String(err))
+ })
+ )
+
+ ipcMain.handle('attachments:pick', async (event) => {
+ const win = BrowserWindow.fromWebContents(event.sender) ?? BrowserWindow.getAllWindows()[0]
+ return service.pickAttachments(win ?? undefined)
+ })
+
return service
}
diff --git a/apps/desktop/src/main/pi/agent-service.ts b/apps/desktop/src/main/pi/agent-service.ts
index b8ca928..f680b91 100644
--- a/apps/desktop/src/main/pi/agent-service.ts
+++ b/apps/desktop/src/main/pi/agent-service.ts
@@ -1,11 +1,12 @@
-import { app, shell } from 'electron'
-import { readFileSync, writeFileSync } from 'node:fs'
-import { join } from 'node:path'
+import { app, dialog, shell } from 'electron'
+import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
+import { dirname, join } from 'node:path'
import {
createAgentSessionRuntime,
createAgentSessionServices,
createAgentSessionFromServices,
getAgentDir,
+ loadSkills,
SessionManager,
type AgentSession,
type AgentSessionEvent,
@@ -18,11 +19,17 @@ import { demoTools, TOOLS_REQUIRING_APPROVAL } from '../tools/demo-tools'
import type {
AgentUiEvent,
AppSettings,
+ AttachmentInfo,
+ ChatResources,
ModelCatalog,
ModelInfo,
+ PermissionMode,
+ SendImage,
SessionMeta,
SessionState,
- SessionStatsInfo
+ SessionStatsInfo,
+ WorkspaceFile,
+ WorkspaceMeta
} from '@shared/types'
/** 增量事件合批的刷新间隔(ms),参考 pi-web 的 60ms 快照节流经验取略低值 */
@@ -43,10 +50,24 @@ export class AgentService {
private attached: AgentSession | null = null
private unsubscribeAttached: (() => void) | null = null
private settings: AppSettings = { systemPrompt: '' }
+ /** 当前工作区目录(agent cwd),会话按目录隔离 */
+ private workspaceDir = ''
+ private approvalsRegistered = false
+ /** 权限模式(Composer pill / 审批行为),持久化在应用设置里 */
+ private permissionMode: PermissionMode = 'confirm'
readonly approvals = new ApprovalService()
+ /** 工作区根目录:文档/Inkreach,每个子目录 = 一个工作区 */
+ get workspacesRoot(): string {
+ return join(app.getPath('documents'), 'Inkreach')
+ }
+
+ private get defaultWorkspaceDir(): string {
+ return join(this.workspacesRoot, 'Default')
+ }
+
private get cwd(): string {
- return app.getPath('home')
+ return this.workspaceDir || this.defaultWorkspaceDir
}
private get settingsFile(): string {
@@ -54,14 +75,103 @@ export class AgentService {
}
private loadSettings(): AppSettings {
+ const VALID_MODES: ReadonlySet = new Set(['confirm', 'autoEdit', 'plan', 'full'])
try {
const raw = JSON.parse(readFileSync(this.settingsFile, 'utf8')) as Partial
- return {
- systemPrompt: typeof raw.systemPrompt === 'string' ? raw.systemPrompt : ''
+ this.settings = {
+ systemPrompt: typeof raw.systemPrompt === 'string' ? raw.systemPrompt : '',
+ workspace: typeof raw.workspace === 'string' ? raw.workspace : undefined,
+ customWorkspaces: Array.isArray(raw.customWorkspaces)
+ ? raw.customWorkspaces.filter((p): p is string => typeof p === 'string')
+ : undefined,
+ permissionMode:
+ typeof raw.permissionMode === 'string' && VALID_MODES.has(raw.permissionMode)
+ ? (raw.permissionMode as PermissionMode)
+ : 'confirm'
}
} catch {
- return { systemPrompt: '' }
+ this.settings = { systemPrompt: '', permissionMode: this.permissionMode }
}
+ this.permissionMode = this.settings.permissionMode ?? 'confirm'
+ this.initWorkspaces()
+ return this.settings
+ }
+
+ /** 确保根目录与默认工作区存在,并解析当前工作区(设置值失效时回退 Default) */
+ private initWorkspaces(): void {
+ try {
+ mkdirSync(this.defaultWorkspaceDir, { recursive: true })
+ } catch (err) {
+ console.error('[agent-service] init workspaces failed:', err)
+ }
+ const saved = this.settings.workspace
+ this.workspaceDir = saved && existsSync(saved) ? saved : this.defaultWorkspaceDir
+ }
+
+ // ---- 工作区 ----
+
+ async listWorkspaces(): Promise {
+ try {
+ mkdirSync(this.defaultWorkspaceDir, { recursive: true })
+ } catch {
+ // 目录已存在或创建失败时仍尝试枚举
+ }
+ const entries: WorkspaceMeta[] = []
+ const seen = new Set([this.workspacesRoot.toLowerCase()])
+ try {
+ for (const d of readdirSync(this.workspacesRoot, { withFileTypes: true })) {
+ if (!d.isDirectory()) continue
+ const path = join(this.workspacesRoot, d.name)
+ entries.push({ name: d.name, path })
+ seen.add(path.toLowerCase())
+ }
+ } catch (err) {
+ console.error('[agent-service] list workspaces failed:', err)
+ }
+ // 目录选择器添加的根目录之外的工作区
+ for (const p of this.settings.customWorkspaces ?? []) {
+ if (!existsSync(p)) continue
+ const normalized = join(p).replace(/[/\\]+$/, '')
+ if (seen.has(normalized.toLowerCase())) continue
+ seen.add(normalized.toLowerCase())
+ entries.push({ name: dirname(normalized) === normalized ? normalized : normalized.split(/[\\/]/).pop() || normalized, path: normalized })
+ }
+ if (!entries.some((w) => w.name === 'Default')) {
+ entries.unshift({ name: 'Default', path: this.defaultWorkspaceDir })
+ }
+ return entries.sort((a, b) => (a.name === 'Default' ? -1 : b.name === 'Default' ? 1 : a.name.localeCompare(b.name)))
+ }
+
+ async createWorkspace(name: string): Promise {
+ const trimmed = name.trim()
+ if (!trimmed || /[\\/:*?"<>|]/.test(trimmed)) {
+ throw new Error('工作区名称非法(不能包含 \\/:*?"<>|)')
+ }
+ const path = join(this.workspacesRoot, trimmed)
+ if (existsSync(path)) {
+ throw new Error(`工作区已存在: ${trimmed}`)
+ }
+ mkdirSync(path, { recursive: true })
+ return { name: trimmed, path }
+ }
+
+ /** 切换工作区:持久化设置后重建运行时(cwd 变化 → 会话列表随目录隔离)。
+ * 允许任意已存在目录;根目录之外的目录记入自定义工作区以便下次列出。 */
+ async openWorkspace(path: string): Promise {
+ const resolved = join(path).replace(/[/\\]+$/, '')
+ if (!existsSync(resolved)) {
+ throw new Error(`工作区目录不存在: ${path}`)
+ }
+ if (samePath(resolved, this.cwd)) return
+ if (dirname(resolved).toLowerCase() !== this.workspacesRoot.toLowerCase()) {
+ const custom = new Set(this.settings.customWorkspaces ?? [])
+ custom.add(resolved)
+ this.settings.customWorkspaces = [...custom]
+ }
+ this.settings.workspace = resolved
+ this.saveSettings()
+ this.workspaceDir = resolved
+ await this.rebuildRuntime()
}
private saveSettings(): void {
@@ -118,20 +228,25 @@ export class AgentService {
const cwd = this.cwd
const agentDir = getAgentDir()
this.settings = this.loadSettings()
- // 审批拦截:TOOLS_REQUIRING_APPROVAL 名单内的工具调用需经渲染层 Approval Card 确认
- this.approvals.onRequest((request) => {
- this.emit({ type: 'approval_request', request })
- })
- this.approvals.onSettle((id, approved, timedOut) => {
- this.emit({ type: 'approval_settled', id, approved, timedOut })
- })
+ // 审批拦截只需注册一次(create 会随工作区切换多次执行)
+ if (!this.approvalsRegistered) {
+ this.approvalsRegistered = true
+ this.approvals.onRequest((request) => {
+ this.emit({ type: 'approval_request', request })
+ })
+ this.approvals.onSettle((id, approved, timedOut) => {
+ this.emit({ type: 'approval_settled', id, approved, timedOut })
+ })
+ }
const buildServices = (targetCwd: string, targetAgentDir: string) =>
createAgentSessionServices({
cwd: targetCwd,
agentDir: targetAgentDir,
resourceLoaderOptions: {
...(this.settings.systemPrompt.trim() ? { systemPrompt: this.settings.systemPrompt } : {}),
- extensionFactories: [createApprovalExtension(this.approvals, TOOLS_REQUIRING_APPROVAL)]
+ extensionFactories: [
+ createApprovalExtension(this.approvals, TOOLS_REQUIRING_APPROVAL, () => this.permissionMode)
+ ]
}
})
const initialServices = await buildServices(cwd, agentDir)
@@ -164,6 +279,8 @@ export class AgentService {
this.emitSwitched(session)
})
this.attach(runtime.session)
+ // 此前 runtime 从未挂到实例上(rebuildRuntime 的 dispose 永远拿不到旧实例),补上
+ this.runtime = runtime
return runtime
}
@@ -306,7 +423,9 @@ export class AgentService {
model: modelInfo,
streaming: session.isStreaming,
activeTools: session.getActiveToolNames(),
- messageCount: session.messages.length
+ messageCount: session.messages.length,
+ workspace: this.cwd,
+ permissionMode: this.permissionMode
}
}
@@ -331,13 +450,125 @@ export class AgentService {
return this.buildState(runtime.session)
}
- async send(text: string): Promise {
+ async send(text: string, images?: SendImage[]): Promise {
const runtime = await this.getRuntime()
const session = runtime.session
- if (session.isStreaming) {
- await session.prompt(text, { streamingBehavior: 'followUp' })
- } else {
- await session.prompt(text)
+ const imageContent = images?.length
+ ? images.map((img) => ({ type: 'image' as const, data: img.data, mimeType: img.mimeType }))
+ : undefined
+ const options = {
+ ...(session.isStreaming ? { streamingBehavior: 'followUp' as const } : {}),
+ ...(imageContent ? { images: imageContent } : {})
+ }
+ await session.prompt(text, Object.keys(options).length > 0 ? options : undefined)
+ }
+
+ // ---- Chatbox 资源(/ 命令、/skill: 技能)----
+
+ /** 重建排队消息(编辑/删除/排序/置顶都走这里:clear 后按新顺序重推)。
+ * 空闲时队列会被 pi 立即消费,无需(也不能)重推。 */
+ async setQueue(steering: string[], followUp: string[]): Promise {
+ const runtime = await this.getRuntime()
+ const session = runtime.session
+ if (!session.isStreaming) return
+ session.clearQueue()
+ for (const text of steering) void session.steer(text)
+ for (const text of followUp) void session.followUp(text)
+ }
+
+ async listChatResources(): Promise {
+ const runtime = await this.getRuntime()
+ let skills: ChatResources['skills'] = []
+ try {
+ const loaded = loadSkills({
+ cwd: this.cwd,
+ agentDir: getAgentDir(),
+ skillPaths: [],
+ includeDefaults: true
+ })
+ skills = loaded.skills.map((s) => ({ name: s.name, description: s.description ?? '' }))
+ } catch (err) {
+ console.error('[agent-service] load skills failed:', err)
+ }
+ const commands = runtime.session.promptTemplates.map((t) => ({
+ name: t.name,
+ description: t.description ?? '',
+ kind: 'prompt' as const
+ }))
+ return { commands, skills }
+ }
+
+ // ---- 工作区文件(@ 引用)----
+
+ async listWorkspaceFiles(): Promise {
+ const root = this.cwd
+ const out: WorkspaceFile[] = []
+ const SKIP = new Set(['.git', 'node_modules', 'dist', 'out', 'build', '.pi', '.next', 'coverage'])
+ const walk = (dir: string, depth: number): void => {
+ if (depth > 4 || out.length >= 400) return
+ let entries: import('node:fs').Dirent[]
+ try {
+ entries = readdirSync(dir, { withFileTypes: true })
+ } catch {
+ return
+ }
+ for (const entry of entries) {
+ if (out.length >= 400) return
+ if (entry.name.startsWith('.') && entry.name !== '.env') continue
+ const full = join(dir, entry.name)
+ const rel = full.slice(root.length + 1).replace(/\\/g, '/')
+ if (entry.isDirectory()) {
+ if (SKIP.has(entry.name)) continue
+ out.push({ path: rel, name: entry.name, isDir: true })
+ walk(full, depth + 1)
+ } else if (entry.isFile()) {
+ out.push({ path: rel, name: entry.name, isDir: false })
+ }
+ }
+ }
+ walk(root, 0)
+ return out
+ }
+
+ // ---- 附件(+ 选择文件)----
+
+ async pickAttachments(parent?: Electron.BrowserWindow): Promise {
+ const options = {
+ title: '添加附件',
+ properties: ['openFile', 'multiSelections'] as Array<'openFile' | 'multiSelections'>,
+ filters: [
+ { name: '常用文件', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'pdf', 'md', 'txt', 'csv', 'json', 'xlsx', 'docx'] },
+ { name: '所有文件', extensions: ['*'] }
+ ]
+ }
+ const result = parent ? await dialog.showOpenDialog(parent, options) : await dialog.showOpenDialog(options)
+ if (result.canceled || result.filePaths.length === 0) return []
+ return result.filePaths.map((path) => this.prepareAttachment(path))
+ }
+
+ prepareAttachment(path: string): AttachmentInfo {
+ const name = path.split(/[\\/]/).pop() ?? path
+ const ext = name.toLowerCase().split('.').pop() ?? ''
+ const IMAGE_MIME: Record = {
+ png: 'image/png',
+ jpg: 'image/jpeg',
+ jpeg: 'image/jpeg',
+ gif: 'image/gif',
+ webp: 'image/webp'
+ }
+ const mimeType = IMAGE_MIME[ext] ?? ''
+ if (!mimeType) {
+ return { name, path, mimeType: '', data: '' }
+ }
+ try {
+ const stat = statSync(path)
+ if (stat.size > 8 * 1024 * 1024) {
+ throw new Error(`图片过大(>8MB): ${name}`)
+ }
+ return { name, path, mimeType, data: readFileSync(path).toString('base64') }
+ } catch (err) {
+ console.error('[agent-service] read attachment failed:', err)
+ return { name, path, mimeType: '', data: '' }
}
}
@@ -356,9 +587,25 @@ export class AgentService {
async listSessions(): Promise {
const infos = await SessionManager.list(this.cwd)
- return infos
+ const metas = infos
.sort((a, b) => b.modified.getTime() - a.modified.getTime())
.map((info) => toSessionMeta(info))
+ // 新会话在首条消息写入前没有 .jsonl 文件,SessionManager.list 扫不到;
+ // 把当前活跃会话兜底合并进列表,避免侧栏“新建后不出现”
+ const session = this.runtime?.session
+ if (session && !metas.some((m) => samePath(m.path, session.sessionFile ?? ''))) {
+ const now = new Date().toISOString()
+ metas.unshift({
+ id: session.sessionId,
+ path: session.sessionFile ?? '',
+ name: session.sessionName,
+ firstMessage: firstUserText(session.messages),
+ created: now,
+ modified: now,
+ messageCount: session.messages.length
+ })
+ }
+ return metas
}
async newSession(): Promise {
@@ -466,11 +713,22 @@ export class AgentService {
/** 更新系统提示词后重建运行时(continueRecent 重新挂载当前最近会话,历史保留) */
async setSystemPrompt(text: string): Promise {
- this.settings = { systemPrompt: text }
+ this.settings = { ...this.settings, systemPrompt: text }
this.saveSettings()
await this.rebuildRuntime()
}
+ /** 切换权限模式:即时生效(审批扩展每笔工具调用实时读取),并广播给渲染层 */
+ setPermissionMode(mode: PermissionMode): void {
+ this.permissionMode = mode
+ this.settings = { ...this.settings, permissionMode: mode }
+ this.saveSettings()
+ const session = this.runtime?.session ?? this.attached
+ if (session) {
+ this.emit({ type: 'state', state: this.buildState(session) })
+ }
+ }
+
private async rebuildRuntime(): Promise {
const old = this.runtime
this.runtime = null
@@ -518,3 +776,24 @@ function readRole(message: unknown): string {
}
return 'unknown'
}
+
+/** 提取第一条用户消息的纯文本(新会话未落盘时用作列表标题) */
+function firstUserText(messages: unknown[]): string {
+ for (const message of messages) {
+ if (readRole(message) !== 'user') continue
+ const content = (message as { content?: unknown }).content
+ if (typeof content === 'string' && content.trim()) return content
+ if (Array.isArray(content)) {
+ const text = content
+ .map((block) =>
+ block && typeof block === 'object' && typeof (block as { text?: unknown }).text === 'string'
+ ? (block as { text: string }).text
+ : ''
+ )
+ .join('')
+ .trim()
+ if (text) return text
+ }
+ }
+ return ''
+}
diff --git a/apps/desktop/src/main/pi/approval.ts b/apps/desktop/src/main/pi/approval.ts
index 245c555..e7568c7 100644
--- a/apps/desktop/src/main/pi/approval.ts
+++ b/apps/desktop/src/main/pi/approval.ts
@@ -1,14 +1,19 @@
import { randomUUID } from 'node:crypto'
import type { ExtensionAPI, InlineExtension } from '@earendil-works/pi-coding-agent'
+import type { PermissionMode } from '@shared/types'
/**
* 工具调用审批桥:
* - extension 拦截 tool_call → 挂起等待渲染层审批
* - ApprovalService 维护 pending 请求,经 IPC 与 Approval Card 交互
* - 超时自动拒绝,避免 agent 永久阻塞
+ * - 权限模式:full 全放行 / autoEdit 自动放行文件编辑 / plan 拦截修改类操作
*/
const APPROVAL_TIMEOUT_MS = 120_000
+/** 文件编辑类工具(autoEdit 模式自动放行;plan 模式拦截) */
+const EDIT_TOOLS = new Set(['write', 'edit'])
+
export interface PendingApproval {
id: string
toolName: string
@@ -67,17 +72,29 @@ function cloneable(value: unknown): unknown {
/**
* 生产 approval 拦截 extension:
- * 匹配 NEEDS_APPROVAL 名单的工具调用先经用户审批,拒绝则 block。
+ * 匹配 NEEDS_APPROVAL 名单的工具调用按权限模式处理 ——
+ * confirm:先经用户审批,拒绝则 block;full:全部放行;
+ * autoEdit:文件编辑自动放行(bash 等仍需审批);plan:修改类操作直接拦截。
*/
export function createApprovalExtension(
service: ApprovalService,
- needsApproval: ReadonlySet
+ needsApproval: ReadonlySet,
+ getMode: () => PermissionMode
): InlineExtension {
return {
name: 'aso-approval',
factory: (pi: ExtensionAPI) => {
pi.on('tool_call', async (event) => {
if (!needsApproval.has(event.toolName)) return
+ const mode = getMode()
+ if (mode === 'full') return
+ if (mode === 'autoEdit' && EDIT_TOOLS.has(event.toolName)) return
+ if (mode === 'plan' && (EDIT_TOOLS.has(event.toolName) || event.toolName === 'bash')) {
+ return {
+ block: true,
+ reason: '计划模式已开启:请先输出完整的执行计划(只读分析),待用户切换权限模式后再执行修改类操作。'
+ }
+ }
const approved = await service.request(event.toolName, event.input)
if (!approved) {
return { block: true, reason: '用户拒绝了本次工具调用审批' }
diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts
index 53bbb8a..0ee0647 100644
--- a/apps/desktop/src/preload/index.ts
+++ b/apps/desktop/src/preload/index.ts
@@ -1,18 +1,51 @@
import { contextBridge, ipcRenderer } from 'electron'
-import type { AgentUiEvent, AppSettings, ModelCatalog, SessionMeta, SessionState } from '@shared/types'
+import type {
+ AgentUiEvent,
+ AppSettings,
+ AttachmentInfo,
+ ChatResources,
+ ModelCatalog,
+ PermissionMode,
+ SendImage,
+ SessionMeta,
+ SessionState,
+ WorkspaceFile,
+ WorkspaceMeta
+} from '@shared/types'
const api = {
- send: (text: string): Promise => ipcRenderer.invoke('agent:send', text),
+ // 窗口控制(自绘标题栏按钮)
+ minimize: (): Promise => ipcRenderer.invoke('win:minimize'),
+ toggleMaximize: (): Promise => ipcRenderer.invoke('win:toggleMaximize'),
+ close: (): Promise => ipcRenderer.invoke('win:close'),
+ onWindowState: (callback: (maximized: boolean) => void): (() => void) => {
+ const listener = (_e: Electron.IpcRendererEvent, maximized: boolean): void => callback(maximized)
+ ipcRenderer.on('win:state', listener)
+ return () => {
+ ipcRenderer.removeListener('win:state', listener)
+ }
+ },
+ send: (text: string, images?: SendImage[]): Promise => ipcRenderer.invoke('agent:send', text, images),
steer: (text: string): Promise => ipcRenderer.invoke('agent:steer', text),
stop: (): Promise => ipcRenderer.invoke('agent:stop'),
getState: (): Promise => ipcRenderer.invoke('agent:state'),
- versions: (): Promise<{ app: string; electron: string; node: string; chrome: string }> =>
+ versions: (): Promise<{ app: string; electron: string; node: string; chrome: string; user: string }> =>
ipcRenderer.invoke('app:versions'),
listSessions: (): Promise => ipcRenderer.invoke('sessions:list'),
newSession: (): Promise => ipcRenderer.invoke('sessions:new'),
openSession: (path: string): Promise => ipcRenderer.invoke('sessions:open', path),
renameSession: (name: string): Promise => ipcRenderer.invoke('sessions:rename', name),
deleteSession: (path: string): Promise => ipcRenderer.invoke('sessions:delete', path),
+ listWorkspaces: (): Promise => ipcRenderer.invoke('workspaces:list'),
+ createWorkspace: (name: string): Promise => ipcRenderer.invoke('workspaces:create', name),
+ openWorkspace: (path: string): Promise => ipcRenderer.invoke('workspaces:open', path),
+ browseWorkspace: (): Promise => ipcRenderer.invoke('workspaces:browse'),
+ listChatResources: (): Promise => ipcRenderer.invoke('chat:resources'),
+ setQueue: (steering: string[], followUp: string[]): Promise =>
+ ipcRenderer.invoke('queue:set', steering, followUp),
+ setPermissionMode: (mode: PermissionMode): Promise => ipcRenderer.invoke('permissions:set', mode),
+ listWorkspaceFiles: (): Promise => ipcRenderer.invoke('files:list'),
+ pickAttachments: (): Promise => ipcRenderer.invoke('attachments:pick'),
getModelCatalog: (): Promise => ipcRenderer.invoke('models:catalog'),
setModel: (providerId: string, modelId: string): Promise =>
ipcRenderer.invoke('models:set', providerId, modelId),
diff --git a/apps/desktop/src/renderer/index.html b/apps/desktop/src/renderer/index.html
index fed5409..ff0c802 100644
--- a/apps/desktop/src/renderer/index.html
+++ b/apps/desktop/src/renderer/index.html
@@ -5,11 +5,11 @@
- 店铺运营 Agent
+ Inkreach Agent
-
+
diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx
index 4aa769b..c6eadc7 100644
--- a/apps/desktop/src/renderer/src/App.tsx
+++ b/apps/desktop/src/renderer/src/App.tsx
@@ -1,16 +1,19 @@
import { useEffect, useRef, useState } from 'react'
import type { JSX } from 'react'
import { useAgent } from './hooks/useAgent'
-import { Sidebar } from './components/Sidebar'
+import { SidebarNav } from './components/SidebarNav'
+import { TitleBar } from './components/TitleBar'
import { ChatView } from './components/ChatView'
+import { BottomSlot } from './components/BottomSlot'
import { Composer } from './components/Composer'
import { SettingsModal } from './components/SettingsModal'
-import { ApprovalStack } from './components/ApprovalCard'
+import { Search } from './components/Search'
export default function App(): JSX.Element {
const {
state,
sessions,
+ workspaces,
send,
stop,
clearError,
@@ -18,23 +21,31 @@ export default function App(): JSX.Element {
openSession,
renameSession,
deleteSession,
+ setModel,
resolveApproval,
- refreshSessions
+ setPermissionMode,
+ refreshSessions,
+ openWorkspace
} = useAgent()
const prefillRef = useRef(null)
const [settingsOpen, setSettingsOpen] = useState(false)
+ const [searchOpen, setSearchOpen] = useState(false)
- // 全局快捷键:Ctrl+N 新会话 / Ctrl+, 设置 / Esc 关闭弹窗
+ // 全局快捷键:Ctrl+N 新会话 / Ctrl+K 搜索 / Ctrl+, 设置 / Esc 关闭弹窗
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
if (e.ctrlKey && e.key.toLowerCase() === 'n') {
e.preventDefault()
newSession()
+ } else if (e.ctrlKey && e.key.toLowerCase() === 'k') {
+ e.preventDefault()
+ setSearchOpen(true)
} else if (e.ctrlKey && e.key === ',') {
e.preventDefault()
setSettingsOpen(true)
} else if (e.key === 'Escape') {
setSettingsOpen(false)
+ setSearchOpen(false)
}
}
window.addEventListener('keydown', onKey)
@@ -45,23 +56,38 @@ export default function App(): JSX.Element {
prefillRef.current = text
}
- const queuedCount = state.queue.followUp.length + state.queue.steering.length
+ // 选区操作(解释/改进/精简/自定义指令):把指令 + 引用发给 Agent
+ const handleApplySelection = (instruction: string, selection: string): void => {
+ send(`${instruction}:\n\n> ${selection.split('\n').join('\n> ')}`)
+ }
+
+ // 标题栏会话名:优先当前会话名,其次会话列表里的名称/首条消息
+ const activeMeta = state.sessionState?.sessionId
+ ? sessions.find((s) => s.id === state.sessionState?.sessionId)
+ : undefined
+ const sessionTitle =
+ state.sessionState?.sessionName?.trim() ||
+ activeMeta?.name?.trim() ||
+ activeMeta?.firstMessage?.trim() ||
+ ''
return (
-
-
+ setSearchOpen(true)}
onOpenSettings={() => setSettingsOpen(true)}
- onRefreshSessions={refreshSessions}
+ workspaces={workspaces}
+ activeWorkspace={workspaces.find((w) => w.path === state.sessionState?.workspace)}
+ onOpenWorkspace={openWorkspace}
/>
+
- setSettingsOpen(true)}
+ onPermissionMode={setPermissionMode}
+ prefillRef={prefillRef}
+ />
+ }
/>
-
+ setSearchOpen(false)}
+ sessions={sessions}
+ onNewSession={newSession}
+ onRefreshSessions={refreshSessions}
+ onOpenSettings={() => setSettingsOpen(true)}
+ onOpenSession={openSession}
+ />
('up')
+
+ useEffect(() => {
+ if (prevRef.current === value) return
+ const from = prevRef.current
+ prevRef.current = value
+ const fromN = parseInt(from, 10)
+ const toN = parseInt(value, 10)
+ setDir(Number.isFinite(fromN) && Number.isFinite(toN) && toN < fromN ? 'down' : 'up')
+ setOldVal(from)
+ setChars(value)
+ setRolling(true)
+ setShifted(false)
+
+ let raf2 = 0
+ const raf1 = requestAnimationFrame(() => {
+ raf2 = requestAnimationFrame(() => setShifted(true))
+ })
+ const done = setTimeout(() => {
+ setRolling(false)
+ setOldVal(value)
+ setShifted(false)
+ }, ROLL_MS)
+
+ return () => {
+ cancelAnimationFrame(raf1)
+ cancelAnimationFrame(raf2)
+ clearTimeout(done)
+ }
+ }, [value])
+
+ return (
+ <>
+ {Array.from({ length: chars.length }, (_, i) => {
+ const o = oldVal[i] ?? ''
+ const n = chars[i] ?? ''
+ if (!rolling || o === n) return {n}
+ const top = dir === 'down' ? n : o
+ const bottom = dir === 'down' ? o : n
+ const restY = dir === 'down' ? '0' : '-1em'
+ const startY = dir === 'down' ? '-1em' : '0'
+ return (
+
+
+ {top}
+ {bottom}
+
+
+ )
+ })}
+ >
+ )
+}
+
+function Ico({ path, size = 14, sw = 2 }: { path: ReactNode; size?: number; sw?: number }): ReactNode {
+ return (
+
+ )
+}
+
+const OPTION_APPROVE = 0
+
+export function ApprovalCard({
request,
onResolve
}: {
request: ApprovalRequest
- onResolve: (approved: boolean) => void
-}): JSX.Element {
- const ref = useRef(null)
- const [remaining, setRemaining] = useState(APPROVAL_TIMEOUT_S)
-
- useEffect(() => {
- const start = Date.now()
- const timer = setInterval(() => {
- const left = Math.max(0, APPROVAL_TIMEOUT_S - Math.floor((Date.now() - start) / 1000))
- setRemaining(left)
- if (left === 0) clearInterval(timer)
- }, 1000)
- return () => clearInterval(timer)
- }, [request.id])
-
- useEffect(() => {
- const el = ref.current
- if (!el) return
- let tween: { kill(): void } | null = null
- let cancelled = false
- void import('gsap').then((gsapModule) => {
- if (cancelled) return
- tween = gsapModule.gsap.fromTo(
- el,
- { opacity: 0, y: 16, scale: 0.97 },
- { opacity: 1, y: 0, scale: 1, duration: 0.3, ease: 'back.out(1.6)' }
- )
- })
- return () => {
- cancelled = true
- tween?.kill()
- }
- }, [])
-
- const argsText = (() => {
- try {
- return JSON.stringify(request.args, null, 2)
- } catch {
- return String(request.args)
- }
- })()
-
- return (
-
-
-
-
-
-
-
敏感操作,需要你的确认
-
{request.toolName}
-
-
-
- {Math.floor(remaining / 60)}:{String(remaining % 60).padStart(2, '0')}
-
-
- {argsText && argsText !== 'null' && (
-
- {argsText.length > 600 ? `${argsText.slice(0, 600)}…` : argsText}
-
- )}
-
-
-
-
-
- )
-}
-
-export function ApprovalStack({
- requests,
- onResolve
-}: {
- requests: ApprovalRequest[]
onResolve: (id: string, approved: boolean) => void
-}): JSX.Element | null {
- if (requests.length === 0) return null
+}): ReactNode {
+ const [picked, setPicked] = useState(null)
+ const [sent, setSent] = useState(null)
+ const submitTimer = useRef | null>(null)
+ const settled = useRef(false)
+
+ useEffect(
+ () => () => {
+ if (submitTimer.current) clearTimeout(submitTimer.current)
+ },
+ []
+ )
+
+ const settle = (approved: boolean, note: string): void => {
+ if (settled.current) return
+ settled.current = true
+ setSent(note)
+ if (submitTimer.current) clearTimeout(submitTimer.current)
+ onResolve(request.id, approved)
+ }
+
+ const pick = (index: number): void => {
+ if (sent) return
+ setPicked(index)
+ // radio 自动前进:480ms 后提交(官方 single-choice 行为)
+ if (submitTimer.current) clearTimeout(submitTimer.current)
+ submitTimer.current = setTimeout(() => {
+ settle(index === OPTION_APPROVE, index === OPTION_APPROVE ? '已允许执行' : '已拒绝')
+ }, 480)
+ }
+
+ if (sent) {
+ const ok = sent === '已允许执行'
+ return (
+
+
+
+ {ok ? (
+
+ ) : (
+
+ )}
+
+ {sent}
+
+
+ )
+ }
+
return (
-
- {requests.map((request) => (
-
onResolve(request.id, approved)} />
- ))}
+
+
+
+
+
+ {/* 问题本体即标题(官方形态:14px medium) */}
+
+ 「{request.toolName}」请求执行敏感操作
+
+
+
+ {(['允许执行', '拒绝执行'] as const).map((option, i) => {
+ const on = picked === i
+ return (
+
+ )
+ })}
+
+
+
+ {/* footer — step nav (rolling counter) + pill actions(官方底栏 1:1) */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
)
}
diff --git a/apps/desktop/src/renderer/src/components/BottomSlot.tsx b/apps/desktop/src/renderer/src/components/BottomSlot.tsx
new file mode 100644
index 0000000..bde63ce
--- /dev/null
+++ b/apps/desktop/src/renderer/src/components/BottomSlot.tsx
@@ -0,0 +1,93 @@
+import { useEffect, useRef, useState, type ReactNode } from 'react'
+import type { ApprovalRequest } from '@shared/types'
+import { ApprovalCard } from './ApprovalCard'
+
+/**
+ * 底部插槽(抽屉):审批卡出现时从 Chatbox 位置升起替换输入框,
+ * 结算后输入框滑回。两行用 grid-template-rows 0fr↔1fr 过渡做真实高度动画,
+ * 交叉淡入淡出;Composer 始终挂载(草稿不丢)。已结算的卡片以结算胶囊态
+ * 多停留约一秒再撤出,避免「啪」一下消失。
+ */
+
+const ROWS_TRANSITION = 'grid-template-rows 400ms cubic-bezier(0.22, 1, 0.36, 1)'
+const FADE_TRANSITION = 'opacity 220ms ease, transform 400ms cubic-bezier(0.22, 1, 0.36, 1)'
+
+/** 跟踪从 requests 里消失的审批:缓存原件让卡片以结算态多停留一拍 */
+function useLeavingApprovals(requests: ApprovalRequest[]): ApprovalRequest[] {
+ const seenRef = useRef(new Map())
+ for (const request of requests) seenRef.current.set(request.id, request)
+ const [leaving, setLeaving] = useState([])
+
+ useEffect(() => {
+ const gone: ApprovalRequest[] = []
+ for (const [id, request] of seenRef.current) {
+ if (!requests.some((r) => r.id === id)) gone.push(request)
+ }
+ if (gone.length === 0) return
+ for (const g of gone) seenRef.current.delete(g.id)
+ setLeaving((cur) => [...cur.filter((l) => !gone.some((g) => g.id === l.id)), ...gone])
+ const timers = gone.map((g) =>
+ setTimeout(() => {
+ setLeaving((cur) => cur.filter((l) => l.id !== g.id))
+ }, 1000)
+ )
+ return () => timers.forEach(clearTimeout)
+ }, [requests])
+
+ return leaving
+}
+
+export function BottomSlot({
+ approvals,
+ onResolve,
+ composer
+}: {
+ approvals: ApprovalRequest[]
+ onResolve: (id: string, approved: boolean) => void
+ composer: ReactNode
+}): ReactNode {
+ const leaving = useLeavingApprovals(approvals)
+ const showApproval = approvals.length > 0 || leaving.length > 0
+
+ return (
+
+ {/* Chatbox 行:审批升起时收起(高度 0 + 下沉淡出) */}
+
+
+ {/* 审批抽屉行:从输入框位置升起(高度 1fr + 上浮淡入) */}
+
+
+
+
+ {[...approvals, ...leaving.filter((l) => !approvals.some((a) => a.id === l.id))].map((request) => (
+
+ ))}
+
+
+
+
+
+ )
+}
diff --git a/apps/desktop/src/renderer/src/components/ChatView.tsx b/apps/desktop/src/renderer/src/components/ChatView.tsx
index a00e179..897d9a4 100644
--- a/apps/desktop/src/renderer/src/components/ChatView.tsx
+++ b/apps/desktop/src/renderer/src/components/ChatView.tsx
@@ -1,8 +1,9 @@
import { useEffect, useRef } from 'react'
-import type { JSX } from 'react'
-import { AlertTriangle, RotateCw, Store } from 'lucide-react'
+import type { ReactNode } from 'react'
+import { LuRotateCw, LuTriangleAlert } from 'react-icons/lu'
import type { ApprovalRequest, ToolState, UiMessage } from '../hooks/useAgent'
-import { MessageItem } from './MessageItem'
+import { AssistantTurn, MessageItem } from './MessageItem'
+import { SelectionActions } from './SelectionActions'
const SUGGESTIONS = [
'帮我分析一下今天该关注哪些品类热点',
@@ -10,24 +11,26 @@ const SUGGESTIONS = [
'查询最近一次上架任务的执行结果'
]
-function EmptyState({ onPick }: { onPick: (text: string) => void }): JSX.Element {
+/** 空状态:大标题 + 建议词(beautifului Streaming Text 的 follow-ups 行样式) */
+function EmptyState({ onPick }: { onPick: (text: string) => void }): ReactNode {
return (
-
-
-
-
店铺运营 Agent
-
- 流程固定、编排灵活。描述你的目标,Agent 会按 SOP 调用工具完成;也可以随时切为手动逐步执行。
+
今天想做点什么?
+
+ 描述你的目标,Agent 会按 SOP 调用工具完成;也可以随时切为手动逐步执行。
-
+
{SUGGESTIONS.map((s) => (
))}
@@ -36,6 +39,47 @@ function EmptyState({ onPick }: { onPick: (text: string) => void }): JSX.Element
)
}
+/** 连续 assistant 消息(pi 每个工具轮次一条)合并为一个回合渲染,
+ * 整轮思考 + 工具调用共享一张 beautifului ToolChips 卡。 */
+function renderTurns(
+ messages: UiMessage[],
+ tools: Record
,
+ streaming: boolean,
+ pendingApprovals: ApprovalRequest[]
+): ReactNode[] {
+ const items: ReactNode[] = []
+ let i = 0
+ while (i < messages.length) {
+ const message = messages[i]
+ if (message.role === 'assistant') {
+ let j = i + 1
+ while (j < messages.length && messages[j].role === 'assistant') j += 1
+ items.push(
+
+ )
+ i = j
+ } else {
+ items.push(
+
+ )
+ i += 1
+ }
+ }
+ return items
+}
+
export function ChatView({
messages,
tools,
@@ -45,7 +89,8 @@ export function ChatView({
sessionError,
pendingApprovals,
onDismissError,
- onPickSuggestion
+ onPickSuggestion,
+ onApplySelection
}: {
messages: UiMessage[]
tools: Record
@@ -56,7 +101,8 @@ export function ChatView({
pendingApprovals: ApprovalRequest[]
onDismissError: () => void
onPickSuggestion: (text: string) => void
-}): JSX.Element {
+ onApplySelection: (instruction: string, selection: string) => void
+}): ReactNode {
const scrollRef = useRef(null)
const pinnedRef = useRef(true)
@@ -80,13 +126,13 @@ export function ChatView({
return (
{(error || sessionError) && (
-
-
+
+
{sessionError ? (
<>
Agent 会话初始化失败
-
{sessionError}
+
{sessionError}
>
) : (
{error}
@@ -95,7 +141,7 @@ export function ChatView({
@@ -103,27 +149,19 @@ export function ChatView({
)}
{retry && (
-
-
+
+
请求失败,自动重试 {retry.attempt}/{retry.maxAttempts}
- {retry.errorMessage}
+ {retry.errorMessage}
)}
{hasContent ? (
- {messages.map((message) => (
-
- ))}
+ {renderTurns(messages, tools, streaming, pendingApprovals)}
) : (
@@ -131,6 +169,9 @@ export function ChatView({
)}
+
+ {/* 划选消息文本后浮出的操作条 */}
+
)
}
diff --git a/apps/desktop/src/renderer/src/components/CodeBlock.tsx b/apps/desktop/src/renderer/src/components/CodeBlock.tsx
new file mode 100644
index 0000000..374ca23
--- /dev/null
+++ b/apps/desktop/src/renderer/src/components/CodeBlock.tsx
@@ -0,0 +1,196 @@
+import { useEffect, useMemo, useState, type ReactNode } from 'react'
+
+/** 行号代码块(beautifului.dev Code Block)。
+ * 头部:代码图标 + 文件名/语言 + Copy;主体:20px 行号列 + hairline 分隔 +
+ * 语法着色(关键字 accent-ink、字符串 orange、行内标识符加粗 ink)。
+ * 内容是 unified diff(```diff 或 +/- 行占多数)时整块渲染为红绿对照。 */
+
+const KEYWORDS = new Set(
+ `export async function const let var await if else return import from class new for while try catch finally throw typeof interface type enum public private protected static extends implements null true false undefined this super yield of in as void delete instanceof default switch case break continue do`.split(
+ /\s+/
+ )
+)
+
+type Token = { text: string; kind: 'kw' | 'str' | 'com' | 'id' | 'plain' }
+
+/** 单行词法着色:注释 → 字符串 → 词元。可选括号分支会匹配空串,
+ * 必须跳过零长度匹配前进一位,否则 lastIndex 不动造成死循环。 */
+function tokenizeLine(line: string, lang: string): Token[] {
+ const commentRe = lang === 'py' || lang === 'python' || lang === 'sh' || lang === 'bash' || lang === 'yaml' ? /#.*$/ : null
+ const re = /("(?:[^"\\]|\\.)*"?|'(?:[^'\\]|\\.)*'?|`(?:[^`\\]|\\.)*`?|(\/\/.*$)?|(\b[A-Za-z_$][\w$]*\b)|(\b\d[\w.]*\b))/g
+ const tokens: Token[] = []
+ let last = 0
+ let m: RegExpExecArray | null
+ const commentMatch = line.match(commentRe ?? /$^/)
+ const commentStart = commentMatch?.index ?? Infinity
+ while ((m = re.exec(line))) {
+ if (m[0] === '') {
+ re.lastIndex += 1
+ continue
+ }
+ if (m.index >= commentStart) break
+ if (m.index > last) tokens.push({ text: line.slice(last, m.index), kind: 'plain' })
+ const [raw, lineComment, word, num] = m
+ if (lineComment) tokens.push({ text: raw, kind: 'com' })
+ else if (raw && (raw[0] === '"' || raw[0] === "'" || raw[0] === '`')) tokens.push({ text: raw, kind: 'str' })
+ else if (word) {
+ const after = line.slice(re.lastIndex).match(/^\s*\(/)
+ tokens.push({ text: raw, kind: KEYWORDS.has(word) ? 'kw' : after ? 'id' : 'plain' })
+ } else if (num) tokens.push({ text: raw, kind: 'plain' })
+ else tokens.push({ text: raw, kind: 'plain' })
+ last = re.lastIndex
+ }
+ if (last < commentStart && last < line.length) tokens.push({ text: line.slice(last), kind: 'plain' })
+ if (commentStart !== Infinity) tokens.push({ text: line.slice(commentStart), kind: 'com' })
+ return tokens
+}
+
+const TOKEN_CLASS: Record
= {
+ kw: 'text-accent-ink',
+ str: 'text-orange',
+ com: 'text-ink-3',
+ id: 'font-medium text-ink',
+ plain: ''
+}
+
+function looksLikeDiff(code: string, lang: string): boolean {
+ if (lang === 'diff' || lang === 'patch') return true
+ const lines = code.split('\n').filter((l) => l.trim() && !l.startsWith('@@'))
+ if (!lines.length) return false
+ const marked = lines.filter((l) => /^[+-]/.test(l)).length
+ return marked / lines.length > 0.6
+}
+
+function isDiffLine(line: string): 'add' | 'del' | 'ctx' | 'hunk' {
+ if (line.startsWith('@@')) return 'hunk'
+ if (line.startsWith('+')) return 'add'
+ if (line.startsWith('-')) return 'del'
+ return 'ctx'
+}
+
+/** Shiki 词元行(Markdown.tsx 异步算好传入);无 token 时回退内置简易分词 */
+export type ShikiToken = { content: string; color?: string; fontStyle?: number }
+export type ShikiLine = ShikiToken[]
+
+export function CodeBlock({
+ code,
+ lang = '',
+ filename,
+ tokens
+}: {
+ code: string
+ lang?: string
+ filename?: string
+ tokens?: ShikiLine[] | null
+}): ReactNode {
+ const source = useMemo(() => code.replace(/\n$/, ''), [code])
+ const language = lang.toLowerCase()
+ const diff = looksLikeDiff(source, language)
+ const [copied, setCopied] = useState(false)
+
+ useEffect(() => {
+ if (!copied) return
+ const t = setTimeout(() => setCopied(false), 1600)
+ return () => clearTimeout(t)
+ }, [copied])
+
+ const copy = (): void => {
+ void navigator.clipboard.writeText(source).then(() => setCopied(true))
+ }
+
+ const title = filename ?? ((language && language !== 'diff' ? language : '') || (diff ? 'diff' : 'code'))
+
+ return (
+
+ {/* 头部:文件名 + Copy */}
+
+
+
+ {title}
+
+
+
+
+ {/* 主体 */}
+ {diff ? (
+
+ ) : (
+
+
+
+ {source.split('\n').map((line, i) => {
+ const lineTokens = tokens?.[i]
+ return (
+
+ {i + 1}
+
+ {lineTokens
+ ? lineTokens.map((token, j) => (
+
+ {token.content}
+
+ ))
+ : tokenizeLine(line, language).map((token, j) => (
+
+ {token.text}
+
+ ))}
+
+
+ )
+ })}
+
+
+ )}
+
+ )
+}
+
+/** unified diff 视图:红删绿增、@@ 保持上下文行号 */
+function DiffBody({ code }: { code: string }): ReactNode {
+ return (
+
+ {code.split('\n').map((line, i) => {
+ const kind = isDiffLine(line)
+ return (
+
+ {kind === 'add' ? '+' : kind === 'del' ? '−' : ' '}
+ {kind === 'hunk' ? line : line.slice(kind === 'ctx' ? 0 : 1)}
+
+ )
+ })}
+
+ )
+}
diff --git a/apps/desktop/src/renderer/src/components/Composer.tsx b/apps/desktop/src/renderer/src/components/Composer.tsx
index e795dcb..d9b58c5 100644
--- a/apps/desktop/src/renderer/src/components/Composer.tsx
+++ b/apps/desktop/src/renderer/src/components/Composer.tsx
@@ -1,25 +1,321 @@
-import { useEffect, useRef, useState } from 'react'
+import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
+import { createPortal } from 'react-dom'
import type { JSX, RefObject } from 'react'
-import { ArrowUp, CornerDownLeft, Square } from 'lucide-react'
+import { LuCheck, LuClipboardList, LuFilePen, LuFileText, LuGripVertical, LuHand, LuPencil, LuShieldCheck, LuTrash2, LuX } from 'react-icons/lu'
+import type { AttachmentInfo, ChatResources, ModelCatalog, ModelInfo, PermissionMode, WorkspaceFile } from '@shared/types'
+
+/**
+ * Prompt Bar(beautifului.dev #prompt-bar):
+ * 14px 圆角容器 + 五列网格(+/输入/模型/语音/发送),focus 边框加深。
+ * "/" 命令面板:应用内动作 + pi 文件命令模板 + /skill: 技能(发送时由 SDK 展开);
+ * "@" 引用工作区文件;"+" 添加附件(图片为真实 ImageContent,其他文件以 @path 引用)。
+ */
+
+interface LocalCommand {
+ id: string
+ label: string
+ hint: string
+ run: () => void
+ enabled?: boolean
+}
+
+/** 语音识别(Chromium 内核;不支持/失败时静默降级为禁用态) */
+function useDictation(onText: (text: string) => void): {
+ supported: boolean
+ listening: boolean
+ toggle: () => void
+} {
+ const [listening, setListening] = useState(false)
+ const recRef = useRef<{ start: () => void; stop: () => void } | null>(null)
+ const onTextRef = useRef(onText)
+ onTextRef.current = onText
+
+ const SR = (
+ globalThis as unknown as { webkitSpeechRecognition?: new () => never; SpeechRecognition?: new () => never }
+ ).SpeechRecognition
+
+ const supported = Boolean(SR)
+
+ const toggle = (): void => {
+ if (!SR) return
+ if (listening) {
+ recRef.current?.stop()
+ return
+ }
+ try {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const rec = new (SR as any)()
+ rec.lang = 'zh-CN'
+ rec.interimResults = false
+ rec.continuous = false
+ rec.onresult = (event: { results: ArrayLike> }): void => {
+ let text = ''
+ for (let i = 0; i < event.results.length; i += 1) text += event.results[i][0].transcript
+ if (text.trim()) onTextRef.current(text.trim())
+ }
+ rec.onend = (): void => setListening(false)
+ rec.onerror = (): void => setListening(false)
+ recRef.current = rec
+ rec.start()
+ setListening(true)
+ } catch {
+ setListening(false)
+ }
+ }
+
+ return { supported, listening, toggle }
+}
+
+
+/** 输入框锚定菜单:portal 到 body 固定定位(BottomSlot 的 overflow-hidden 会裁剪普通弹层) */
+function ComposerMenu({
+ anchor,
+ align,
+ onClose,
+ children
+}: {
+ anchor: { top: number; left: number; right: number }
+ align: 'left' | 'right'
+ onClose: () => void
+ children: ReactNode
+}): ReactNode {
+ return createPortal(
+ <>
+
+
+ {children}
+
+ >,
+ document.body
+ )
+}
+
+/** 权限模式菜单(参考 ZCode:变更前确认 / 自动编辑 / 计划模式 / 完全访问) */
+const PERMISSION_MODES: Array<{ id: PermissionMode; label: string; desc: string; icon: typeof LuHand }> = [
+ { id: 'confirm', label: '变更前确认', desc: '改文件前先问我。', icon: LuHand },
+ { id: 'autoEdit', label: '自动编辑', desc: '自动编辑文件。', icon: LuFilePen },
+ { id: 'plan', label: '计划模式', desc: '编辑前先出计划。', icon: LuClipboardList },
+ { id: 'full', label: '完全访问', desc: '减少确认次数。', icon: LuShieldCheck }
+]
export function Composer({
streaming,
- queuedCount,
+ queue,
disabled,
+ model,
+ permissionMode = 'confirm',
onSend,
onStop,
+ onNewSession,
+ onSetModel,
+ onOpenSettings,
+ onPermissionMode,
prefillRef
}: {
streaming: boolean
- queuedCount: number
+ /** 排队消息(steering 先于 followUp 送达) */
+ queue: { steering: string[]; followUp: string[] }
disabled: boolean
- onSend: (text: string) => void
+ model?: ModelInfo
+ permissionMode?: PermissionMode
+ onSend: (text: string, images?: { mimeType: string; data: string }[]) => void
onStop: () => void
+ onNewSession: () => void
+ onSetModel: (providerId: string, modelId: string) => void
+ onOpenSettings: () => void
+ onPermissionMode: (mode: PermissionMode) => void
/** 供空状态建议词点击后填入 */
prefillRef: RefObject
}): JSX.Element {
const [value, setValue] = useState('')
+ const [modelOpen, setModelOpen] = useState(false)
+ const [modelPos, setModelPos] = useState<{ top: number; left: number; right: number } | null>(null)
+ const [permOpen, setPermOpen] = useState(false)
+ const [permPos, setPermPos] = useState<{ top: number; left: number; right: number } | null>(null)
+ const [catalog, setCatalog] = useState(null)
+ const [catalogLoading, setCatalogLoading] = useState(false)
+ const [attachments, setAttachments] = useState([])
+ const [picking, setPicking] = useState(false)
+ const [resources, setResources] = useState(null)
+ const [files, setFiles] = useState(null)
+ const [activeIdx, setActiveIdx] = useState(0)
const textareaRef = useRef(null)
+ const catalogLoadedRef = useRef(false)
+
+ const localCommands = useMemo(
+ () => [
+ { id: 'new', label: '/new', hint: '新会话', run: onNewSession },
+ { id: 'settings', label: '/settings', hint: '打开设置', run: onOpenSettings },
+ { id: 'stop', label: '/stop', hint: '停止生成', run: onStop, enabled: streaming },
+ { id: 'clear', label: '/clear', hint: '清空输入', run: () => setValue('') }
+ ],
+ [onNewSession, onOpenSettings, onStop, streaming]
+ )
+
+ // "/" 命令面板:输入以 / 开头且当前词无空格时触发
+ const trimmed = value.trimStart()
+ const slashExec = trimmed === value ? /^\/(\S*)$/.exec(trimmed) : null
+ const slashQuery = slashExec ? (slashExec[1] ?? '').toLowerCase() : null
+
+ // "@ " 引用文件:末个 @ 词令牌触发
+ const atMatch = /@([^\s@]*)$/.exec(value)
+ const atQuery = atMatch ? (atMatch[1] ?? '') : null
+
+ // 资源懒加载:每次打开菜单都刷新(文件/skills 变化即时可见),in-flight 防抖
+ const filesLoadingRef = useRef(false)
+ const resourcesLoadingRef = useRef(false)
+ const atOpen = atQuery !== null
+ const slashOpen = slashQuery !== null
+
+ useEffect(() => {
+ if (!slashOpen || resourcesLoadingRef.current) return
+ resourcesLoadingRef.current = true
+ window.api
+ .listChatResources()
+ .then(setResources)
+ .catch(() => undefined)
+ .finally(() => {
+ resourcesLoadingRef.current = false
+ })
+ }, [slashOpen])
+
+ useEffect(() => {
+ if (!atOpen || filesLoadingRef.current) return
+ filesLoadingRef.current = true
+ window.api
+ .listWorkspaceFiles()
+ .then(setFiles)
+ .catch(() => undefined)
+ .finally(() => {
+ filesLoadingRef.current = false
+ })
+ }, [atOpen])
+
+ // "/" 面板条目:本地命令 + pi 命令模板 + /skill: 技能
+ const commandItems = useMemo(() => {
+ if (slashQuery === null) return []
+ const q = slashQuery.slice(1).toLowerCase()
+ const match = (s: string): boolean => s.toLowerCase().includes(q) || q === ''
+ const items: Array<{ key: string; insert: string; label: string; hint: string; group: string; run?: () => void; enabled?: boolean }> = []
+ for (const cmd of localCommands) {
+ if (match(cmd.label.slice(1))) items.push({ key: `local-${cmd.id}`, insert: '', label: cmd.label, hint: cmd.hint, group: '命令', run: cmd.run, enabled: cmd.enabled })
+ }
+ for (const cmd of resources?.commands ?? []) {
+ if (match(cmd.name)) items.push({ key: `prompt-${cmd.name}`, insert: `/${cmd.name} `, label: `/${cmd.name}`, hint: cmd.description || '命令模板', group: '命令' })
+ }
+ for (const skill of resources?.skills ?? []) {
+ if (match(`skill:${skill.name}`) || match(skill.name)) {
+ items.push({ key: `skill-${skill.name}`, insert: `/skill:${skill.name} `, label: `/skill:${skill.name}`, hint: skill.description || '技能', group: '技能' })
+ }
+ }
+ return items.slice(0, 12)
+ }, [slashQuery, localCommands, resources])
+
+ // "@ " 文件条目
+ const fileItems = useMemo(() => {
+ if (atQuery === null || !files) return []
+ const q = atQuery.toLowerCase()
+ return files
+ .filter((f) => f.path.toLowerCase().includes(q))
+ .sort((a, b) => {
+ const an = a.name.toLowerCase().startsWith(q) ? 0 : 1
+ const bn = b.name.toLowerCase().startsWith(q) ? 0 : 1
+ return an - bn || a.path.length - b.path.length || a.path.localeCompare(b.path)
+ })
+ .slice(0, 8)
+ }, [atQuery, files])
+
+ const menuOpen = !modelOpen && (commandItems.length > 0 || fileItems.length > 0)
+ const menuCount = commandItems.length + fileItems.length
+
+ // ---- 排队消息(steering 先于 followUp 送达;「立即」= 置顶优先送达)----
+ const [queueOverride, setQueueOverride] = useState<{ steering: string[]; followUp: string[] } | null>(null)
+ useEffect(() => {
+ if (!queueOverride) return
+ if (
+ queue.steering.join('\u0000') === queueOverride.steering.join('\u0000') &&
+ queue.followUp.join('\u0000') === queueOverride.followUp.join('\u0000')
+ ) {
+ setQueueOverride(null)
+ }
+ }, [queue, queueOverride])
+ const effectiveQueue = queueOverride ?? queue
+
+ const queueItems = useMemo(
+ () => [
+ ...effectiveQueue.steering.map((text) => ({ text, from: 'steering' as const })),
+ ...effectiveQueue.followUp.map((text) => ({ text, from: 'followUp' as const }))
+ ],
+ [effectiveQueue]
+ )
+
+ /** 应用本地新顺序并下发;600ms 内忽略回推的 queue_update,避免行闪烁 */
+ const commitQueue = (items: Array<{ text: string; from: 'steering' | 'followUp' }>): void => {
+ const steering = items.filter((i) => i.from === 'steering').map((i) => i.text)
+ const followUp = items.filter((i) => i.from === 'followUp').map((i) => i.text)
+ setQueueOverride({ steering, followUp })
+ window.setTimeout(() => setQueueOverride(null), 600)
+ void window.api.setQueue(steering, followUp).catch(() => undefined)
+ }
+
+ const removeQueued = (index: number): void => {
+ const items = [...queueItems]
+ items.splice(index, 1)
+ commitQueue(items)
+ }
+
+ const promoteQueued = (index: number): void => {
+ if (index === 0) return
+ const items = [...queueItems]
+ const [item] = items.splice(index, 1)
+ items.unshift(item)
+ commitQueue(items)
+ }
+
+ const reorderQueued = (from: number, to: number): void => {
+ if (from === to) return
+ const items = [...queueItems]
+ const [moved] = items.splice(from, 1)
+ items.splice(to, 0, moved)
+ commitQueue(items)
+ }
+
+ const saveQueuedEdit = (index: number, text: string): void => {
+ const trimmedText = text.trim()
+ if (!trimmedText) return
+ const items = [...queueItems]
+ items[index] = { ...items[index], text: trimmedText }
+ commitQueue(items)
+ }
+
+ const [editingIdx, setEditingIdx] = useState(null)
+ const [editValue, setEditValue] = useState('')
+ const dragFromRef = useRef(null)
+ const [dragOverIdx, setDragOverIdx] = useState(null)
+
+ // 命令/文件菜单:覆盖层(不占布局,避免聊天区上下跳动)
+ const barRef = useRef(null)
+ const [menuPos, setMenuPos] = useState<{ top: number; left: number; width: number } | null>(null)
+ useEffect(() => {
+ if (menuOpen) {
+ const rect = barRef.current?.getBoundingClientRect()
+ if (rect) setMenuPos({ top: rect.top, left: rect.left, width: rect.width })
+ } else {
+ setMenuPos(null)
+ }
+ }, [menuOpen])
+ const closeMenu = (): void => {
+ if (atMatch) setValue(value.replace(/@[^\s@]*$/, ''))
+ else if (slashQuery !== null) setValue('')
+ }
+
+ useEffect(() => setActiveIdx(0), [value])
// 空状态建议词点击 → 填入输入框并聚焦
useEffect(() => {
@@ -38,73 +334,550 @@ export function Composer({
el.style.height = `${Math.min(el.scrollHeight, 200)}px`
}, [value])
+ const modelBtnRef = useRef(null)
+ const permBtnRef = useRef(null)
+
+ const anchorOf = (el: HTMLElement | null): { top: number; left: number; right: number } | null => {
+ if (!el) return null
+ const rect = el.getBoundingClientRect()
+ return { top: rect.top, left: rect.left, right: window.innerWidth - rect.right }
+ }
+
+ const openModelMenu = (): void => {
+ if (modelOpen) {
+ setModelOpen(false)
+ return
+ }
+ setModelPos(anchorOf(modelBtnRef.current))
+ setModelOpen((open) => {
+ if (!open && !catalogLoadedRef.current) {
+ catalogLoadedRef.current = true
+ setCatalogLoading(true)
+ window.api
+ .getModelCatalog()
+ .then(setCatalog)
+ .catch(() => undefined)
+ .finally(() => setCatalogLoading(false))
+ }
+ return true
+ })
+ }
+
+ const togglePermMenu = (): void => {
+ if (permOpen) {
+ setPermOpen(false)
+ return
+ }
+ setPermPos(anchorOf(permBtnRef.current))
+ setPermOpen(true)
+ }
+
+ const pickMenuItem = (index: number): void => {
+ if (index < commandItems.length) {
+ const item = commandItems[index]
+ setValue('')
+ textareaRef.current?.focus()
+ if (item.insert) {
+ setValue(item.insert)
+ textareaRef.current?.focus()
+ return
+ }
+ if (item.enabled !== false) item.run?.()
+ return
+ }
+ const file = fileItems[index - commandItems.length]
+ if (!file) return
+ setValue((current) => current.replace(/@[^\s@]*$/, `@${file.path} `))
+ textareaRef.current?.focus()
+ }
+
+ const addAttachments = (): void => {
+ if (picking) return
+ setPicking(true)
+ window.api
+ .pickAttachments()
+ .then((picked) => {
+ setAttachments((cur) => {
+ const seen = new Set(cur.map((a) => a.path))
+ return [...cur, ...picked.filter((p) => !seen.has(p.path))]
+ })
+ })
+ .catch(() => undefined)
+ .finally(() => setPicking(false))
+ }
+
const submit = (): void => {
+ if (disabled) return
const text = value.trim()
- if (!text || disabled) return
- onSend(text)
+ const images = attachments.filter((a) => a.data && a.mimeType.startsWith('image/'))
+ const fileRefs = attachments.filter((a) => !(a.data && a.mimeType.startsWith('image/')))
+ if (!text && images.length === 0) return
+ const refLines = fileRefs.map((f) => `@${f.path}`)
+ const full = refLines.length > 0 ? `${text}${text ? '\n\n' : ''}${refLines.join('\n')}` : text
+ onSend(full, images.map((img) => ({ mimeType: img.mimeType, data: img.data })))
setValue('')
+ setAttachments([])
+ }
+
+ const onKeyDown = (e: React.KeyboardEvent): void => {
+ if (e.nativeEvent.isComposing) return
+ if (menuOpen && menuCount > 0) {
+ if (e.key === 'ArrowDown') {
+ e.preventDefault()
+ setActiveIdx((i) => (i + 1) % menuCount)
+ return
+ }
+ if (e.key === 'ArrowUp') {
+ e.preventDefault()
+ setActiveIdx((i) => (i - 1 + menuCount) % menuCount)
+ return
+ }
+ if (e.key === 'Enter' || e.key === 'Tab') {
+ e.preventDefault()
+ pickMenuItem(activeIdx)
+ return
+ }
+ if (e.key === 'Escape') {
+ e.preventDefault()
+ setValue(atMatch ? value.replace(/@[^\s@]*$/, '') : '')
+ return
+ }
+ }
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault()
+ submit()
+ }
+ }
+
+ const dictation = useDictation((text) => setValue((current) => (current ? `${current} ${text}` : text)))
+
+ const permMeta = PERMISSION_MODES.find((m) => m.id === permissionMode) ?? PERMISSION_MODES[0]
+
+ // 快捷切换只列已配置凭据的服务商;全量目录(含未配置)去「管理模型与密钥」里看
+ const providers = catalog?.providers.filter((p) => (p.hasApiKey || p.isOAuth) && p.models.length > 0) ?? []
+ const canSend = !disabled && (value.trim().length > 0 || attachments.some((a) => a.data && a.mimeType.startsWith('image/')))
+
+ // 菜单分段偏移(键盘高亮跨段连续)
+ const commandEnd = commandItems.length
+
+ const renderMenuItem = (item: (typeof commandItems)[number], index: number): ReactNode => {
+ return (
+
+ )
}
return (
diff --git a/apps/desktop/src/renderer/src/components/GlideMenu.tsx b/apps/desktop/src/renderer/src/components/GlideMenu.tsx
new file mode 100644
index 0000000..6d7f649
--- /dev/null
+++ b/apps/desktop/src/renderer/src/components/GlideMenu.tsx
@@ -0,0 +1,56 @@
+import { useRef, useState, type ReactNode } from 'react'
+
+/** 单一悬浮高亮层在菜单行之间滑移(beautifului.dev 的 glide hover)。
+ * 行元素需带 data-menu-row,高亮层 top/height 220ms expo-out、透明度 150ms。 */
+export function GlideMenu({
+ children,
+ className = '',
+ highlightClassName = 'inset-x-0 rounded-[6px] bg-hover',
+ rowSelector = '[data-menu-row]'
+}: {
+ children: ReactNode
+ className?: string
+ highlightClassName?: string
+ rowSelector?: string
+}): ReactNode {
+ const ref = useRef
(null)
+ const [box, setBox] = useState<{ top: number; height: number } | null>(null)
+ const [visible, setVisible] = useState(false)
+
+ const moveTo = (target: EventTarget | null): void => {
+ const container = ref.current
+ if (!(target instanceof Element) || !container) return
+ const row = target.closest(rowSelector)
+ if (!(row instanceof HTMLElement) || !container.contains(row)) return
+ const containerRect = container.getBoundingClientRect()
+ const rowRect = row.getBoundingClientRect()
+ setBox({ top: rowRect.top - containerRect.top, height: rowRect.height })
+ setVisible(true)
+ }
+
+ return (
+ moveTo(event.target)}
+ onMouseLeave={() => setVisible(false)}
+ onFocusCapture={(event) => moveTo(event.target)}
+ onBlurCapture={(event) => {
+ if (!ref.current?.contains(event.relatedTarget as Node | null)) setVisible(false)
+ }}
+ className={`group/glide-menu relative flex flex-col gap-px ${className}`}
+ >
+
+ {children}
+
+ )
+}
diff --git a/apps/desktop/src/renderer/src/components/Loading.tsx b/apps/desktop/src/renderer/src/components/Loading.tsx
new file mode 100644
index 0000000..e45a783
--- /dev/null
+++ b/apps/desktop/src/renderer/src/components/Loading.tsx
@@ -0,0 +1,75 @@
+import { useEffect, useState } from 'react'
+import type { ReactNode } from 'react'
+
+/** 像素网格加载器 + shimmer 标签 + 计时(beautifului.dev Loading State)。
+ * Drive=方格 / Dots=圆点 / Orbit=绕圈。 */
+const chevron = Array.from({ length: 9 }, (_, i) => {
+ const r = Math.floor(i / 3)
+ const c = i % 3
+ return (c + Math.abs(r - 1)) * 90
+})
+
+const ORBIT_ORDER = [0, 1, 2, 5, 8, 7, 6, 3]
+const orbit = Array.from({ length: 9 }, (_, i) => {
+ const k = ORBIT_ORDER.indexOf(i)
+ return k === -1 ? null : k * 110
+})
+
+const PATTERNS: Record = {
+ Drive: { delays: chevron, dur: 650, round: false },
+ Dots: { delays: chevron, dur: 650, round: true },
+ Orbit: { delays: orbit, dur: 950, round: false }
+}
+
+function LoaderGrid({
+ delays,
+ dur,
+ round
+}: {
+ delays: (number | null)[]
+ dur: number
+ round: boolean
+}): ReactNode {
+ return (
+
+ {delays.map((delay, index) => (
+
+ ))}
+
+ )
+}
+
+export function Loading({ label = '处理中', variant = 'Drive' }: { label?: string; variant?: keyof typeof PATTERNS | string }): ReactNode {
+ const [ds, setDs] = useState(0)
+ useEffect(() => {
+ const t = setInterval(() => setDs((d) => d + 1), 100)
+ return () => clearInterval(t)
+ }, [])
+ const total = ds / 10
+ const elapsed = total < 60 ? `${total.toFixed(1)}s` : `${Math.floor(total / 60)}m ${(total % 60).toFixed(1)}s`
+ const { delays, dur, round } = PATTERNS[variant] ?? PATTERNS.Drive
+
+ return (
+
+
+
+ {label}
+
+ {elapsed}
+
+ )
+}
diff --git a/apps/desktop/src/renderer/src/components/Markdown.tsx b/apps/desktop/src/renderer/src/components/Markdown.tsx
index ea79b79..455d1c4 100644
--- a/apps/desktop/src/renderer/src/components/Markdown.tsx
+++ b/apps/desktop/src/renderer/src/components/Markdown.tsx
@@ -1,224 +1,279 @@
-import type { ReactNode } from 'react'
+import { useEffect, useState, type ReactNode } from 'react'
+import ReactMarkdown, { type Components } from 'react-markdown'
+import remarkGfm from 'remark-gfm'
+import {
+ useLLMOutput,
+ type BlockMatch,
+ type LLMOutputBlock,
+ type LLMOutputComponent,
+ type LLMOutputFallbackBlock
+} from '@llm-ui/react'
+import { markdownLookBack } from '@llm-ui/markdown'
+import {
+ codeBlockLookBack,
+ findCompleteCodeBlock,
+ findPartialCodeBlock,
+ parseCompleteMarkdownCodeBlock,
+ parsePartialMarkdownCodeBlock
+} from '@llm-ui/code'
+import { createHighlighterCore, type HighlighterCore } from 'shiki/core'
+import { createOnigurumaEngine } from 'shiki/engine/oniguruma'
+import { bundledLanguages, type BundledLanguage } from 'shiki/langs'
+import { bundledThemes } from 'shiki/themes'
+import { CodeBlock, type ShikiLine } from './CodeBlock'
/**
- * 轻量 Markdown 渲染:
- * fenced code block、标题(#~###)、无序/有序列表、引用块、链接、行内 code、加粗。
- * 全部构建 React 节点,不使用 dangerouslySetInnerHTML。
- * 链接点击由外层(Electron main 的 setWindowOpenHandler)转交系统浏览器。
+ * Markdown 渲染:基于 llm-ui(github.com/richardgill/llm-ui)做块级解析 ——
+ * 围栏代码块由 @llm-ui/code 的匹配器拆出(流式中未闭合的围栏也能正确归属),
+ * 其余内容作为 markdown 回退块交给 react-markdown + remark-gfm(表格/删除线/任务列表)。
+ * 视觉样式通过 components 映射到 beautifului 设计令牌;
+ * 代码块用 Shiki 做真实语法高亮(按需加载语言),保留本项目 CodeBlock 的行号/复制/diff 视图。
*/
-interface CodeBlock {
- lang: string
- code: string
+/** Shiki 高亮器:github-light 主题 + 常用语言(grammar 在运行时按需动态加载) */
+const HIGHLIGHT_LANGS = [
+ 'typescript',
+ 'javascript',
+ 'tsx',
+ 'jsx',
+ 'python',
+ 'rust',
+ 'go',
+ 'java',
+ 'kotlin',
+ 'c',
+ 'cpp',
+ 'csharp',
+ 'ruby',
+ 'php',
+ 'swift',
+ 'bash',
+ 'powershell',
+ 'json',
+ 'yaml',
+ 'toml',
+ 'html',
+ 'css',
+ 'sql',
+ 'markdown',
+ 'dockerfile',
+ 'diff'
+]
+
+const highlighterPromise: Promise = createHighlighterCore({
+ themes: [bundledThemes['github-light']],
+ langs: HIGHLIGHT_LANGS.map((name) => bundledLanguages[name as BundledLanguage]),
+ engine: createOnigurumaEngine(import('shiki/wasm'))
+})
+
+/** 常见语言别名 → shiki 语法名 */
+const LANG_ALIAS: Record = {
+ js: 'javascript',
+ mjs: 'javascript',
+ cjs: 'javascript',
+ jsx: 'jsx',
+ ts: 'typescript',
+ py: 'python',
+ rb: 'ruby',
+ rs: 'rust',
+ golang: 'go',
+ 'c++': 'cpp',
+ sh: 'bash',
+ shell: 'bash',
+ zsh: 'bash',
+ console: 'bash',
+ terminal: 'bash',
+ yml: 'yaml',
+ htm: 'html',
+ scss: 'css',
+ less: 'css',
+ postgres: 'sql',
+ mysql: 'sql',
+ text: '',
+ txt: '',
+ plaintext: ''
}
-function splitFences(text: string): Array<{ type: 'text'; text: string } | { type: 'code'; block: CodeBlock }> {
- const parts: Array<{ type: 'text'; text: string } | { type: 'code'; block: CodeBlock }> = []
- const regex = /```(\w*)\n?([\s\S]*?)(?:```|$)/g
- let last = 0
- let match: RegExpExecArray | null
- while ((match = regex.exec(text))) {
- if (match.index > last) parts.push({ type: 'text', text: text.slice(last, match.index) })
- parts.push({ type: 'code', block: { lang: match[1] ?? '', code: match[2] ?? '' } })
- last = regex.lastIndex
- }
- if (last < text.length) parts.push({ type: 'text', text: text.slice(last) })
- return parts
-}
-
-function renderInline(text: string, keyPrefix: string): ReactNode[] {
- const nodes: ReactNode[] = []
- const regex = /(\*\*[^*\n]+\*\*|`[^`\n]+`|\[[^\]\n]+\]\((https?:\/\/[^)\s]+)\))/g
- let last = 0
- let match: RegExpExecArray | null
- let i = 0
- while ((match = regex.exec(text))) {
- if (match.index > last) nodes.push(text.slice(last, match.index))
- const token = match[0]
- if (token.startsWith('**')) {
- nodes.push(
-
- {token.slice(2, -2)}
-
- )
- } else if (token.startsWith('[')) {
- nodes.push(
-
- {token.slice(1, token.indexOf(']'))}
-
- )
- } else {
- nodes.push(
-
- {token.slice(1, -1)}
-
- )
+const mdComponents: Components = {
+ p({ children }) {
+ return {children}
+ },
+ h1({ children }) {
+ return {children}
+ },
+ h2({ children }) {
+ return {children}
+ },
+ h3({ children }) {
+ return {children}
+ },
+ h4({ children }) {
+ return {children}
+ },
+ h5: ({ children }) => {children}
,
+ h6: ({ children }) => {children}
,
+ ul({ children }) {
+ return
+ },
+ ol({ children }) {
+ return {children}
+ },
+ li({ children }) {
+ return {children}
+ },
+ blockquote({ children }) {
+ return (
+
+ {children}
+
+ )
+ },
+ table({ children }) {
+ return (
+
+ )
+ },
+ thead({ children }) {
+ return {children}
+ },
+ th({ children, style }) {
+ return (
+
+ {children}
+ |
+ )
+ },
+ td({ children, style }) {
+ return (
+
+ {children}
+ |
+ )
+ },
+ tr({ children }) {
+ return {children}
+ },
+ code({ className, children }) {
+ if (typeof className === 'string' && className.includes('language-')) {
+ // 围栏代码块正常由 llm-ui 拆走;这里兜底缩进式/未识别的块级代码
+ return {children}
}
- last = regex.lastIndex
- i += 1
+ return (
+ {children}
+ )
+ },
+ pre({ children }) {
+ return (
+ {children}
+ )
+ },
+ a({ children, href }) {
+ return (
+
+ {children}
+
+ )
+ },
+ strong({ children }) {
+ return {children}
+ },
+ del({ children }) {
+ return {children}
+ },
+ img({ src, alt }) {
+ return
+ },
+ hr() {
+ return
}
- if (last < text.length) nodes.push(text.slice(last))
- return nodes
}
-const INLINE = /^(\s*)([-*+]\s+|\d+[.)]\s+|>\s?|(#{1,3}\s+))?(.*)$/
+const MarkdownComponent: LLMOutputComponent = ({ blockMatch }) => (
+
+
+ {blockMatch.output}
+
+
+)
-interface Block {
- kind: 'p' | 'h1' | 'h2' | 'h3' | 'ul' | 'ol' | 'quote'
- lines: string[]
-}
+const CodeBlockComponent: LLMOutputComponent = ({ blockMatch }) => {
+ const parse = blockMatch.isComplete ? parseCompleteMarkdownCodeBlock : parsePartialMarkdownCodeBlock
+ const { language, code } = parse(blockMatch.output)
+ const source = code ?? ''
+ const rawLang = (language ?? '').toLowerCase()
+ const shikiLang = LANG_ALIAS[rawLang] ?? (HIGHLIGHT_LANGS.includes(rawLang) ? rawLang : '')
-/** 把普通文本切成块级结构(段落/标题/列表/引用) */
-function parseBlocks(text: string): Block[] {
- const blocks: Block[] = []
- const lines = text.split('\n')
- let current: Block | null = null
-
- const flush = (): void => {
- if (current) blocks.push(current)
- current = null
- }
-
- for (const line of lines) {
- const m = line.match(INLINE)
- if (!m) continue
- const [, indent, marker, content] = m
- const isListItem = Boolean(marker && /^([-*+]\s+|\d+[.)]\s+)/.test(marker))
- const kind: Block['kind'] | null = marker
- ? marker.startsWith('#')
- ? marker.trim().length === 2 && marker.startsWith('# ')
- ? 'h1'
- : marker.trim().length === 3 && marker.startsWith('## ')
- ? 'h2'
- : marker.trim().length === 4 && marker.startsWith('### ')
- ? 'h3'
- : null
- : isListItem
- ? /^\d/.test(marker)
- ? 'ol'
- : 'ul'
- : marker.startsWith('>')
- ? 'quote'
- : null
- : 'p'
-
- if (kind === null) {
- if (!current || current.kind !== 'p') flush()
- current = current ?? { kind: 'p', lines: [] }
- current.lines.push(line)
- continue
+ const [tokens, setTokens] = useState(null)
+ useEffect(() => {
+ if (!source.trim() || !shikiLang) {
+ setTokens(null)
+ return
}
-
- if (kind === 'p') {
- if (!current || current.kind !== 'p') {
- flush()
- current = { kind: 'p', lines: [] }
- }
- current.lines.push(content)
- continue
- }
-
- const stripped = marker?.startsWith('>') ? content : content
- if (!current || current.kind !== kind) {
- flush()
- current = { kind, lines: [stripped ?? ''] }
- } else {
- current.lines.push(stripped ?? '')
- }
- void indent
- }
- flush()
- return blocks
-}
-
-function BlockView({ block, k }: { block: Block; k: number }): ReactNode {
- switch (block.kind) {
- case 'h1':
- return (
-
- {renderInline(block.lines.join(' '), `h${k}`)}
-
- )
- case 'h2':
- return (
-
- {renderInline(block.lines.join(' '), `h${k}`)}
-
- )
- case 'h3':
- return (
-
- {renderInline(block.lines.join(' '), `h${k}`)}
-
- )
- case 'ul':
- return (
-
- {block.lines.map((line, i) => (
- -
- {renderInline(line, `ul${k}-${i}`)}
-
- ))}
-
- )
- case 'ol':
- return (
-
- {block.lines.map((line, i) => (
- -
- {renderInline(line, `ol${k}-${i}`)}
-
- ))}
-
- )
- case 'quote':
- return (
-
- {renderInline(block.lines.join('\n'), `q${k}`)}
-
- )
- default:
- return (
-
- {renderInline(block.lines.join('\n'), `p${k}`)}
-
- )
- }
-}
-
-export function Markdown({ text }: { text: string }): ReactNode {
- const parts = splitFences(text)
- return (
-
- {parts.map((part, i) => {
- if (part.type === 'code') {
- return (
-
- {part.block.lang && (
-
{part.block.lang}
- )}
-
- {part.block.code.replace(/\n$/, '')}
-
-
- )
+ let alive = true
+ highlighterPromise
+ .then((highlighter) => {
+ if (!alive) return
+ try {
+ const result = highlighter.codeToTokens(source, { lang: shikiLang, theme: 'github-light' })
+ setTokens(result.tokens)
+ } catch {
+ setTokens(null)
}
- const trimmed = part.text.replace(/^\n+|\n+$/g, '')
- if (!trimmed) return null
- return (
-
- {parseBlocks(trimmed).map((block, j) => (
-
- ))}
-
- )
- })}
+ })
+ .catch(() => undefined)
+ return () => {
+ alive = false
+ }
+ }, [source, shikiLang])
+
+ return (
+
+
)
}
+
+// 匹配器在模块级创建一次,避免每条消息/每帧重建
+const markdownFallback: LLMOutputFallbackBlock = {
+ component: MarkdownComponent,
+ lookBack: markdownLookBack()
+}
+
+const codeBlock: LLMOutputBlock = {
+ findPartialMatch: findPartialCodeBlock(),
+ findCompleteMatch: findCompleteCodeBlock(),
+ component: CodeBlockComponent,
+ lookBack: codeBlockLookBack()
+}
+
+function BlockView({ blockMatch }: { blockMatch: BlockMatch }): ReactNode {
+ const Component = blockMatch.block.component
+ return
+}
+
+export function Markdown({ text }: { text: string }): ReactNode {
+ const { blockMatches } = useLLMOutput({
+ llmOutput: text,
+ isStreamFinished: true,
+ fallbackBlock: markdownFallback,
+ blocks: [codeBlock]
+ })
+ if (!text.trim()) return null
+ return (
+ <>
+ {blockMatches.map((blockMatch, i) => (
+
+ ))}
+ >
+ )
+}
diff --git a/apps/desktop/src/renderer/src/components/MessageItem.tsx b/apps/desktop/src/renderer/src/components/MessageItem.tsx
index d69333e..252090e 100644
--- a/apps/desktop/src/renderer/src/components/MessageItem.tsx
+++ b/apps/desktop/src/renderer/src/components/MessageItem.tsx
@@ -1,13 +1,17 @@
-import { useState } from 'react'
-import type { JSX } from 'react'
-import { Brain, ChevronRight } from 'lucide-react'
+import { useEffect, useRef, useState, type ReactNode } from 'react'
import type { ApprovalRequest, ToolState, UiMessage } from '../hooks/useAgent'
import { Markdown } from './Markdown'
-import { ToolCard, ToolPendingApproval, useEntrance } from './ToolCard'
+import { Loading } from './Loading'
+import { StreamingText } from './StreamingText'
+import { Thinking, thinkingRows } from './Thinking'
+import { ToolChips, type ToolCallView } from './ToolChips'
+import { useEntrance } from '../hooks/useEntrance'
interface ContentBlock {
type?: string
text?: string
+ /** pi 的 thinking 块字段(thinkingSignature 标记来源) */
+ thinking?: string
id?: string
name?: string
arguments?: unknown
@@ -24,94 +28,177 @@ function readBlocks(raw: unknown): ContentBlock[] {
return []
}
-function ThinkingBlock({ text, streaming }: { text: string; streaming: boolean }): JSX.Element | null {
- const [open, setOpen] = useState(false)
- if (!text.trim()) return null
- return (
-
-
-
-
- )
+/** 工作计时:working=true 起表,结束后冻结读数 */
+function useWorkingSeconds(working: boolean): string {
+ const startRef = useRef
(null)
+ const [finalSecs, setFinalSecs] = useState(null)
+ const [, tick] = useState(0)
+
+ useEffect(() => {
+ if (!working) return
+ if (startRef.current === null) startRef.current = Date.now()
+ const timer = setInterval(() => tick((n) => n + 1), 1000)
+ return () => clearInterval(timer)
+ }, [working])
+
+ useEffect(() => {
+ if (!working && startRef.current !== null && finalSecs === null) {
+ setFinalSecs(Math.max(1, Math.round((Date.now() - startRef.current) / 1000)))
+ }
+ }, [working, finalSecs])
+
+ const secs =
+ finalSecs !== null
+ ? finalSecs
+ : startRef.current !== null
+ ? Math.max(1, Math.round((Date.now() - startRef.current) / 1000))
+ : null
+ if (secs === null) return ''
+ if (secs < 60) return `已工作 ${secs} 秒`
+ return `已工作 ${Math.floor(secs / 60)} 分 ${secs % 60} 秒`
}
-function AssistantBody({
- message,
+/** 一轮助手回复 = 连续的 assistant 消息(pi 每个工具轮次一条消息)。
+ * 过程可折叠:头部为工作计时/调用统计(ZCode 式),收起只留头部 + 正文,
+ * 展开按时间顺序铺出全部思考/工具行;流式期间自动展开,结束自动收起。 */
+export function AssistantTurn({
+ messages,
tools,
streaming,
pendingApprovals
}: {
- message: UiMessage
+ messages: UiMessage[]
tools: Record
streaming: boolean
pendingApprovals: ApprovalRequest[]
-}): JSX.Element {
- if (message.stream) {
- return (
-
-
- {message.stream.text ? (
-
-
- {streaming && }
-
- ) : streaming && !message.stream.thinking ? (
-
-
-
-
-
- ) : null}
+}): ReactNode {
+ const units: ReactNode[] = []
+ const textUnits: ReactNode[] = []
+ const labelCount = new Map
()
+ let calls = 0
+ let thinking = 0
+ let callsAndThinking: ToolCallView[] = []
+ let thinkingTexts: string[] = []
+ let stream: { text: string; thinking: string } | null = null
+
+ /** 连续的思考/工具行作为一组裸行铺出(组内保留顺序),不缩进 */
+ const flushRows = (): void => {
+ if (!callsAndThinking.length && !thinkingTexts.length) return
+ const group = { calls: callsAndThinking, thinkingTexts }
+ callsAndThinking = []
+ thinkingTexts = []
+ units.push(
+
+
)
}
- const blocks = readBlocks(message.raw)
- return (
-
- {blocks.map((block, i) => {
- if (block.type === 'thinking' && typeof block.text === 'string') {
- return
- }
- if (block.type === 'text' && block.text) {
- return (
-
-
-
- )
- }
- if (block.type === 'toolCall' && block.id) {
- const tool = tools[block.id]
- if (tool) return
- const pending = pendingApprovals.find((a) => a.toolName === block.name)
+ for (const message of messages) {
+ if (message.stream) {
+ stream = message.stream
+ continue
+ }
+ for (const block of readBlocks(message.raw)) {
+ const thinkingText = block.type === 'thinking' ? (block.text ?? block.thinking) : undefined
+ if (block.type === 'thinking' && typeof thinkingText === 'string' && thinkingText.trim()) {
+ thinkingTexts.push(thinkingText)
+ thinking += 1
+ continue
+ }
+ if (block.type === 'text' && block.text) {
+ flushRows()
+ const textNode = (
+
+
+
+ )
+ units.push(textNode)
+ textUnits.push(textNode)
+ continue
+ }
+ if (block.type === 'toolCall') {
+ const name = block.name ?? 'tool'
+ const tool = block.id ? tools[block.id] : undefined
+ if (!tool) {
+ const pending = pendingApprovals.find((a) => a.toolName === name)
if (pending) {
- return
+ callsAndThinking.push({ id: block.id, name, pendingApproval: { args: pending.args } })
+ calls += 1
+ continue
}
- return (
-
- {block.name ?? 'tool'}
-
- )
}
- return null
- })}
+ const dup = labelCount.get(name) ?? 0
+ labelCount.set(name, dup + 1)
+ callsAndThinking.push({ id: block.id, name, tool })
+ calls += 1
+ }
+ }
+ }
+ flushRows()
+
+ const hasProcess = calls > 0 || thinking > 0
+
+ // 折叠状态:历史回合默认收起,流式回合默认展开;结束自动收起,可手动切换
+ const [collapsed, setCollapsed] = useState
(() => !streaming && hasProcess)
+ const prevStreaming = useRef(streaming)
+ useEffect(() => {
+ if (prevStreaming.current && !streaming) setCollapsed(true)
+ prevStreaming.current = streaming
+ }, [streaming])
+
+ const workedFor = useWorkingSeconds(streaming)
+ const headerLabel = workedFor || `${calls} 次工具调用${thinking ? `,${thinking} 条消息` : ''}`
+ const streamRows = stream ? thinkingRows(stream.thinking) : []
+
+ return (
+
+ {hasProcess && (
+
+
+
+ )}
+
+ {/* 展开态:过程行(缩进)+ 正文按时间交错;收起态:只保留正文(不缩进) */}
+ {!collapsed || !hasProcess ? units : textUnits}
+
+ {stream && (
+
+ {streamRows.length > 0 && (
+
+
+
+ )}
+ {stream.text ? (
+
+
+
+ ) : streamRows.length === 0 ? (
+
+ ) : null}
+
+ )}
)
}
@@ -126,7 +213,7 @@ export function MessageItem({
tools: Record
streaming: boolean
pendingApprovals?: ApprovalRequest[]
-}): JSX.Element | null {
+}): ReactNode | null {
const ref = useEntrance()
if (message.role === 'user') {
@@ -134,7 +221,7 @@ export function MessageItem({
const text = blocks.map((b) => b.text ?? '').join('') || ''
return (
-
@@ -143,8 +230,8 @@ export function MessageItem({
if (message.role === 'assistant') {
return (
-
-
+
)
}
diff --git a/apps/desktop/src/renderer/src/components/Search.tsx b/apps/desktop/src/renderer/src/components/Search.tsx
new file mode 100644
index 0000000..2cb0235
--- /dev/null
+++ b/apps/desktop/src/renderer/src/components/Search.tsx
@@ -0,0 +1,193 @@
+import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
+import { createPortal } from 'react-dom'
+import { LuMessageSquare, LuPlus, LuRefreshCw, LuSettings } from 'react-icons/lu'
+import { GlideMenu } from '@/components/GlideMenu'
+import type { SessionMeta } from '@shared/types'
+
+/** 命令搜索面板(beautifului.dev Search)。
+ * 输入即时过滤;↑↓ 移动、Enter 执行、Esc 关闭;无结果显示空态。 */
+
+export interface SearchAction {
+ id: string
+ label: string
+ icon: ReactNode
+ run: () => void
+}
+
+function defaultActions(onNewSession: () => void, onRefreshSessions: () => void, onOpenSettings: () => void): SearchAction[] {
+ return [
+ { id: 'new', label: '新会话', icon:
, run: onNewSession },
+ { id: 'refresh', label: '刷新会话列表', icon:
, run: onRefreshSessions },
+ { id: 'settings', label: '打开设置', icon:
, run: onOpenSettings }
+ ]
+}
+
+function sessionLabel(s: SessionMeta): string {
+ if (s.name?.trim()) return s.name.trim()
+ const first = s.firstMessage?.trim()
+ return first ? (first.length > 48 ? `${first.slice(0, 48)}…` : first) : '(空会话)'
+}
+
+export function Search({
+ open,
+ onClose,
+ sessions,
+ onNewSession,
+ onRefreshSessions,
+ onOpenSettings,
+ onOpenSession
+}: {
+ open: boolean
+ onClose: () => void
+ sessions: SessionMeta[]
+ onNewSession: () => void
+ onRefreshSessions: () => void
+ onOpenSettings: () => void
+ onOpenSession: (path: string) => void
+}): ReactNode {
+ const [query, setQuery] = useState('')
+ const [active, setActive] = useState(0)
+ const inputRef = useRef
(null)
+ const listRef = useRef(null)
+
+ const actions = useMemo(
+ () => defaultActions(onNewSession, onRefreshSessions, onOpenSettings),
+ [onNewSession, onRefreshSessions, onOpenSettings]
+ )
+
+ const q = query.trim().toLowerCase()
+ const filteredActions = q ? actions.filter((a) => a.label.toLowerCase().includes(q)) : actions
+ const filteredSessions = q
+ ? sessions.filter((s) => sessionLabel(s).toLowerCase().includes(q) || s.firstMessage?.toLowerCase().includes(q))
+ : sessions
+
+ const entries: ({ kind: 'action'; item: SearchAction } | { kind: 'session'; item: SessionMeta })[] = [
+ ...filteredActions.map((item) => ({ kind: 'action' as const, item })),
+ ...filteredSessions.map((item) => ({ kind: 'session' as const, item }))
+ ]
+
+ useEffect(() => {
+ if (open) {
+ setQuery('')
+ setActive(0)
+ requestAnimationFrame(() => inputRef.current?.focus())
+ }
+ }, [open])
+
+ useEffect(() => setActive(0), [query])
+
+ // 键盘导航
+ const onKey = (e: React.KeyboardEvent): void => {
+ if (e.key === 'ArrowDown') {
+ e.preventDefault()
+ setActive((i) => Math.min(i + 1, entries.length - 1))
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault()
+ setActive((i) => Math.max(i - 1, 0))
+ } else if (e.key === 'Enter') {
+ e.preventDefault()
+ const entry = entries[active]
+ if (!entry) return
+ if (entry.kind === 'action') entry.item.run()
+ else onOpenSession(entry.item.path)
+ onClose()
+ } else if (e.key === 'Escape') {
+ e.preventDefault()
+ onClose()
+ }
+ }
+
+ // 活动行滚动进视野
+ useEffect(() => {
+ const row = listRef.current?.querySelector(`[data-idx="${active}"]`)
+ row?.scrollIntoView({ block: 'nearest' })
+ }, [active])
+
+ if (!open) return null
+
+ return createPortal(
+
+
e.stopPropagation()}>
+
+ {/* 输入行 */}
+
+
+
setQuery(e.target.value)}
+ onKeyDown={onKey}
+ placeholder="搜索会话或命令…"
+ aria-label="搜索会话或命令"
+ className="min-w-0 flex-1 bg-transparent text-[13px] text-ink outline-none placeholder:text-ink-3"
+ />
+
Esc
+
+
+ {/* 结果列表(glide hover + 键盘活动态) */}
+
+ {entries.length === 0 ? (
+
+
+
没有匹配的结果
+
试试其他关键词
+
+ ) : (
+
+ {entries.map((entry, i) => {
+ const isActive = i === active
+ const label = entry.kind === 'action' ? entry.item.label : sessionLabel(entry.item)
+ const icon =
+ entry.kind === 'action' ? (
+ entry.item.icon
+ ) : (
+
+ )
+ return (
+
+ )
+ })}
+
+ )}
+
+
+ {/* 底部提示 */}
+
+ ↑↓ 选择
+ Enter 执行
+ {sessions.length} 个会话
+
+
+
+
,
+ document.body
+ )
+}
diff --git a/apps/desktop/src/renderer/src/components/SelectionActions.tsx b/apps/desktop/src/renderer/src/components/SelectionActions.tsx
new file mode 100644
index 0000000..2a46049
--- /dev/null
+++ b/apps/desktop/src/renderer/src/components/SelectionActions.tsx
@@ -0,0 +1,203 @@
+import { useEffect, useRef, useState, type ReactNode, type RefObject } from 'react'
+import { LuCircleHelp, LuScissors, LuSmile, LuSpellCheck, LuWandSparkles } from 'react-icons/lu'
+
+/** 选区操作条(beautifului.dev Selection Actions)。
+ * 在 containerRef 内划选文本后,于选区上方浮出胶囊工具条:
+ * 「描述修改」输入框 + 解释/改进,更多按钮展开 精简/语气/语法;
+ * 输入内容后出现发送钮。预设动作与自定义指令都经 onApply 交给上层。 */
+
+const PRESET_MAIN: { label: string; icon: ReactNode }[] = [
+ { label: '解释', icon: },
+ { label: '改进', icon: }
+]
+
+const PRESET_MORE: { label: string; icon: ReactNode }[] = [
+ { label: '精简', icon: },
+ { label: '语气', icon: },
+ { label: '语法', icon: }
+]
+
+const pillButton =
+ 'inline-flex items-center justify-center select-none transition-[transform,background-color,opacity] duration-150 ease-out active:scale-[0.96] disabled:opacity-50 disabled:pointer-events-none text-ink hover:bg-hover h-7 rounded-full px-2.5 text-[12px] font-normal leading-none gap-1 shrink-0'
+
+export function SelectionActions({
+ containerRef,
+ onApply
+}: {
+ containerRef: RefObject
+ onApply: (instruction: string, selection: string) => void
+}): ReactNode {
+ const [anchor, setAnchor] = useState<{ x: number; y: number } | null>(null)
+ const [selection, setSelection] = useState('')
+ const [instruction, setInstruction] = useState('')
+ const [moreOpen, setMoreOpen] = useState(false)
+ const pillRef = useRef(null)
+
+ const dismiss = (): void => {
+ setAnchor(null)
+ setSelection('')
+ setInstruction('')
+ setMoreOpen(false)
+ }
+
+ // 划选/键盘选词后定位工具条(选区需落在容器内;滚动时收起)
+ useEffect(() => {
+ const read = (): void => {
+ const container = containerRef.current
+ if (!container) return
+ const sel = window.getSelection()
+ if (!sel || sel.isCollapsed || sel.rangeCount === 0) {
+ setAnchor(null)
+ setSelection('')
+ return
+ }
+ const range = sel.getRangeAt(0)
+ const text = sel.toString().replace(/\s+/g, ' ').trim()
+ if (!text || text.length < 2 || !container.contains(range.commonAncestorContainer)) {
+ setAnchor(null)
+ setSelection('')
+ return
+ }
+ const rect = range.getBoundingClientRect()
+ const containerRect = container.getBoundingClientRect()
+ if (rect.width < 4) return
+ const x = Math.min(Math.max(rect.left + rect.width / 2 - containerRect.left, 130), Math.max(containerRect.width - 130, 130))
+ const y = Math.max(rect.top - containerRect.top - 46, 4)
+ setAnchor({ x, y })
+ setSelection(text)
+ }
+ const onUp = (): void => {
+ setTimeout(read, 0)
+ }
+ document.addEventListener('mouseup', onUp)
+ document.addEventListener('keyup', onUp)
+ const onScroll = (event: Event): void => {
+ if (containerRef.current && (event.target === containerRef.current || containerRef.current.contains(event.target as Node))) {
+ setAnchor(null)
+ }
+ }
+ document.addEventListener('scroll', onScroll, true)
+ return () => {
+ document.removeEventListener('mouseup', onUp)
+ document.removeEventListener('keyup', onUp)
+ document.removeEventListener('scroll', onScroll, true)
+ }
+ }, [containerRef])
+
+ // Esc 收起
+ useEffect(() => {
+ const onKey = (e: KeyboardEvent): void => {
+ if (e.key === 'Escape' && anchor) dismiss()
+ }
+ window.addEventListener('keydown', onKey)
+ return () => window.removeEventListener('keydown', onKey)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [anchor])
+
+ const apply = (label: string): void => {
+ if (!selection) return
+ onApply(label, selection)
+ dismiss()
+ window.getSelection()?.removeAllRanges()
+ }
+
+ const submitInstruction = (): void => {
+ const text = instruction.trim()
+ if (!text || !selection) return
+ onApply(`${text}(针对以下选中内容)`, selection)
+ dismiss()
+ window.getSelection()?.removeAllRanges()
+ }
+
+ const visible = anchor !== null
+
+ return (
+
+
+ {/* 描述修改输入框(常显,站点默认态) */}
+
+
+
+
+ {/* 分隔线 */}
+
+
+ {/* 预设动作 + 更多 */}
+
+ {PRESET_MAIN.map((action) => (
+
+ ))}
+
+ {PRESET_MORE.map((action) => (
+
+ ))}
+
+
+
+
+
+ {/* 发送钮:输入内容后出现 */}
+
+
+
+ )
+}
diff --git a/apps/desktop/src/renderer/src/components/SettingsModal.tsx b/apps/desktop/src/renderer/src/components/SettingsModal.tsx
index 71660ce..d92c5fd 100644
--- a/apps/desktop/src/renderer/src/components/SettingsModal.tsx
+++ b/apps/desktop/src/renderer/src/components/SettingsModal.tsx
@@ -1,18 +1,88 @@
-import { useEffect, useMemo, useState } from 'react'
+import { useEffect, useMemo, useState, type ReactNode } from 'react'
import type { JSX } from 'react'
-import { Check, Cpu, KeyRound, LogOut, Search, Sparkles, X } from 'lucide-react'
+import {
+ LuBot,
+ LuBrain,
+ LuCheck,
+ LuCpu,
+ LuKeyRound,
+ LuLogOut,
+ LuPuzzle,
+ LuSearch,
+ LuServer,
+ LuSettings2,
+ LuSparkles,
+ LuTerminal,
+ LuWebhook,
+ LuX
+} from 'react-icons/lu'
+import type { IconType } from 'react-icons'
import type { ModelCatalog, ProviderCatalogEntry, SessionState } from '@shared/types'
-type Tab = 'model' | 'prompt'
+type Tab =
+ | 'general'
+ | 'model'
+ | 'memory'
+ | 'subagent'
+ | 'plugins'
+ | 'mcp'
+ | 'skills'
+ | 'commands'
+ | 'hooks'
+
+/** 左侧分组导航(参考 ZCode 设置页:基础设置 / Agent 能力) */
+const NAV: Array<{ group: string; items: Array<{ id: Tab; label: string; icon: IconType }> }> = [
+ {
+ group: '基础设置',
+ items: [
+ { id: 'general', label: '常规', icon: LuSettings2 },
+ { id: 'model', label: '模型设置', icon: LuCpu }
+ ]
+ },
+ {
+ group: 'Agent 能力',
+ items: [
+ { id: 'memory', label: '记忆', icon: LuBrain },
+ { id: 'subagent', label: '子智能体', icon: LuBot },
+ { id: 'plugins', label: '插件', icon: LuPuzzle },
+ { id: 'mcp', label: 'MCP 服务器', icon: LuServer },
+ { id: 'skills', label: '技能', icon: LuSparkles },
+ { id: 'commands', label: '命令', icon: LuTerminal },
+ { id: 'hooks', label: '钩子', icon: LuWebhook }
+ ]
+ }
+]
+
+const PLACEHOLDER: Record = {
+ memory: '让 Agent 在会话之间记住偏好与事实,越用越懂你的店铺。',
+ subagent: '为主流程配置可调度的子智能体,分头完成检索、写作与校验。',
+ plugins: '安装扩展插件,为 Agent 增加新的工具与界面能力。',
+ mcp: '接入 MCP 服务器,把外部工具与数据源挂载到 Agent。',
+ skills: '管理可复用的技能包(SOP),让 Agent 按流程执行任务。',
+ commands: '自定义斜杠命令,把常用操作变成一键指令。',
+ hooks: '在会话生命周期的关键节点上挂载自动化钩子。'
+}
+
+const TAB_META: Record = {
+ general: { title: '常规', desc: '管理 Agent 的系统提示词与基础行为。' },
+ model: { title: '模型设置', desc: '管理模型供应商与密钥,配置后可在聊天时选择使用。' },
+ memory: { title: '记忆', desc: PLACEHOLDER.memory },
+ subagent: { title: '子智能体', desc: PLACEHOLDER.subagent },
+ plugins: { title: '插件', desc: PLACEHOLDER.plugins },
+ mcp: { title: 'MCP 服务器', desc: PLACEHOLDER.mcp },
+ skills: { title: '技能', desc: PLACEHOLDER.skills },
+ commands: { title: '命令', desc: PLACEHOLDER.commands },
+ hooks: { title: '钩子', desc: PLACEHOLDER.hooks }
+}
function ProviderBadge({ provider }: { provider: ProviderCatalogEntry }): JSX.Element {
if (provider.hasApiKey) {
- return API Key
+ return API Key
}
if (provider.isOAuth) {
- return OAuth
+ return OAuth
}
- return 未配置
+ return 未配置
}
function formatContext(n: number): string {
@@ -21,6 +91,18 @@ function formatContext(n: number): string {
return String(n)
}
+function ComingSoon({ icon: Icon, label }: { icon: IconType; label: string }): ReactNode {
+ return (
+
+
+
+
+
{label}
+
该能力正在建设中,敬请期待
+
+ )
+}
+
export function SettingsModal({
open,
sessionState,
@@ -102,224 +184,243 @@ export function SettingsModal({
if (!open) return null
+ const meta = TAB_META[tab]
+
return (
-
+
e.stopPropagation()}
>
- {/* 头部 */}
-
-
设置
- {(
- [
- ['model', '模型与密钥', Cpu],
- ['prompt', '系统提示词', Sparkles]
- ] as const
- ).map(([id, label, Icon]) => (
+ {/* 左侧:分组导航 */}
+
+
+ {/* 右侧:内容页 */}
+
+
+
+
{meta.title}
+
{meta.desc}
+
+
+
+
+ {tab === 'general' && (
+
+
+ )}
+
+ {tab === 'model' && (
+
+
+
+
+ {filtered.map((provider) => (
+
+
+
{provider.name}
+
+
+ {(provider.hasApiKey || provider.isOAuth) && (
+
+ )}
+
+
+ {keyProvider === provider.id && (
+
+ setKeyValue(e.target.value)}
+ placeholder="输入 API Key(保存到 ~/.pi/agent/auth.json,与 pi CLI 共享)"
+ className="flex-1 rounded-control border border-zinc-300 bg-white px-2.5 py-1.5 font-mono text-[12px] text-zinc-900 outline-none focus:border-zinc-500"
+ />
+
+
+
+ )}
+
+
+ {provider.models.map((m) => {
+ const selected = provider.id === currentProvider && m.id === currentModel
+ return (
+
+ )
+ })}
+ {provider.models.length === 0 && (
+
配置密钥后显示可用模型
+ )}
+
+
+ ))}
+ {!loading && filtered.length === 0 && (
+
没有匹配的模型
+ )}
+
+
+ )}
+
+ {tab !== 'general' && tab !== 'model' && (
+
i.id === tab)?.icon ?? LuPuzzle} label={meta.title} />
+ )}
+
+
+ {/* 通知条 */}
+ {notice && (
+
-
- {label}
-
- ))}
-
+ {notice.text}
+
+ )}
-
- {/* 内容 */}
- {tab === 'model' ? (
-
-
-
- {filtered.map((provider) => (
-
-
-
{provider.name}
-
-
- {(provider.hasApiKey || provider.isOAuth) && (
-
- )}
-
-
- {keyProvider === provider.id && (
-
- setKeyValue(e.target.value)}
- placeholder="输入 API Key(保存到 ~/.pi/agent/auth.json,与 pi CLI 共享)"
- className="flex-1 rounded-md border border-zinc-800 bg-zinc-950 px-2.5 py-1.5 font-mono text-[12px] text-zinc-200 outline-none focus:border-emerald-800"
- />
-
-
-
- )}
-
-
- {provider.models.map((m) => {
- const selected = provider.id === currentProvider && m.id === currentModel
- return (
-
- )
- })}
- {provider.models.length === 0 && (
-
配置密钥后显示可用模型
- )}
-
-
- ))}
- {!loading && filtered.length === 0 && (
-
没有匹配的模型
- )}
-
-
- ) : (
-
-
- 覆盖 pi 默认系统提示词。留空使用默认(面向编码的提示词)。保存后运行时重建,当前最近会话的历史会保留。
-
-
- )}
-
- {/* 通知条 */}
- {notice && (
-
- {notice.text}
-
- )}
)
diff --git a/apps/desktop/src/renderer/src/components/Sidebar.tsx b/apps/desktop/src/renderer/src/components/Sidebar.tsx
deleted file mode 100644
index 174df49..0000000
--- a/apps/desktop/src/renderer/src/components/Sidebar.tsx
+++ /dev/null
@@ -1,336 +0,0 @@
-import { useEffect, useRef, useState } from 'react'
-import type { JSX } from 'react'
-import { Check, Cpu, MessageSquare, Pencil, RefreshCw, Settings, Store, Trash2, X } from 'lucide-react'
-import type { SessionMeta, SessionState, SessionStatsInfo } from '@shared/types'
-
-interface VersionInfo {
- app: string
- electron: string
- node: string
-}
-
-function formatCost(cost: number): string {
- if (cost >= 1) return `$${cost.toFixed(2)}`
- if (cost > 0) return `$${cost.toFixed(4)}`
- return '$0'
-}
-
-function formatTokens(n: number): string {
- if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`
- if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
- return String(n)
-}
-
-function formatModified(iso: string): string {
- const d = new Date(iso)
- const now = new Date()
- const sameDay = d.toDateString() === now.toDateString()
- if (sameDay) {
- return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
- }
- const yesterday = new Date(now.getTime() - 86400000)
- if (d.toDateString() === yesterday.toDateString()) return '昨天'
- if (d.getFullYear() === now.getFullYear()) return `${d.getMonth() + 1}月${d.getDate()}日`
- return `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`
-}
-
-function sessionTitle(s: SessionMeta): string {
- if (s.name?.trim()) return s.name.trim()
- const first = s.firstMessage?.trim()
- if (first) return first.length > 36 ? `${first.slice(0, 36)}…` : first
- return '(空会话)'
-}
-
-function SessionRow({
- session,
- active,
- onOpen,
- onRename,
- onDelete
-}: {
- session: SessionMeta
- active: boolean
- onOpen: () => void
- onRename: () => void
- onDelete: () => void
-}): JSX.Element {
- return (
-
- {active &&
}
-
-
-
{sessionTitle(session)}
-
- {formatModified(session.modified)} · {session.messageCount} 条
-
-
-
- {active && (
-
- )}
-
-
-
- )
-}
-
-export function Sidebar({
- sessionState,
- stats,
- connected,
- sessions,
- onNewSession,
- onOpenSession,
- onRenameSession,
- onDeleteSession,
- onOpenSettings,
- onRefreshSessions
-}: {
- sessionState: SessionState | null
- stats: SessionStatsInfo | null
- connected: boolean
- sessions: SessionMeta[]
- onNewSession: () => void
- onOpenSession: (path: string) => void
- onRenameSession: (name: string) => void
- onDeleteSession: (path: string) => void
- onOpenSettings: () => void
- onRefreshSessions: () => void
-}): JSX.Element {
- const [versions, setVersions] = useState
(null)
- const [renaming, setRenaming] = useState(false)
- const [renameValue, setRenameValue] = useState('')
- const [confirmDelete, setConfirmDelete] = useState(null)
- const renameInputRef = useRef(null)
- const entranceRef = useRef(null)
-
- useEffect(() => {
- void window.api.versions().then(setVersions).catch(() => setVersions(null))
- }, [])
-
- useEffect(() => {
- const el = entranceRef.current
- if (!el) return
- let tween: { kill(): void } | null = null
- let cancelled = false
- void import('gsap').then((gsapModule) => {
- if (cancelled) return
- tween = gsapModule.gsap.fromTo(
- el,
- { opacity: 0, x: -12 },
- { opacity: 1, x: 0, duration: 0.35, ease: 'power2.out' }
- )
- })
- return () => {
- cancelled = true
- tween?.kill()
- }
- }, [])
-
- useEffect(() => {
- if (renaming) renameInputRef.current?.focus()
- }, [renaming])
-
- const submitRename = (): void => {
- const name = renameValue.trim()
- if (name) onRenameSession(name)
- setRenaming(false)
- }
-
- const activeSessionId = sessionState?.sessionId
-
- return (
-
- )
-}
diff --git a/apps/desktop/src/renderer/src/components/SidebarNav.tsx b/apps/desktop/src/renderer/src/components/SidebarNav.tsx
new file mode 100644
index 0000000..df21204
--- /dev/null
+++ b/apps/desktop/src/renderer/src/components/SidebarNav.tsx
@@ -0,0 +1,528 @@
+import { useEffect, useRef, useState, type ReactNode } from 'react'
+import { createPortal } from 'react-dom'
+import {
+ LuCheck,
+ LuChevronDown,
+ LuPencil,
+ LuPlus,
+ LuSearch,
+ LuSettings,
+ LuSquarePen,
+ LuTrash2,
+ LuX
+} from 'react-icons/lu'
+import { LuPanelLeft } from 'react-icons/lu'
+import { Button } from '@/components/atoms/Button'
+import { GlideMenu } from '@/components/GlideMenu'
+import type { SessionMeta, SessionState, WorkspaceMeta } from '@shared/types'
+
+/** 可折叠工作区导航(beautifului.dev Sidebar Nav)。
+ * 224px ↔ 56px 宽度过渡,文案随折叠淡出;导航行与会话行共用一个
+ * 滑移高亮层(glide hover);会话区支持内联搜索即时过滤。 */
+
+const EXPANDED_W = 224
+const COLLAPSED_W = 52
+const COLLAPSE_KEY = 'aso-sidebar-collapsed'
+
+function sessionTitle(s: SessionMeta): string {
+ if (s.name?.trim()) return s.name.trim()
+ const first = s.firstMessage?.trim()
+ if (first) return first.length > 36 ? `${first.slice(0, 36)}…` : first
+ return '(空会话)'
+}
+
+/* 官方 RailButton 形态:行宽由 .sidebar-row 固定(展开 208 / 收起 36),
+ * 收起时图标恰好落在 52px 轨道正中。 */
+const navRowClass =
+ 'sidebar-row relative z-10 mx-2 flex h-8 items-center rounded-[8px] px-2 text-left transition-[width,background-color,color,transform] duration-150 active:scale-[0.98] [-webkit-app-region:no-drag]'
+
+/** 行内操作浮层:portal 到 body,锚定在触发按钮下方,点外部关闭。
+ * 形态与官方 WorkspaceMenu 一致(rounded-[14px] + pop-in + shadow-overlay)。 */
+function RowActionPortal({
+ rect,
+ onClose,
+ children
+}: {
+ rect: { top: number; left: number }
+ onClose: () => void
+ children: ReactNode
+}): ReactNode {
+ const closeRef = useRef(onClose)
+ closeRef.current = onClose
+ useEffect(() => {
+ const close = (event: PointerEvent): void => {
+ const target = event.target as Element
+ if (!target.closest('[data-row-menu]')) closeRef.current()
+ }
+ document.addEventListener('pointerdown', close)
+ return () => document.removeEventListener('pointerdown', close)
+ }, [])
+
+ // 视口内收拢:贴近底部时向上展开,横向不出界
+ const top = Math.min(rect.top + 6, window.innerHeight - 176)
+ const left = Math.max(8, Math.min(rect.left, window.innerWidth - 256))
+ return createPortal(
+
+ {children}
+
,
+ document.body
+ )
+}
+
+export function SidebarNav({
+ sessionState,
+ sessions,
+ onNewSession,
+ onOpenSession,
+ onRenameSession,
+ onDeleteSession,
+ onOpenSearch,
+ onOpenSettings,
+ workspaces,
+ activeWorkspace,
+ onOpenWorkspace
+}: {
+ sessionState: SessionState | null
+ sessions: SessionMeta[]
+ onNewSession: () => void
+ onOpenSession: (path: string) => void
+ onRenameSession: (name: string) => void
+ onDeleteSession: (path: string) => void
+ onOpenSearch: () => void
+ onOpenSettings: () => void
+ workspaces: WorkspaceMeta[]
+ activeWorkspace?: WorkspaceMeta
+ onOpenWorkspace: (path: string) => void
+}): ReactNode {
+ const [collapsed, setCollapsed] = useState(() => {
+ try {
+ return localStorage.getItem(COLLAPSE_KEY) === '1'
+ } catch {
+ return false
+ }
+ })
+ const [chatsOpen, setChatsOpen] = useState(true)
+ const [searchOpen, setSearchOpen] = useState(false)
+ const [query, setQuery] = useState('')
+ /** 行内操作弹出层:锚定在触发按钮旁(官方 WorkspaceMenu 的浮层形态) */
+ const [actionMenu, setActionMenu] = useState<{
+ kind: 'rename' | 'delete'
+ session: SessionMeta
+ rect: { top: number; left: number }
+ } | null>(null)
+ const [renameValue, setRenameValue] = useState('')
+ const [wsMenuOpen, setWsMenuOpen] = useState(false)
+ const [expandedWs, setExpandedWs] = useState>(new Set())
+ const renameInputRef = useRef(null)
+ const searchInputRef = useRef(null)
+
+ useEffect(() => {
+ try {
+ localStorage.setItem(COLLAPSE_KEY, collapsed ? '1' : '0')
+ } catch {
+ /* 忽略持久化失败 */
+ }
+ }, [collapsed])
+
+ useEffect(() => {
+ if (searchOpen) searchInputRef.current?.focus()
+ }, [searchOpen])
+
+ useEffect(() => {
+ if (collapsed) setWsMenuOpen(false)
+ }, [collapsed])
+
+ useEffect(() => {
+ if (actionMenu?.kind === 'rename') renameInputRef.current?.focus()
+ }, [actionMenu])
+
+ const submitRename = (): void => {
+ const name = renameValue.trim()
+ if (name) onRenameSession(name)
+ setActionMenu(null)
+ }
+
+ const activeSessionId = sessionState?.sessionId
+ const q = query.trim().toLowerCase()
+ const filtered = q
+ ? sessions.filter((s) => sessionTitle(s).toLowerCase().includes(q) || s.firstMessage?.toLowerCase().includes(q))
+ : sessions
+
+ return (
+