From 9328c01c1bdd39cd1347a141032c58222df93cea Mon Sep 17 00:00:00 2001 From: Fam Zheng Date: Sun, 24 May 2026 17:16:44 +0100 Subject: [PATCH] =?UTF-8?q?write:=20=E8=BF=9B=20cube=20=E4=BB=93=E5=BA=93?= =?UTF-8?q?=20+=20=E6=8E=A5=20gitea=20CI=20=E8=87=AA=E5=8A=A8=E9=83=A8?= =?UTF-8?q?=E7=BD=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 整 apps/write/ 进 git(含 frontend 源码 + Makefile + systemd unit + k8s service/ingress) - .gitea/workflows/deploy-write.yml: act_runner fam 用户跑 host shell cargo build → npm build → install 到 ~/.local/bin/share/config → systemctl --user daemon-reload + restart → kubectl apply svc/ingress - 前端 3 处"麻薯"字样去掉(思考中 / placeholder × 2) 注意 ~/.config/write/env 已有 passphrase,CI placeholder 逻辑会跳过不覆盖。 --- .gitea/workflows/deploy-write.yml | 63 + apps/write/Cargo.toml | 24 + apps/write/Makefile | 82 ++ apps/write/frontend/index.html | 13 + apps/write/frontend/package-lock.json | 1396 +++++++++++++++++++++ apps/write/frontend/package.json | 20 + apps/write/frontend/public/asr-worklet.js | 45 + apps/write/frontend/src/App.vue | 287 +++++ apps/write/frontend/src/lib/api.ts | 69 + apps/write/frontend/src/lib/asr.ts | 118 ++ apps/write/frontend/src/main.ts | 5 + apps/write/frontend/src/styles.css | 327 +++++ apps/write/frontend/tsconfig.json | 20 + apps/write/frontend/vite.config.ts | 13 + apps/write/k8s/all.yaml | 51 + apps/write/src/asr.rs | 141 +++ apps/write/src/docs.rs | 320 +++++ apps/write/src/main.rs | 165 +++ apps/write/src/state.rs | 17 + apps/write/systemd/write.service | 30 + 20 files changed, 3206 insertions(+) create mode 100644 .gitea/workflows/deploy-write.yml create mode 100644 apps/write/Cargo.toml create mode 100644 apps/write/Makefile create mode 100644 apps/write/frontend/index.html create mode 100644 apps/write/frontend/package-lock.json create mode 100644 apps/write/frontend/package.json create mode 100644 apps/write/frontend/public/asr-worklet.js create mode 100644 apps/write/frontend/src/App.vue create mode 100644 apps/write/frontend/src/lib/api.ts create mode 100644 apps/write/frontend/src/lib/asr.ts create mode 100644 apps/write/frontend/src/main.ts create mode 100644 apps/write/frontend/src/styles.css create mode 100644 apps/write/frontend/tsconfig.json create mode 100644 apps/write/frontend/vite.config.ts create mode 100644 apps/write/k8s/all.yaml create mode 100644 apps/write/src/asr.rs create mode 100644 apps/write/src/docs.rs create mode 100644 apps/write/src/main.rs create mode 100644 apps/write/src/state.rs create mode 100644 apps/write/systemd/write.service diff --git a/.gitea/workflows/deploy-write.yml b/.gitea/workflows/deploy-write.yml new file mode 100644 index 0000000..5fd0e5b --- /dev/null +++ b/.gitea/workflows/deploy-write.yml @@ -0,0 +1,63 @@ +name: deploy write +# write.famzheng.me — host systemd service(不是 k8s pod),act_runner fam 用户直接 cp 本地 + +on: + push: + branches: [master] + paths: + - 'apps/write/**' + - 'crates/cube-core/**' + - 'Cargo.toml' + - 'Cargo.lock' + - '.gitea/workflows/deploy-write.yml' + workflow_dispatch: + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + env: + APP: write + # systemctl --user 需要 runtime dir;fam 已 enable linger + XDG_RUNTIME_DIR: /run/user/1001 + steps: + - uses: actions/checkout@v4 + + - name: Build backend + run: | + export PATH="$HOME/.cargo/bin:$PATH" + cargo build --release -p "$APP" + + - name: Build frontend + run: | + cd "apps/$APP/frontend" + npm ci + npm run build + + - name: Install binary + dist + systemd unit + run: | + mkdir -p \ + "$HOME/.local/bin" \ + "$HOME/.local/share/$APP/dist" \ + "$HOME/.local/state/$APP" \ + "$HOME/.config/$APP" \ + "$HOME/.config/systemd/user" + install -m 755 "target/release/$APP" "$HOME/.local/bin/$APP" + rsync -a --delete "apps/$APP/frontend/dist/" "$HOME/.local/share/$APP/dist/" + install -m 644 "apps/$APP/systemd/$APP.service" "$HOME/.config/systemd/user/$APP.service" + # 首次部署占位 env(已有则不动,避免覆盖 passphrase) + if [ ! -f "$HOME/.config/$APP/env" ]; then + echo "WRITE_PASSPHRASE=CHANGE-ME" > "$HOME/.config/$APP/env" + chmod 600 "$HOME/.config/$APP/env" + echo "⚠ created placeholder ~/.config/$APP/env, edit + restart" + fi + + - name: Reload + restart write.service + run: | + systemctl --user daemon-reload + systemctl --user enable "$APP.service" + systemctl --user restart "$APP.service" + sleep 1 + systemctl --user --no-pager status "$APP.service" | head -15 + + - name: Apply k8s service/ingress + run: kubectl apply -f "apps/$APP/k8s/all.yaml" diff --git a/apps/write/Cargo.toml b/apps/write/Cargo.toml new file mode 100644 index 0000000..6d36f32 --- /dev/null +++ b/apps/write/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "write" +version = "0.1.0" +edition.workspace = true +license.workspace = true +authors.workspace = true +description = "write.famzheng.me — voice/text → claude → markdown doc" + +[dependencies] +cube-core = { path = "../../crates/cube-core" } +axum = { workspace = true, features = ["ws"] } +tokio = { workspace = true } +tower = { workspace = true } +tower-http = { workspace = true } +tracing = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +rusqlite = { workspace = true } +reqwest = { workspace = true } +futures = { workspace = true } +tokio-stream = { workspace = true } +tokio-tungstenite = "0.24" +futures-util = "0.3" +url = "2" diff --git a/apps/write/Makefile b/apps/write/Makefile new file mode 100644 index 0000000..36f1401 --- /dev/null +++ b/apps/write/Makefile @@ -0,0 +1,82 @@ +SHELL := /bin/bash +HOST := famzheng.com +HOST_USER := fam +APP := write + +REMOTE_BIN := /home/$(HOST_USER)/.local/bin/$(APP) +REMOTE_DIST := /home/$(HOST_USER)/.local/share/$(APP)/dist +REMOTE_SYSTEMD := /home/$(HOST_USER)/.config/systemd/user/$(APP).service +REMOTE_ENV := /home/$(HOST_USER)/.config/$(APP)/env +REMOTE_STATE := /home/$(HOST_USER)/.local/state/$(APP) + +.PHONY: build build-fe build-be deploy install enable start stop restart status logs k8s-apply k8s-delete dev dev-fe local-test + +build: build-be build-fe + +build-be: + @cd ../.. && cargo build --release -p $(APP) + +build-fe: + @cd frontend && (test -d node_modules || npm ci) && npm run build + +deploy: build + @echo "==> ensure remote dirs" + @ssh $(HOST) 'mkdir -p $(dir $(REMOTE_BIN)) $(REMOTE_DIST) $(dir $(REMOTE_SYSTEMD)) $(dir $(REMOTE_ENV)) $(REMOTE_STATE)' + @echo "==> ship binary" + @scp ../../target/release/$(APP) $(HOST):$(REMOTE_BIN) + @echo "==> ship frontend dist" + @rsync -a --delete frontend/dist/ $(HOST):$(REMOTE_DIST)/ + @echo "==> ship systemd unit" + @scp systemd/$(APP).service $(HOST):$(REMOTE_SYSTEMD) + @ssh $(HOST) 'systemctl --user daemon-reload' + @echo "==> ensure env file (touch if absent)" + @ssh $(HOST) '[ -f $(REMOTE_ENV) ] || { echo "WRITE_PASSPHRASE=CHANGE-ME" > $(REMOTE_ENV) && chmod 600 $(REMOTE_ENV) && echo "⚠ created placeholder $(REMOTE_ENV) — edit it then run make restart"; }' + @$(MAKE) enable start status + +install: + @$(MAKE) deploy + +enable: + @ssh $(HOST) 'systemctl --user enable $(APP).service' + +start: + @ssh $(HOST) 'systemctl --user start $(APP).service' + +stop: + @ssh $(HOST) 'systemctl --user stop $(APP).service' + +restart: + @ssh $(HOST) 'systemctl --user restart $(APP).service' + +status: + @ssh $(HOST) 'systemctl --user --no-pager status $(APP).service | head -20' + +logs: + @ssh $(HOST) 'tail -n 200 -f $(REMOTE_STATE)/$(APP).log' + +k8s-apply: + @kubectl apply -f k8s/all.yaml + +k8s-delete: + @kubectl delete -f k8s/all.yaml + +# Local dev (runs both backend and frontend with auto-reload). +# Make a config first: ~/.config/write/env (locally) with WRITE_PASSPHRASE=... +dev: + @echo "==> backend on :31391" + @( cd ../.. && cargo run --release -p $(APP) ) & \ + echo "==> frontend on :5173 (vite)"; \ + cd frontend && npm run dev + +dev-fe: + @cd frontend && npm run dev + +# Local end-to-end test (uses ~/.local/share/write paths locally) +local-test: + @mkdir -p ~/.local/share/write/docs ~/.local/state/write + @( cd ../.. && \ + WRITE_PASSPHRASE=test \ + WRITE_DIST_DIR=$$PWD/apps/write/frontend/dist \ + PORT=31391 \ + cargo run --release -p $(APP) ) & + @echo "==> backend running; ctrl-c to stop" diff --git a/apps/write/frontend/index.html b/apps/write/frontend/index.html new file mode 100644 index 0000000..ba25138 --- /dev/null +++ b/apps/write/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + write + + + +
+ + + diff --git a/apps/write/frontend/package-lock.json b/apps/write/frontend/package-lock.json new file mode 100644 index 0000000..7b5cec1 --- /dev/null +++ b/apps/write/frontend/package-lock.json @@ -0,0 +1,1396 @@ +{ + "name": "write", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "write", + "version": "0.1.0", + "dependencies": { + "marked": "^14.0.0", + "vue": "^3.4.21" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.4", + "typescript": "^5.4.0", + "vite": "^5.4.0", + "vue-tsc": "^2.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.34.tgz", + "integrity": "sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/shared": "3.5.34", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz", + "integrity": "sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz", + "integrity": "sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/compiler-core": "3.5.34", + "@vue/compiler-dom": "3.5.34", + "@vue/compiler-ssr": "3.5.34", + "@vue/shared": "3.5.34", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.14", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.34.tgz", + "integrity": "sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.34.tgz", + "integrity": "sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.34.tgz", + "integrity": "sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz", + "integrity": "sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.34", + "@vue/runtime-core": "3.5.34", + "@vue/shared": "3.5.34", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.34.tgz", + "integrity": "sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.34", + "@vue/shared": "3.5.34" + }, + "peerDependencies": { + "vue": "3.5.34" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.34.tgz", + "integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==", + "license": "MIT" + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/marked": { + "version": "14.1.4", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.1.4.tgz", + "integrity": "sha512-vkVZ8ONmUdPnjCKc5uTRvmkRbx4EAi2OkTOXmfTDhZz3OFqMNBM1oTTWwTr4HY4uAEojhzPf+Fy8F1DWa3Sndg==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.34.tgz", + "integrity": "sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.34", + "@vue/compiler-sfc": "3.5.34", + "@vue/runtime-dom": "3.5.34", + "@vue/server-renderer": "3.5.34", + "@vue/shared": "3.5.34" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + } + } +} diff --git a/apps/write/frontend/package.json b/apps/write/frontend/package.json new file mode 100644 index 0000000..e469744 --- /dev/null +++ b/apps/write/frontend/package.json @@ -0,0 +1,20 @@ +{ + "name": "write", + "private": true, + "version": "0.1.0", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "marked": "^14.0.0", + "vue": "^3.4.21" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.4", + "typescript": "^5.4.0", + "vite": "^5.4.0", + "vue-tsc": "^2.0.0" + } +} diff --git a/apps/write/frontend/public/asr-worklet.js b/apps/write/frontend/public/asr-worklet.js new file mode 100644 index 0000000..30b0b40 --- /dev/null +++ b/apps/write/frontend/public/asr-worklet.js @@ -0,0 +1,45 @@ +// AudioWorkletProcessor: downsample mic input to 16 kHz mono **Int16 LE** PCM. +// Qwen3-ASR streaming server (mochi-asr on i7:9000) expects Int16 LE. +// +// Browsers run AudioContext at 44.1k or 48k. We linear-resample to 16k. + +const TARGET_RATE = 16000; +const CHUNK_SAMPLES = 1600; // 100 ms at 16 kHz + +class AsrWorklet extends AudioWorkletProcessor { + constructor() { + super(); + this._ratio = sampleRate / TARGET_RATE; + this._tail = []; + } + + process(inputs) { + const input = inputs[0]; + if (!input || input.length === 0 || !input[0]) return true; + const channel = input[0]; + + const outLen = Math.floor(channel.length / this._ratio); + for (let i = 0; i < outLen; i++) { + const srcIdx = i * this._ratio; + const i0 = srcIdx | 0; + const i1 = i0 + 1 < channel.length ? i0 + 1 : channel.length - 1; + const t = srcIdx - i0; + this._tail.push(channel[i0] * (1 - t) + channel[i1] * t); + } + + while (this._tail.length >= CHUNK_SAMPLES) { + const samples = this._tail.splice(0, CHUNK_SAMPLES); + const i16 = new Int16Array(CHUNK_SAMPLES); + for (let i = 0; i < CHUNK_SAMPLES; i++) { + let s = samples[i]; + if (s < -1) s = -1; + else if (s > 1) s = 1; + i16[i] = s < 0 ? Math.round(s * 32768) : Math.round(s * 32767); + } + this.port.postMessage(i16.buffer, [i16.buffer]); + } + return true; + } +} + +registerProcessor('asr-worklet', AsrWorklet); diff --git a/apps/write/frontend/src/App.vue b/apps/write/frontend/src/App.vue new file mode 100644 index 0000000..ccb65a9 --- /dev/null +++ b/apps/write/frontend/src/App.vue @@ -0,0 +1,287 @@ + + + diff --git a/apps/write/frontend/src/lib/api.ts b/apps/write/frontend/src/lib/api.ts new file mode 100644 index 0000000..024484f --- /dev/null +++ b/apps/write/frontend/src/lib/api.ts @@ -0,0 +1,69 @@ +// Tiny API client. Token is read once from localStorage and attached to every +// request. WS endpoints use ?token= query (browsers can't set ws headers). + +const TOKEN_KEY = 'write.token' + +export function getToken(): string { + return localStorage.getItem(TOKEN_KEY) || '' +} + +export function setToken(t: string): void { + localStorage.setItem(TOKEN_KEY, t) +} + +function authHeaders(): Record { + const t = getToken() + return t ? { Authorization: `token ${t}` } : {} +} + +async function req(method: string, path: string, body?: unknown): Promise { + const headers: Record = { ...authHeaders() } + if (body !== undefined) headers['Content-Type'] = 'application/json' + const r = await fetch(path, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + }) + if (!r.ok) { + const text = await r.text().catch(() => '') + throw new Error(`${method} ${path} → ${r.status}: ${text}`) + } + if (r.status === 204) return undefined as T + return r.json() as Promise +} + +export interface DocMeta { + id: number + title: string + created_at: string + updated_at: string +} + +export interface DocFull extends DocMeta { + content: string +} + +export interface ChatResponse { + content: string + reply: string + cost_usd?: number + turns?: number + duration_ms?: number +} + +export const api = { + listDocs: () => req('GET', '/api/docs'), + createDoc: (title?: string) => req('POST', '/api/docs', { title }), + getDoc: (id: number) => req('GET', `/api/docs/${id}`), + deleteDoc: (id: number) => req('DELETE', `/api/docs/${id}`), + saveContent: (id: number, content: string, title?: string) => + req('PUT', `/api/docs/${id}/content`, { content, title }), + chat: (id: number, text: string) => + req('POST', `/api/docs/${id}/chat`, { text }), +} + +export function asrWsUrl(): string { + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:' + const t = getToken() + return `${proto}//${location.host}/asr?token=${encodeURIComponent(t)}` +} diff --git a/apps/write/frontend/src/lib/asr.ts b/apps/write/frontend/src/lib/asr.ts new file mode 100644 index 0000000..21491ae --- /dev/null +++ b/apps/write/frontend/src/lib/asr.ts @@ -0,0 +1,118 @@ +// Browser-side ASR client. Connects to /asr (proxied to mochi-asr on i7:9000), +// captures the mic, downsamples in an AudioWorklet, emits transcript events. + +import { asrWsUrl } from './api' + +export type AsrEvent = + | { kind: 'partial'; text: string } + | { kind: 'final'; text: string } + | { kind: 'error'; message: string } + +export type AsrEventHandler = (e: AsrEvent) => void + +export class AsrSession { + private ws: WebSocket | null = null + private audioCtx: AudioContext | null = null + private node: AudioWorkletNode | null = null + private mediaStream: MediaStream | null = null + private source: MediaStreamAudioSourceNode | null = null + private handler: AsrEventHandler + private active = false + + constructor(handler: AsrEventHandler) { + this.handler = handler + } + + async start(): Promise { + if (this.active) return + this.active = true + + this.mediaStream = await navigator.mediaDevices.getUserMedia({ + audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true }, + }) + + this.audioCtx = new AudioContext() + await this.audioCtx.audioWorklet.addModule('/asr-worklet.js') + this.node = new AudioWorkletNode(this.audioCtx, 'asr-worklet', { + numberOfInputs: 1, + numberOfOutputs: 0, + channelCount: 1, + }) + this.source = this.audioCtx.createMediaStreamSource(this.mediaStream) + this.source.connect(this.node) + + this.ws = new WebSocket(asrWsUrl()) + this.ws.binaryType = 'arraybuffer' + + const flushQueue: ArrayBuffer[] = [] + let wsReady = false + + this.node.port.onmessage = (ev: MessageEvent) => { + if (!this.ws) return + if (wsReady && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(ev.data) + } else { + flushQueue.push(ev.data) + } + } + + this.ws.addEventListener('open', () => { + wsReady = true + while (flushQueue.length) { + const buf = flushQueue.shift()! + this.ws?.send(buf) + } + }) + + this.ws.addEventListener('message', (ev) => { + if (typeof ev.data !== 'string') return + let data: { text?: string; final?: boolean; error?: string } + try { + data = JSON.parse(ev.data) + } catch { + return + } + if (data.error) { + this.handler({ kind: 'error', message: data.error }) + return + } + if (typeof data.text === 'string') { + this.handler( + data.final + ? { kind: 'final', text: data.text } + : { kind: 'partial', text: data.text }, + ) + } + }) + + this.ws.addEventListener('error', () => { + this.handler({ kind: 'error', message: 'asr ws error' }) + }) + } + + async stop(): Promise { + if (!this.active) return + this.active = false + + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify({ type: 'flush' })) + } + + if (this.source) this.source.disconnect() + if (this.node) this.node.disconnect() + if (this.mediaStream) { + for (const track of this.mediaStream.getTracks()) track.stop() + } + if (this.audioCtx) await this.audioCtx.close() + + this.source = null + this.node = null + this.mediaStream = null + this.audioCtx = null + + // Give upstream a moment to send the {"final": true} response. + const ws = this.ws + this.ws = null + setTimeout(() => ws?.close(), 1500) + } +} diff --git a/apps/write/frontend/src/main.ts b/apps/write/frontend/src/main.ts new file mode 100644 index 0000000..fdbdce5 --- /dev/null +++ b/apps/write/frontend/src/main.ts @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import App from './App.vue' +import './styles.css' + +createApp(App).mount('#app') diff --git a/apps/write/frontend/src/styles.css b/apps/write/frontend/src/styles.css new file mode 100644 index 0000000..701fee9 --- /dev/null +++ b/apps/write/frontend/src/styles.css @@ -0,0 +1,327 @@ +* { box-sizing: border-box; } +html, body, #app { height: 100%; margin: 0; width: 100%; overflow-x: hidden; } +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', + 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; + background: #fafafa; + color: #222; + font-size: 14px; +} +.app { max-width: 100vw; overflow-x: hidden; } + +button { font: inherit; cursor: pointer; } +textarea, input { font: inherit; } + +/* ====== layout ====== */ + +.app { + display: grid; + grid-template-columns: 260px 1fr; + height: 100vh; +} + +.sidebar { + border-right: 1px solid #e5e5e5; + background: #fff; + display: flex; + flex-direction: column; + min-width: 0; +} + +.sidebar-header { + padding: 14px 16px; + border-bottom: 1px solid #eee; + display: flex; + align-items: center; + gap: 8px; +} +.sidebar-header h1 { margin: 0; font-size: 16px; font-weight: 600; } +.sidebar-header .new-btn { + margin-left: auto; + border: 1px solid #ddd; + background: #fff; + padding: 4px 10px; + border-radius: 6px; + font-size: 13px; +} +.sidebar-header .new-btn:hover { background: #f5f5f5; } + +.doc-list { flex: 1; overflow-y: auto; padding: 6px; } +.doc-item { + padding: 8px 10px; + border-radius: 6px; + cursor: pointer; + margin-bottom: 2px; + font-size: 13px; + display: flex; + align-items: center; + gap: 8px; +} +.doc-item:hover { background: #f3f3f3; } +.doc-item.active { background: #e8efff; } +.doc-item .title { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.doc-item .meta { font-size: 11px; color: #888; } +.doc-item .del { + border: none; + background: transparent; + color: #c44; + font-size: 12px; + visibility: hidden; +} +.doc-item:hover .del { visibility: visible; } + +/* ====== workspace ====== */ + +.workspace { + display: grid; + grid-template-rows: 1fr auto; + min-width: 0; + min-height: 0; + background: #fafafa; + overflow: hidden; +} + +.editor-pane > * { min-width: 0; min-height: 0; } +.workspace > * { min-width: 0; } +.input-bar { min-width: 0; box-sizing: border-box; overflow: hidden; } + +.editor-pane { + display: grid; + grid-template-columns: 1fr 1fr; + min-height: 0; + border-bottom: 1px solid #e5e5e5; +} + +.title-row { + grid-column: 1 / -1; + padding: 10px 16px; + border-bottom: 1px solid #eee; + background: #fff; +} +.title-row input { + width: 100%; + border: none; + outline: none; + font-size: 16px; + font-weight: 600; + background: transparent; +} + +.editor, .preview { + min-height: 0; + overflow-y: auto; + padding: 16px; +} +.editor { + border-right: 1px solid #e5e5e5; + background: #fff; +} +.editor { overflow-x: hidden; } +.editor textarea { + width: 100%; + max-width: 100%; + height: 100%; + border: none; + outline: none; + resize: none; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 13px; + line-height: 1.6; + background: transparent; + word-break: break-all; +} +.preview { + background: #fff; + line-height: 1.6; + overflow-x: hidden; + word-wrap: break-word; + overflow-wrap: anywhere; +} +.preview h1, .preview h2, .preview h3 { margin: 18px 0 8px; } +.preview p { margin: 8px 0; } +.preview pre { + background: #f5f5f5; padding: 10px; border-radius: 6px; + font-size: 12px; overflow-x: auto; + max-width: 100%; +} +.preview code { background: #f5f5f5; padding: 1px 4px; border-radius: 3px; font-size: 12px; } +.preview pre code { background: transparent; padding: 0; word-wrap: normal; overflow-wrap: normal; } +.preview blockquote { border-left: 3px solid #ddd; margin: 8px 0; padding: 4px 12px; color: #555; } +.preview img { max-width: 100%; height: auto; } +.preview table { display: block; max-width: 100%; overflow-x: auto; } + +/* ====== input bar ====== */ + +.input-bar { + background: #fff; + border-top: 1px solid #e5e5e5; + padding: 10px 12px; +} +.input-row { display: flex; gap: 8px; align-items: flex-end; min-width: 0; } +.input-row textarea { + flex: 1 1 0; + min-width: 0; + min-height: 40px; + max-height: 160px; + resize: none; + padding: 9px 12px; + border: 1px solid #ddd; + border-radius: 8px; + outline: none; + font-size: 14px; + line-height: 1.4; +} +.input-row textarea:focus { border-color: #6b7cff; } + +.btn { + height: 40px; + padding: 0 14px; + border-radius: 8px; + border: 1px solid #ddd; + background: #fff; + font-size: 14px; +} +.btn:hover { background: #f5f5f5; } +.btn.primary { background: #5566ee; border-color: #4455dd; color: #fff; } +.btn.primary:hover { background: #4455dd; } +.btn.primary:disabled { background: #aaa; border-color: #aaa; cursor: not-allowed; } +.btn.mic { width: 44px; padding: 0; } +.btn.mic.on { background: #ee5566; border-color: #dd4455; color: #fff; } + +.status { + font-size: 12px; + color: #666; + padding: 4px 4px 0; + min-height: 18px; +} +.status.err { color: #c44; } + +/* ====== mobile-only chrome ====== */ + +.mobile-bar { + display: none; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: #fff; + border-bottom: 1px solid #e5e5e5; + padding-top: max(8px, env(safe-area-inset-top)); +} +.mobile-bar .hamburger, +.mobile-bar .pane-tabs button { + border: 1px solid #ddd; + background: #fff; + border-radius: 6px; + font-size: 14px; + padding: 6px 10px; +} +.mobile-bar .pane-tabs { + margin-left: auto; + display: flex; + gap: 4px; +} +.mobile-bar .pane-tabs button.active { + background: #5566ee; + color: #fff; + border-color: #4455dd; +} +.mobile-bar .doc-title { + flex: 1 1 0; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-weight: 600; + font-size: 14px; +} +.mobile-bar .doc-title input { min-width: 0; } + +.sidebar-backdrop { + display: none; + position: fixed; inset: 0; + background: rgba(0,0,0,0.35); + z-index: 50; +} + +/* ====== mobile layout ====== */ + +@media (max-width: 768px) { + .app { + grid-template-columns: 1fr; + grid-template-rows: auto 1fr; + height: 100dvh; + } + + .mobile-bar { display: flex; } + + /* sidebar becomes a slide-in drawer */ + .sidebar { + position: fixed; + top: 0; left: 0; bottom: 0; + width: 78vw; + max-width: 320px; + border-right: 1px solid #e5e5e5; + border-bottom: none; + transform: translateX(-100%); + transition: transform 0.2s ease; + z-index: 60; + box-shadow: 2px 0 8px rgba(0,0,0,0.1); + } + .app.drawer-open .sidebar { transform: translateX(0); } + .app.drawer-open .sidebar-backdrop { display: block; } + + /* workspace fills the screen; editor + preview overlay each other, + pane-tabs picks which one shows */ + .editor-pane { + grid-template-columns: 1fr; + grid-template-rows: 1fr; + position: relative; + } + .title-row { display: none; } /* title shows in the mobile-bar instead */ + + .editor, .preview { + grid-column: 1; + grid-row: 1; + border-right: none; + border-bottom: none; + padding: 12px; + } + .editor { display: none; } + .preview { display: none; } + .app.pane-editor .editor { display: block; } + .app.pane-preview .preview { display: block; } + + /* input bar honors safe area at bottom */ + .input-bar { + padding-bottom: max(10px, env(safe-area-inset-bottom)); + } + .input-row textarea { + font-size: 16px; /* iOS won't zoom in if >= 16px */ + } + .btn { height: 44px; } /* easier thumb target */ + .btn.mic { width: 48px; } +} + +/* ====== modal ====== */ + +.modal-backdrop { + position: fixed; inset: 0; + background: rgba(0,0,0,0.4); + display: flex; align-items: center; justify-content: center; + z-index: 100; +} +.modal { + background: #fff; padding: 20px; border-radius: 10px; + width: 360px; max-width: 90vw; +} +.modal h2 { margin: 0 0 12px; font-size: 16px; } +.modal input { + width: 100%; padding: 8px 10px; border: 1px solid #ddd; border-radius: 6px; + margin-bottom: 12px; outline: none; +} +.modal-actions { display: flex; gap: 8px; justify-content: flex-end; } diff --git a/apps/write/frontend/tsconfig.json b/apps/write/frontend/tsconfig.json new file mode 100644 index 0000000..02399b7 --- /dev/null +++ b/apps/write/frontend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": false, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"] +} diff --git a/apps/write/frontend/vite.config.ts b/apps/write/frontend/vite.config.ts new file mode 100644 index 0000000..9acc24b --- /dev/null +++ b/apps/write/frontend/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + server: { + proxy: { + '/api': 'http://localhost:31391', + '/asr': { target: 'ws://localhost:31391', ws: true }, + '/healthz': 'http://localhost:31391', + }, + }, +}) diff --git a/apps/write/k8s/all.yaml b/apps/write/k8s/all.yaml new file mode 100644 index 0000000..f5d037a --- /dev/null +++ b/apps/write/k8s/all.yaml @@ -0,0 +1,51 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: cube-write +--- +# write runs as a systemd --user fam service on the host (it spawns `claude` +# which uses Fam's OAuth credentials in ~/.claude). To expose it via traefik +# we point a manual Endpoints object at the node-side of cni0 (10.42.0.1). +apiVersion: v1 +kind: Service +metadata: + name: write + namespace: cube-write +spec: + ports: + - port: 80 + targetPort: 31391 + protocol: TCP + name: http +--- +apiVersion: v1 +kind: Endpoints +metadata: + name: write + namespace: cube-write +subsets: + - addresses: + - ip: 10.42.0.1 + ports: + - port: 31391 + protocol: TCP + name: http +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: write + namespace: cube-write +spec: + ingressClassName: traefik + rules: + - host: write.famzheng.me + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: write + port: + number: 80 diff --git a/apps/write/src/asr.rs b/apps/write/src/asr.rs new file mode 100644 index 0000000..bc2cfb7 --- /dev/null +++ b/apps/write/src/asr.rs @@ -0,0 +1,141 @@ +//! `/asr` — browser-facing WS that proxies to the upstream ASR server. +//! +//! Upstream protocol (Qwen3-ASR streaming server on i7:9000): +//! client → server: text JSON `{"type":"init","language":"...","chunk_size_sec":N}`, +//! binary Int16 LE PCM, 16 kHz mono, +//! text JSON `{"type":"flush"}` +//! server → client: text JSON `{"text":"..."}`, `{"text":"...","final":true}` +//! +//! Browser only pushes PCM binary + optional flush. The proxy injects the +//! upstream `init` from server-side config. + +use axum::{ + extract::{ + ws::{Message, WebSocket}, + State, WebSocketUpgrade, + }, + response::Response, +}; +use futures_util::{SinkExt, StreamExt}; +use tokio_tungstenite::{ + connect_async, + tungstenite::{client::IntoClientRequest, Message as TgMessage}, +}; +use tracing::{debug, error, info, warn}; + +use crate::state::AppState; + +pub async fn ws_handler(State(state): State, ws: WebSocketUpgrade) -> Response { + ws.on_upgrade(move |socket| handle_socket(socket, state)) +} + +async fn handle_socket(client_ws: WebSocket, state: AppState) { + info!(upstream = %state.asr_upstream, "ASR proxy: dialing upstream"); + let request = match state.asr_upstream.as_str().into_client_request() { + Ok(r) => r, + Err(e) => { + error!(error = %e, "failed to build upstream request"); + return; + } + }; + + let (upstream_ws, _resp) = match connect_async(request).await { + Ok(pair) => pair, + Err(e) => { + error!(error = %e, "ASR upstream connection failed"); + let _ = close_with_msg(client_ws, "ASR upstream unreachable").await; + return; + } + }; + + let (mut up_tx, mut up_rx) = upstream_ws.split(); + let (mut cl_tx, mut cl_rx) = client_ws.split(); + + let init = serde_json::json!({ + "type": "init", + "language": state.asr_language, + "chunk_size_sec": state.asr_chunk_size_sec, + }) + .to_string(); + if let Err(e) = up_tx.send(TgMessage::Text(init)).await { + warn!(error = %e, "ASR upstream init send failed"); + return; + } + + let to_upstream = async move { + while let Some(msg) = cl_rx.next().await { + match msg { + Ok(Message::Binary(b)) => { + if up_tx.send(TgMessage::Binary(b.to_vec())).await.is_err() { + break; + } + } + Ok(Message::Text(t)) => { + if up_tx.send(TgMessage::Text(t.to_string())).await.is_err() { + break; + } + } + Ok(Message::Close(_)) => { + let _ = up_tx.send(TgMessage::Close(None)).await; + break; + } + Ok(Message::Ping(p)) => { + let _ = up_tx.send(TgMessage::Ping(p)).await; + } + Ok(_) => {} + Err(e) => { + debug!(error = %e, "client ws recv error"); + break; + } + } + } + }; + + let to_client = async move { + while let Some(msg) = up_rx.next().await { + match msg { + Ok(TgMessage::Text(t)) => { + if cl_tx + .send(Message::Text(t.as_str().to_string().into())) + .await + .is_err() + { + break; + } + } + Ok(TgMessage::Binary(b)) => { + if cl_tx + .send(Message::Binary(b.to_vec().into())) + .await + .is_err() + { + break; + } + } + Ok(TgMessage::Close(_)) => { + let _ = cl_tx.send(Message::Close(None)).await; + break; + } + Ok(_) => {} + Err(e) => { + debug!(error = %e, "upstream ws recv error"); + break; + } + } + } + }; + + tokio::select! { + () = to_upstream => warn!("ASR proxy: client side closed"), + () = to_client => warn!("ASR proxy: upstream side closed"), + } +} + +async fn close_with_msg(mut ws: WebSocket, reason: &str) -> Result<(), axum::Error> { + let _ = ws + .send(Message::Text( + format!("{{\"error\":\"{reason}\"}}").into(), + )) + .await; + ws.send(Message::Close(None)).await +} diff --git a/apps/write/src/docs.rs b/apps/write/src/docs.rs new file mode 100644 index 0000000..adfe7b2 --- /dev/null +++ b/apps/write/src/docs.rs @@ -0,0 +1,320 @@ +//! Doc CRUD + the chat endpoint that hands off to claude. +//! +//! Layout on disk: +//! ${DOCS_DIR}//doc.md the editable markdown +//! Metadata (id/title/timestamps) in sqlite. + +use std::path::PathBuf; +use std::process::Stdio; + +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Json as JsonResp}, + routing::{get, post, put}, + Router, +}; +use rusqlite::{params, OptionalExtension}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use tokio::process::Command; +use tracing::{error, info, warn}; + +use crate::state::AppState; + +#[derive(Serialize)] +pub struct DocMeta { + pub id: i64, + pub title: String, + pub created_at: String, + pub updated_at: String, +} + +pub fn router() -> Router { + Router::new() + .route("/docs", get(list_docs).post(create_doc)) + .route("/docs/:id", get(get_doc).delete(delete_doc)) + .route("/docs/:id/content", put(save_doc_content)) + .route("/docs/:id/chat", post(chat)) +} + +fn doc_dir(state: &AppState, id: i64) -> PathBuf { + state.docs_dir.join(id.to_string()) +} + +fn doc_path(state: &AppState, id: i64) -> PathBuf { + doc_dir(state, id).join("doc.md") +} + +async fn list_docs(State(state): State) -> impl IntoResponse { + let db = state.db.lock().expect("db lock"); + let mut stmt = match db.prepare( + "SELECT id, title, created_at, updated_at FROM docs ORDER BY updated_at DESC", + ) { + Ok(s) => s, + Err(e) => return Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string())), + }; + let rows = stmt + .query_map([], |row| { + Ok(DocMeta { + id: row.get(0)?, + title: row.get(1)?, + created_at: row.get(2)?, + updated_at: row.get(3)?, + }) + }) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + let out: Vec = rows + .filter_map(Result::ok) + .collect(); + Ok(JsonResp(out)) +} + +#[derive(Deserialize)] +struct CreateDocReq { + title: Option, +} + +async fn create_doc( + State(state): State, + JsonResp(req): JsonResp, +) -> impl IntoResponse { + let title = req.title.unwrap_or_else(|| "未命名文档".to_string()); + let id: i64 = { + let db = state.db.lock().expect("db lock"); + db.execute( + "INSERT INTO docs (title) VALUES (?1)", + params![title], + ) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + db.last_insert_rowid() + }; + let dir = doc_dir(&state, id); + if let Err(e) = std::fs::create_dir_all(&dir) { + error!(error = %e, dir = %dir.display(), "create dir failed"); + return Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string())); + } + let path = doc_path(&state, id); + let _ = std::fs::write(&path, ""); + let meta = read_meta(&state, id) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(JsonResp(meta)) +} + +fn read_meta(state: &AppState, id: i64) -> Result { + let db = state.db.lock().expect("db lock"); + db.query_row( + "SELECT id, title, created_at, updated_at FROM docs WHERE id = ?1", + params![id], + |row| { + Ok(DocMeta { + id: row.get(0)?, + title: row.get(1)?, + created_at: row.get(2)?, + updated_at: row.get(3)?, + }) + }, + ) + .optional() + .map_err(|e| e.to_string())? + .ok_or_else(|| "doc not found".to_string()) +} + +#[derive(Serialize)] +struct DocResp { + #[serde(flatten)] + meta: DocMeta, + content: String, +} + +async fn get_doc( + State(state): State, + Path(id): Path, +) -> impl IntoResponse { + let meta = read_meta(&state, id) + .map_err(|e| (StatusCode::NOT_FOUND, e))?; + let content = std::fs::read_to_string(doc_path(&state, id)) + .unwrap_or_default(); + Ok::<_, (StatusCode, String)>(JsonResp(DocResp { meta, content })) +} + +async fn delete_doc( + State(state): State, + Path(id): Path, +) -> impl IntoResponse { + { + let db = state.db.lock().expect("db lock"); + let _ = db.execute("DELETE FROM docs WHERE id = ?1", params![id]); + } + let _ = std::fs::remove_dir_all(doc_dir(&state, id)); + StatusCode::NO_CONTENT +} + +#[derive(Deserialize)] +struct SaveContentReq { + content: String, + title: Option, +} + +async fn save_doc_content( + State(state): State, + Path(id): Path, + JsonResp(req): JsonResp, +) -> impl IntoResponse { + let dir = doc_dir(&state, id); + std::fs::create_dir_all(&dir).ok(); + std::fs::write(doc_path(&state, id), &req.content) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + { + let db = state.db.lock().expect("db lock"); + if let Some(t) = &req.title { + let _ = db.execute( + "UPDATE docs SET title = ?1, updated_at = CURRENT_TIMESTAMP WHERE id = ?2", + params![t, id], + ); + } else { + let _ = db.execute( + "UPDATE docs SET updated_at = CURRENT_TIMESTAMP WHERE id = ?1", + params![id], + ); + } + } + let meta = read_meta(&state, id) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok::<_, (StatusCode, String)>(JsonResp(meta)) +} + +#[derive(Deserialize)] +struct ChatReq { + text: String, +} + +#[derive(Serialize)] +struct ChatResp { + content: String, + reply: String, + cost_usd: Option, + turns: Option, + duration_ms: Option, +} + +async fn chat( + State(state): State, + Path(id): Path, + JsonResp(req): JsonResp, +) -> impl IntoResponse { + // Verify doc exists + let _meta = match read_meta(&state, id) { + Ok(m) => m, + Err(e) => return Err((StatusCode::NOT_FOUND, e)), + }; + + let dir = doc_dir(&state, id); + std::fs::create_dir_all(&dir).ok(); + let path = doc_path(&state, id); + if !path.exists() { + let _ = std::fs::write(&path, ""); + } + + info!(id, "spawning claude in {}", dir.display()); + + // Build the prompt for claude. claude runs in the doc's directory with full + // read/write access via Read/Edit/Write tools — it edits `doc.md` directly. + let prompt = format!( + r#"你是 write.famzheng.me 的 AI 协作者。Fam 在跟你协作编辑一个 markdown 文档。 + +工作目录里只有一个文件: `doc.md`。这就是当前正在编辑的文档。 + +Fam 刚说: +--- +{} +--- + +根据 Fam 说的话,决定动作: +- 如果他要求改文档(例如 "把第二段重写"、"加一个章节" 等),用 Read / Edit / Write 工具修改 `doc.md`。 +- 如果他在问问题,**也直接把答案写进 `doc.md`**(追加,或合适地融入),因为这个 app 没有独立对话框——所有对话都体现在文档上。 +- 如果他说的是闲聊或不相关,简短回应一下别动文档。 + +**重要**: 最终输出一句话简短反馈(不超过 30 字),告诉 Fam 你做了什么。比如 "已扩写第二段"、"已加 TODO 章节"、"没看懂你想改啥"。**不要在输出里贴文档全文**。 + +完成后退出。 +"#, + req.text + ); + + // Spawn claude + let mut cmd = Command::new(&state.claude_bin); + cmd.current_dir(&dir) + .arg("-p") + .arg(&prompt) + .arg("--dangerously-skip-permissions") + .arg("--model") + .arg(&state.claude_model) + .arg("--output-format") + .arg("json") + .arg("--max-budget-usd") + .arg(state.claude_max_budget_usd.to_string()) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let child = match cmd.spawn() { + Ok(c) => c, + Err(e) => { + error!(error = %e, "spawn claude failed"); + return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("spawn: {e}"))); + } + }; + + // Read stdout fully (JSON result format gives a single JSON object) + let output = match child.wait_with_output().await { + Ok(o) => o, + Err(e) => { + error!(error = %e, "claude wait failed"); + return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("wait: {e}"))); + } + }; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + warn!(rc = ?output.status.code(), "claude exited non-zero"); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + format!("claude exit={:?}: {}", output.status.code(), &stderr[..stderr.len().min(2000)]), + )); + } + + let stdout_text = String::from_utf8_lossy(&output.stdout); + let parsed: Value = serde_json::from_str(&stdout_text) + .unwrap_or_else(|_| json!({"result": stdout_text})); + + let reply = parsed.get("result") + .and_then(Value::as_str) + .unwrap_or("") + .trim() + .to_string(); + let cost = parsed.get("total_cost_usd").and_then(Value::as_f64); + let turns = parsed.get("num_turns").and_then(Value::as_i64); + let duration = parsed.get("duration_ms").and_then(Value::as_i64); + + // Re-read doc.md + let content = std::fs::read_to_string(&path).unwrap_or_default(); + + { + let db = state.db.lock().expect("db lock"); + let _ = db.execute( + "UPDATE docs SET updated_at = CURRENT_TIMESTAMP WHERE id = ?1", + params![id], + ); + } + + info!(id, ?cost, ?turns, ?duration, "claude chat done"); + + Ok::<_, (StatusCode, String)>(JsonResp(ChatResp { + content, + reply, + cost_usd: cost, + turns, + duration_ms: duration, + })) +} diff --git a/apps/write/src/main.rs b/apps/write/src/main.rs new file mode 100644 index 0000000..6f26b74 --- /dev/null +++ b/apps/write/src/main.rs @@ -0,0 +1,165 @@ +//! write.famzheng.me — voice/text → claude → markdown doc. +//! +//! Auth: `Authorization: token ` header. WS endpoints also accept +//! `?token=...` query (since browser ws can't set headers). +//! Config: env vars (see write.service for the full list). + +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use axum::{ + extract::{Request, State}, + http::{header, StatusCode}, + middleware::{from_fn_with_state, Next}, + response::{IntoResponse, Response}, + routing::get, + Router, +}; +use rusqlite::Connection; +use tracing::warn; + +mod asr; +mod docs; +mod state; +use state::AppState; + +#[tokio::main] +async fn main() -> std::io::Result<()> { + cube_core::init_tracing(); + + let docs_dir = PathBuf::from( + std::env::var("WRITE_DOCS_DIR") + .unwrap_or_else(|_| "/home/fam/.local/share/write/docs".into()), + ); + let db_path = std::env::var("WRITE_DB_PATH") + .unwrap_or_else(|_| "/home/fam/.local/share/write/app.db".into()); + let dist = std::env::var("WRITE_DIST_DIR") + .unwrap_or_else(|_| "/home/fam/src/cube/apps/write/frontend/dist".into()); + + let passphrase = std::env::var("WRITE_PASSPHRASE").unwrap_or_default(); + if passphrase.is_empty() { + warn!("WRITE_PASSPHRASE not set — all /api/* and /asr will return 401"); + } + + let asr_upstream = std::env::var("WRITE_ASR_UPSTREAM") + .unwrap_or_else(|_| "ws://cpc-i7:9000".into()); + let asr_language = + std::env::var("WRITE_ASR_LANGUAGE").unwrap_or_else(|_| "Chinese".into()); + let asr_chunk_size_sec: f64 = std::env::var("WRITE_ASR_CHUNK_SIZE_SEC") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(1.0); + + let claude_bin = std::env::var("WRITE_CLAUDE_BIN") + .unwrap_or_else(|_| "/home/fam/.local/bin/claude".into()); + let claude_model = + std::env::var("WRITE_CLAUDE_MODEL").unwrap_or_else(|_| "sonnet".into()); + let claude_max_budget_usd: f64 = std::env::var("WRITE_CLAUDE_MAX_BUDGET_USD") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(2.0); + + std::fs::create_dir_all(&docs_dir).expect("mkdir docs_dir"); + if let Some(parent) = PathBuf::from(&db_path).parent() { + std::fs::create_dir_all(parent).ok(); + } + + let conn = Connection::open(&db_path).expect("open sqlite"); + conn.execute_batch( + "PRAGMA journal_mode=WAL; + CREATE TABLE IF NOT EXISTS docs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL DEFAULT '未命名文档', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + );", + ) + .expect("init schema"); + + tracing::info!( + %db_path, + docs = %docs_dir.display(), + %asr_upstream, + %claude_model, + "write ready" + ); + + let state = AppState { + db: Arc::new(Mutex::new(conn)), + docs_dir, + passphrase, + asr_upstream, + asr_language, + asr_chunk_size_sec, + claude_bin, + claude_model, + claude_max_budget_usd, + }; + + let api = docs::router().route_layer(from_fn_with_state(state.clone(), auth_header)); + let asr_router = Router::new() + .route("/asr", get(asr::ws_handler)) + .route_layer(from_fn_with_state(state.clone(), auth_query_or_header)); + + let app: Router = Router::new() + .nest("/api", api) + .merge(asr_router) + .with_state(state) + .merge(cube_core::base(&dist)); + + let port: u16 = std::env::var("PORT") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(31391); + cube_core::serve(app, port).await +} + +/// Standard `Authorization: token ` middleware. +async fn auth_header(State(state): State, req: Request, next: Next) -> Response { + if state.passphrase.is_empty() { + return (StatusCode::UNAUTHORIZED, "auth not configured").into_response(); + } + let want = format!("token {}", state.passphrase); + let got = req + .headers() + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + if got != want { + return (StatusCode::UNAUTHORIZED, "invalid token").into_response(); + } + next.run(req).await +} + +/// Same as `auth_header` but also accepts `?token=` query — needed +/// for WebSocket endpoints since the browser can't attach custom headers to ws. +async fn auth_query_or_header( + State(state): State, + req: Request, + next: Next, +) -> Response { + if state.passphrase.is_empty() { + return (StatusCode::UNAUTHORIZED, "auth not configured").into_response(); + } + let want_header = format!("token {}", state.passphrase); + let header_match = req + .headers() + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .map(|got| got == want_header) + .unwrap_or(false); + let query_match = req.uri().query().map(|q| { + q.split('&').any(|kv| { + let mut parts = kv.splitn(2, '='); + matches!( + (parts.next(), parts.next()), + (Some("token"), Some(v)) if v == state.passphrase + ) + }) + }).unwrap_or(false); + + if !header_match && !query_match { + return (StatusCode::UNAUTHORIZED, "invalid token").into_response(); + } + next.run(req).await +} diff --git a/apps/write/src/state.rs b/apps/write/src/state.rs new file mode 100644 index 0000000..3b3c49a --- /dev/null +++ b/apps/write/src/state.rs @@ -0,0 +1,17 @@ +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use rusqlite::Connection; + +#[derive(Clone)] +pub struct AppState { + pub db: Arc>, + pub docs_dir: PathBuf, + pub passphrase: String, + pub asr_upstream: String, + pub asr_language: String, + pub asr_chunk_size_sec: f64, + pub claude_bin: String, + pub claude_model: String, + pub claude_max_budget_usd: f64, +} diff --git a/apps/write/systemd/write.service b/apps/write/systemd/write.service new file mode 100644 index 0000000..4554605 --- /dev/null +++ b/apps/write/systemd/write.service @@ -0,0 +1,30 @@ +[Unit] +Description=write.famzheng.me — voice/text → claude → markdown doc +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/home/fam/.local/bin/write +Restart=on-failure +RestartSec=5 + +# Env: required + optional +EnvironmentFile=/home/fam/.config/write/env +Environment=PORT=31391 +Environment=WRITE_DOCS_DIR=/home/fam/.local/share/write/docs +Environment=WRITE_DB_PATH=/home/fam/.local/share/write/app.db +Environment=WRITE_DIST_DIR=/home/fam/.local/share/write/dist +Environment=WRITE_ASR_UPSTREAM=ws://cpc-i7:9000 +Environment=WRITE_ASR_LANGUAGE=Chinese +Environment=WRITE_ASR_CHUNK_SIZE_SEC=1.0 +Environment=WRITE_CLAUDE_BIN=/home/fam/.local/bin/claude +Environment=WRITE_CLAUDE_MODEL=sonnet +Environment=WRITE_CLAUDE_MAX_BUDGET_USD=2 +Environment=RUST_LOG=info + +StandardOutput=append:/home/fam/.local/state/write/write.log +StandardError=append:/home/fam/.local/state/write/write.log + +[Install] +WantedBy=default.target