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 @@
+
+
+
+
+
+
请输入 passphrase
+
+
+
+
+
+
+
+
+
+
+
+
+ 没选中文档
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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