Compare commits
44 Commits
bcdf6c6ba4
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b87085ca9 | |||
| e99a032852 | |||
| ae9c08aa35 | |||
| 089de84396 | |||
| 83418c198f | |||
| 0756362d14 | |||
| adbd259a32 | |||
| 8991033f70 | |||
| bcf99ec454 | |||
| 1a62ec6658 | |||
| 915b91d986 | |||
| b2bec0406f | |||
| 85b55f2243 | |||
| 027921de0c | |||
| b2d70b2491 | |||
| 7b868852d2 | |||
| 9328c01c1b | |||
| f8a7f31427 | |||
| 3f742352e2 | |||
| 3e478228dd | |||
| e072109e91 | |||
| ca11a9bda7 | |||
| a8e5100380 | |||
| a5e97adf85 | |||
| bcc8c3f484 | |||
| 1859512976 | |||
| 857c0d5481 | |||
| 34fa47f95f | |||
| 674011ddf3 | |||
| e7912f3547 | |||
| d964b46dbe | |||
| 1ee35b4d19 | |||
| 688ccdc76f | |||
| e5a87cc65f | |||
| e56e2138a8 | |||
| 68671784f6 | |||
| 3a34fbdfd8 | |||
| eb7cd81395 | |||
| 93039457a7 | |||
| 44652eb398 | |||
| c2c0c6999d | |||
| 61abd3f560 | |||
| 802d5beae9 | |||
| af697ea6d0 |
@@ -0,0 +1,52 @@
|
||||
name: deploy llm-proxy
|
||||
# llm.famzheng.me — gemma 反向代理。host shell runner(fam 用户)。
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'apps/llm-proxy/**'
|
||||
- 'crates/cube-core/**'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- '.gitea/workflows/deploy-llm-proxy.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
APP: llm-proxy
|
||||
IMAGE: registry.famzheng.me/mochi/llm-proxy
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve image tag
|
||||
id: tag
|
||||
run: echo "sha=$(git rev-parse --short=12 HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build rust (musl static)
|
||||
run: |
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
cargo build --release --target x86_64-unknown-linux-musl -p "$APP"
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
cargo test --release -p "$APP"
|
||||
|
||||
- name: Build & push image
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
echo "$REGISTRY_TOKEN" | docker login registry.famzheng.me -u mochi --password-stdin
|
||||
docker build --no-cache -f "apps/$APP/Dockerfile" -t "$IMAGE:${{ steps.tag.outputs.sha }}" .
|
||||
docker push "$IMAGE:${{ steps.tag.outputs.sha }}"
|
||||
|
||||
- name: Initialize K8s resources
|
||||
run: kubectl apply -f "apps/$APP/k8s/all.yaml"
|
||||
|
||||
- name: Roll out to k3s
|
||||
run: |
|
||||
kubectl -n llm-proxy set image "deploy/$APP" "$APP=$IMAGE:${{ steps.tag.outputs.sha }}"
|
||||
kubectl -n llm-proxy rollout status "deploy/$APP" --timeout=120s
|
||||
@@ -0,0 +1,63 @@
|
||||
name: deploy notes
|
||||
# notes.famzheng.me — 录音 → ASR → LLM 会议纪要
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'apps/notes/**'
|
||||
- 'crates/cube-core/**'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- '.gitea/workflows/deploy-notes.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
APP: notes
|
||||
NS: cube-notes
|
||||
IMAGE: registry.famzheng.me/mochi/notes
|
||||
FEISHU_IMAGE: registry.famzheng.me/mochi/notes-feishu
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve image tag
|
||||
id: tag
|
||||
run: echo "sha=$(git rev-parse --short=12 HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build rust (musl static)
|
||||
run: |
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
cargo build --release --target x86_64-unknown-linux-musl -p "$APP"
|
||||
|
||||
- name: Build frontend
|
||||
run: |
|
||||
cd "apps/$APP/frontend"
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Build & push images
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
echo "$REGISTRY_TOKEN" | docker login registry.famzheng.me -u mochi --password-stdin
|
||||
# main app —— FROM scratch + COPY musl binary,必须 --no-cache(cube docker cache 坑)
|
||||
docker build --no-cache -f "apps/$APP/Dockerfile" -t "$IMAGE:${{ steps.tag.outputs.sha }}" .
|
||||
docker push "$IMAGE:${{ steps.tag.outputs.sha }}"
|
||||
# feishu sidecar —— node+python+chromium-free,layer cache 帮助大不用 --no-cache
|
||||
docker build -f "apps/$APP/feishu/Dockerfile" \
|
||||
-t "$FEISHU_IMAGE:${{ steps.tag.outputs.sha }}" \
|
||||
"apps/$APP/feishu"
|
||||
docker push "$FEISHU_IMAGE:${{ steps.tag.outputs.sha }}"
|
||||
|
||||
- name: Initialize K8s resources
|
||||
run: kubectl apply -f apps/notes/k8s/all.yaml
|
||||
|
||||
- name: Roll out to k3s
|
||||
run: |
|
||||
kubectl -n "$NS" set image "deploy/$APP" \
|
||||
"$APP=$IMAGE:${{ steps.tag.outputs.sha }}" \
|
||||
"feishu=$FEISHU_IMAGE:${{ steps.tag.outputs.sha }}"
|
||||
kubectl -n "$NS" rollout status "deploy/$APP" --timeout=300s
|
||||
@@ -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"
|
||||
@@ -56,6 +56,7 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum-core",
|
||||
"base64",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
@@ -75,8 +76,10 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sha1",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
@@ -116,12 +119,27 @@ version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.1"
|
||||
@@ -150,12 +168,36 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cube"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"cube-core",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -169,6 +211,22 @@ dependencies = [
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.5"
|
||||
@@ -314,6 +372,16 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
@@ -644,6 +712,20 @@ version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
||||
|
||||
[[package]]
|
||||
name = "llm-proxy"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"cube-core",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
@@ -748,6 +830,24 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notes"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"cube-core",
|
||||
"futures",
|
||||
"reqwest",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
@@ -845,7 +945,7 @@ dependencies = [
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
@@ -860,13 +960,13 @@ dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
"rand",
|
||||
"rand 0.9.4",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror",
|
||||
"thiserror 2.0.18",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
@@ -901,14 +1001,35 @@ version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha 0.3.1",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -918,7 +1039,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -974,6 +1104,7 @@ dependencies = [
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
@@ -1150,6 +1281,17 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
@@ -1259,13 +1401,33 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||
dependencies = [
|
||||
"thiserror-impl 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
"thiserror-impl 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1362,6 +1524,18 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"tokio",
|
||||
"tungstenite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
@@ -1512,6 +1686,30 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.8.6",
|
||||
"sha1",
|
||||
"thiserror 1.0.69",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
@@ -1542,6 +1740,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf-8"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
@@ -1863,6 +2067,27 @@ version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "write"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"cube-core",
|
||||
"futures",
|
||||
"futures-util",
|
||||
"reqwest",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tokio-tungstenite",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.3"
|
||||
|
||||
@@ -8,6 +8,9 @@ members = [
|
||||
"apps/werewolf",
|
||||
"apps/articulate",
|
||||
"apps/karaoke",
|
||||
"apps/notes",
|
||||
"apps/llm-proxy",
|
||||
"apps/write",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
@@ -25,7 +28,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream", "multipart"] }
|
||||
futures = "0.3"
|
||||
tokio-stream = "0.1"
|
||||
|
||||
|
||||
@@ -8,4 +8,9 @@ description = "cube.famzheng.me — cube 平台入口门户(app #0)"
|
||||
|
||||
[dependencies]
|
||||
cube-core = { path = "../../crates/cube-core" }
|
||||
axum = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import AppCard from './components/AppCard.vue'
|
||||
import Chatbot from './components/Chatbot.vue'
|
||||
import { apps } from './apps'
|
||||
</script>
|
||||
|
||||
@@ -33,6 +34,8 @@ import { apps } from './apps'
|
||||
<span>cube · monorepo at</span>
|
||||
<a href="https://famzheng.me/gitea/fam/cube" target="_blank" rel="noopener">famzheng.me/gitea/fam/cube</a>
|
||||
</footer>
|
||||
|
||||
<Chatbot />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
[
|
||||
{
|
||||
"slug": "cube",
|
||||
"name": "cube",
|
||||
"description": "你正在看的这个门户。cube 平台本身的入口。",
|
||||
"url": "https://cube.famzheng.me",
|
||||
"status": "live"
|
||||
},
|
||||
{
|
||||
"slug": "portfolio",
|
||||
"name": "portfolio",
|
||||
"description": "投资组合追踪。从 oci 迁移中(原 portfolio.oci.euphon.net)。",
|
||||
"url": "https://portfolio.famzheng.me",
|
||||
"status": "pending"
|
||||
},
|
||||
{
|
||||
"slug": "repo-vis",
|
||||
"name": "repo-vis",
|
||||
"description": "git 仓库可视化。从 oci 迁移中。",
|
||||
"url": "https://repo-vis.famzheng.me",
|
||||
"status": "pending"
|
||||
},
|
||||
{
|
||||
"slug": "simpleasm",
|
||||
"name": "simpleasm",
|
||||
"description": "汇编教学小游戏。",
|
||||
"url": "https://asm.famzheng.me",
|
||||
"status": "live"
|
||||
},
|
||||
{
|
||||
"slug": "music",
|
||||
"name": "music",
|
||||
"description": "听歌 + 练琴 曲目管理。243 首曲库(从 oci 旧 guitar 迁过来)+ 自动抓 yopu 吉他/功能谱 + LLM 灵感推荐。",
|
||||
"url": "https://music.famzheng.me",
|
||||
"status": "live"
|
||||
},
|
||||
{
|
||||
"slug": "pyroblem",
|
||||
"name": "pyroblem",
|
||||
"description": "详情待补。",
|
||||
"url": "https://pyroblem.famzheng.me",
|
||||
"status": "tbd"
|
||||
},
|
||||
{
|
||||
"slug": "werewolf",
|
||||
"name": "werewolf",
|
||||
"description": "狼人杀单机发牌器。一台手机轮流传,30 个角色、4x 偏好加权、配置历史本地记忆。从 partiverse 移植。",
|
||||
"url": "https://werewolf.famzheng.me",
|
||||
"status": "live"
|
||||
},
|
||||
{
|
||||
"slug": "articulate",
|
||||
"name": "articulate",
|
||||
"description": "中英猜词派对游戏(Articulate)。15 个主题词库 + 3 档难度 + 已看词跨场记忆。从 partiverse 移植。",
|
||||
"url": "https://articulate.famzheng.me",
|
||||
"status": "live"
|
||||
},
|
||||
{
|
||||
"slug": "karaoke",
|
||||
"name": "karaoke",
|
||||
"description": "卡拉OK 点歌单本地管理。增删改排 + YouTube 一键搜,10 秒撤销。从 partiverse 移植。",
|
||||
"url": "https://karaoke.famzheng.me",
|
||||
"status": "live"
|
||||
},
|
||||
{
|
||||
"slug": "notes",
|
||||
"name": "notes",
|
||||
"description": "录音 → ASR 转写 → LLM 生成会议纪要。Sidebar + content;passphrase 鉴权。",
|
||||
"url": "https://notes.famzheng.me",
|
||||
"status": "live"
|
||||
}
|
||||
]
|
||||
@@ -1,3 +1,8 @@
|
||||
// apps 列表的 SSOT 是 apps.json — 后端 chat.rs 也 include_str! 同一份,注入到
|
||||
// chatbot 的 system prompt 里。改 apps 只改 apps.json。
|
||||
|
||||
import data from './apps.json'
|
||||
|
||||
export type AppStatus = 'live' | 'pending' | 'tbd'
|
||||
|
||||
export interface App {
|
||||
@@ -8,68 +13,4 @@ export interface App {
|
||||
status: AppStatus
|
||||
}
|
||||
|
||||
export const apps: App[] = [
|
||||
{
|
||||
slug: 'cube',
|
||||
name: 'cube',
|
||||
description: '你正在看的这个门户。cube 平台本身的入口。',
|
||||
url: 'https://cube.famzheng.me',
|
||||
status: 'live',
|
||||
},
|
||||
{
|
||||
slug: 'portfolio',
|
||||
name: 'portfolio',
|
||||
description: '投资组合追踪。从 oci 迁移中(原 portfolio.oci.euphon.net)。',
|
||||
url: 'https://portfolio.famzheng.me',
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
slug: 'repo-vis',
|
||||
name: 'repo-vis',
|
||||
description: 'git 仓库可视化。从 oci 迁移中。',
|
||||
url: 'https://repo-vis.famzheng.me',
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
slug: 'simpleasm',
|
||||
name: 'simpleasm',
|
||||
description: '汇编教学小游戏。',
|
||||
url: 'https://asm.famzheng.me',
|
||||
status: 'live',
|
||||
},
|
||||
{
|
||||
slug: 'music',
|
||||
name: 'music',
|
||||
description: '听歌 + 练琴 曲目管理。243 首曲库(从 oci 旧 guitar 迁过来)+ 自动抓 yopu 吉他/功能谱 + LLM 灵感推荐。',
|
||||
url: 'https://music.famzheng.me',
|
||||
status: 'live',
|
||||
},
|
||||
{
|
||||
slug: 'pyroblem',
|
||||
name: 'pyroblem',
|
||||
description: '详情待补。',
|
||||
url: 'https://pyroblem.famzheng.me',
|
||||
status: 'tbd',
|
||||
},
|
||||
{
|
||||
slug: 'werewolf',
|
||||
name: 'werewolf',
|
||||
description: '狼人杀单机发牌器。一台手机轮流传,30 个角色、4x 偏好加权、配置历史本地记忆。从 partiverse 移植。',
|
||||
url: 'https://werewolf.famzheng.me',
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
slug: 'articulate',
|
||||
name: 'articulate',
|
||||
description: '中英猜词派对游戏(Articulate)。15 个主题词库 + 3 档难度 + 已看词跨场记忆。从 partiverse 移植。',
|
||||
url: 'https://articulate.famzheng.me',
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
slug: 'karaoke',
|
||||
name: 'karaoke',
|
||||
description: '卡拉OK 点歌单本地管理。增删改排 + YouTube 一键搜,10 秒撤销。从 partiverse 移植。',
|
||||
url: 'https://karaoke.famzheng.me',
|
||||
status: 'pending',
|
||||
},
|
||||
]
|
||||
export const apps: App[] = data as App[]
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
interface Msg {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
issue?: { number: number; url: string; title: string }
|
||||
}
|
||||
|
||||
const open = ref(false)
|
||||
const messages = ref<Msg[]>([])
|
||||
const input = ref('')
|
||||
const busy = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const scrollEl = ref<HTMLElement | null>(null)
|
||||
|
||||
const canSend = computed(() => !busy.value && input.value.trim().length > 0)
|
||||
|
||||
watch(messages, async () => {
|
||||
await nextTick()
|
||||
scrollEl.value?.scrollTo({ top: scrollEl.value.scrollHeight, behavior: 'smooth' })
|
||||
}, { deep: true })
|
||||
|
||||
async function send() {
|
||||
if (!canSend.value) return
|
||||
const text = input.value.trim()
|
||||
input.value = ''
|
||||
error.value = null
|
||||
messages.value.push({ role: 'user', content: text })
|
||||
busy.value = true
|
||||
try {
|
||||
const payload = {
|
||||
messages: messages.value.map((m) => ({ role: m.role, content: m.content })),
|
||||
}
|
||||
const res = await fetch('/api/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.text()
|
||||
throw new Error(body || `HTTP ${res.status}`)
|
||||
}
|
||||
const data = (await res.json()) as {
|
||||
reply: string
|
||||
created_issue: { number: number; url: string; title: string } | null
|
||||
}
|
||||
messages.value.push({
|
||||
role: 'assistant',
|
||||
content: data.reply,
|
||||
issue: data.created_issue ?? undefined,
|
||||
})
|
||||
} catch (e) {
|
||||
const msg = (e as Error).message
|
||||
error.value = msg
|
||||
messages.value.push({ role: 'assistant', content: `(出错了:${msg})` })
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
send()
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
messages.value = []
|
||||
error.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button v-if="!open" class="fab" @click="open = true" aria-label="打开聊天">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8z" />
|
||||
</svg>
|
||||
<span>反馈 / 提问</span>
|
||||
</button>
|
||||
|
||||
<div v-else class="panel">
|
||||
<header>
|
||||
<div class="title">
|
||||
<span class="dot" />
|
||||
<strong>cube · chat</strong>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="icon" @click="reset" title="清空对话">↺</button>
|
||||
<button class="icon" @click="open = false" title="收起">✕</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="thread" ref="scrollEl">
|
||||
<div v-if="messages.length === 0" class="hint">
|
||||
<p>问 cube 平台的事,或反馈 bug / 想法 — 我会帮你提到 <code>fam/cube</code> issue。</p>
|
||||
</div>
|
||||
|
||||
<div v-for="(m, i) in messages" :key="i" :class="['bubble', m.role]">
|
||||
<div class="content">{{ m.content }}</div>
|
||||
<a v-if="m.issue" :href="m.issue.url" target="_blank" rel="noopener" class="issue-link">
|
||||
已建 issue #{{ m.issue.number }} →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div v-if="busy" class="bubble assistant typing">
|
||||
<span /><span /><span />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<textarea
|
||||
v-model="input"
|
||||
:disabled="busy"
|
||||
@keydown="onKeydown"
|
||||
rows="2"
|
||||
placeholder="说点什么...(Enter 发送,Shift+Enter 换行)"
|
||||
/>
|
||||
<button class="send" :disabled="!canSend" @click="send">发送</button>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fab {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
z-index: 1000;
|
||||
background: linear-gradient(135deg, #7c3aed, #06b6d4);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 18px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
box-shadow: 0 8px 24px rgba(124, 58, 237, 0.35);
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.fab:hover { transform: translateY(-2px); box-shadow: 0 12px 28px rgba(124, 58, 237, 0.45); }
|
||||
|
||||
.panel {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
z-index: 1000;
|
||||
width: min(380px, calc(100vw - 32px));
|
||||
height: min(560px, calc(100vh - 40px));
|
||||
background: var(--bg-soft, rgba(20, 20, 30, 0.95));
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--border, rgba(255, 255, 255, 0.15));
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
color: var(--fg, rgba(255, 255, 255, 0.92));
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border, rgba(255, 255, 255, 0.15));
|
||||
}
|
||||
.title { display: flex; align-items: center; gap: 8px; }
|
||||
.dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: #4ade80;
|
||||
box-shadow: 0 0 8px rgba(74, 222, 128, 0.7);
|
||||
}
|
||||
.actions { display: flex; gap: 4px; }
|
||||
.icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border, rgba(255, 255, 255, 0.15));
|
||||
color: inherit;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.icon:hover { background: rgba(255, 255, 255, 0.06); }
|
||||
|
||||
.thread {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.hint { color: var(--fg-dim, rgba(255, 255, 255, 0.6)); font-size: 0.9rem; }
|
||||
.hint code {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
max-width: 85%;
|
||||
padding: 10px 13px;
|
||||
border-radius: 12px;
|
||||
line-height: 1.4;
|
||||
font-size: 0.92rem;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.bubble.user {
|
||||
align-self: flex-end;
|
||||
background: linear-gradient(135deg, #7c3aed, #4f46e5);
|
||||
color: white;
|
||||
}
|
||||
.bubble.assistant {
|
||||
align-self: flex-start;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid var(--border, rgba(255, 255, 255, 0.15));
|
||||
}
|
||||
.issue-link {
|
||||
display: inline-block;
|
||||
margin-top: 8px;
|
||||
color: #4ea7f7;
|
||||
font-size: 0.85rem;
|
||||
text-decoration: none;
|
||||
border-top: 1px dashed rgba(255, 255, 255, 0.15);
|
||||
padding-top: 8px;
|
||||
}
|
||||
.issue-link:hover { color: #80c2ff; }
|
||||
|
||||
.bubble.typing {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.bubble.typing span {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.45);
|
||||
animation: bounce 1.2s ease-in-out infinite;
|
||||
}
|
||||
.bubble.typing span:nth-child(2) { animation-delay: 0.15s; }
|
||||
.bubble.typing span:nth-child(3) { animation-delay: 0.3s; }
|
||||
@keyframes bounce {
|
||||
0%, 60%, 100% { transform: translateY(0); opacity: 0.45; }
|
||||
30% { transform: translateY(-5px); opacity: 1; }
|
||||
}
|
||||
|
||||
footer {
|
||||
padding: 10px 12px 12px;
|
||||
border-top: 1px solid var(--border, rgba(255, 255, 255, 0.15));
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
textarea {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid var(--border, rgba(255, 255, 255, 0.15));
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
font-family: inherit;
|
||||
font-size: 0.92rem;
|
||||
color: inherit;
|
||||
line-height: 1.4;
|
||||
}
|
||||
textarea:focus { outline: 2px solid #7c3aed; outline-offset: -1px; }
|
||||
.send {
|
||||
background: linear-gradient(135deg, #7c3aed, #06b6d4);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.send:disabled { background: rgba(255, 255, 255, 0.1); color: rgba(255, 255, 255, 0.4); cursor: not-allowed; }
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.panel { right: 12px; bottom: 12px; width: calc(100vw - 24px); height: calc(100vh - 24px); }
|
||||
}
|
||||
</style>
|
||||
@@ -1 +0,0 @@
|
||||
{"root":["./src/apps.ts","./src/main.ts","./src/App.vue","./src/components/AppCard.vue","./vite-env.d.ts"],"version":"5.7.3"}
|
||||
@@ -30,6 +30,20 @@ spec:
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
name: http
|
||||
envFrom:
|
||||
# secret `chat-credentials` (LLM_API_TOKEN + GITEA_TOKEN) 由 kubectl 手工创建,
|
||||
# 不在 git manifest 里。kubectl apply -f all.yaml 不会动它。
|
||||
- secretRef:
|
||||
name: chat-credentials
|
||||
env:
|
||||
- name: LLM_GATEWAY
|
||||
value: "http://3.135.65.204:8848/v1"
|
||||
- name: LLM_MODEL
|
||||
value: "gemma-4-31b-it"
|
||||
- name: GITEA_URL
|
||||
value: "https://famzheng.me/gitea"
|
||||
- name: ISSUE_REPO
|
||||
value: "fam/cube"
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
@@ -48,7 +62,7 @@ spec:
|
||||
memory: 16Mi
|
||||
limits:
|
||||
cpu: 200m
|
||||
memory: 64Mi
|
||||
memory: 128Mi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
//! `/api/chat` — 浏览器 ↔ LLM gateway 中转 + `create_issue` 工具调用。
|
||||
//!
|
||||
//! 单步 tool calling:拿到用户消息 → 调一次 LLM with tools → 如果 LLM 决定调
|
||||
//! `create_issue` 就同步建 issue,把结果(issue 编号 + URL)当作 reply 返回给前端。
|
||||
//! 不做 agent loop,不递归把工具结果喂回 LLM(重新调一次是浪费,issue 已经建好,
|
||||
//! 直接告诉用户就行)。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Config {
|
||||
pub gateway: String, // http://3.135.65.204:8848/v1
|
||||
pub llm_token: String, // Bearer for LLM gateway
|
||||
pub llm_model: String, // gemma-4-31b-it
|
||||
pub gitea_url: String, // https://famzheng.me/gitea
|
||||
pub gitea_token: String,
|
||||
pub issue_repo: String, // fam/cube
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> Self {
|
||||
Self {
|
||||
gateway: env_or("LLM_GATEWAY", "http://3.135.65.204:8848/v1"),
|
||||
llm_token: env_or("LLM_API_TOKEN", ""),
|
||||
llm_model: env_or("LLM_MODEL", "gemma-4-31b-it"),
|
||||
gitea_url: env_or("GITEA_URL", "https://famzheng.me/gitea"),
|
||||
gitea_token: env_or("GITEA_TOKEN", ""),
|
||||
issue_repo: env_or("ISSUE_REPO", "fam/cube"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn env_or(key: &str, fallback: &str) -> String {
|
||||
std::env::var(key).unwrap_or_else(|_| fallback.to_string())
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug)]
|
||||
pub struct ChatMessage {
|
||||
pub role: String, // "user" | "assistant" | "system" | "tool"
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ChatRequest {
|
||||
pub messages: Vec<ChatMessage>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CreatedIssue {
|
||||
pub number: u64,
|
||||
pub url: String,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ChatResponse {
|
||||
pub reply: String,
|
||||
pub created_issue: Option<CreatedIssue>,
|
||||
}
|
||||
|
||||
pub enum ChatError {
|
||||
UpstreamLlm(String),
|
||||
UpstreamGitea(String),
|
||||
Empty,
|
||||
}
|
||||
|
||||
impl IntoResponse for ChatError {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
match self {
|
||||
Self::UpstreamLlm(msg) => {
|
||||
tracing::error!(%msg, "llm upstream failed");
|
||||
(StatusCode::BAD_GATEWAY, format!("LLM upstream error: {msg}")).into_response()
|
||||
}
|
||||
Self::UpstreamGitea(msg) => {
|
||||
tracing::error!(%msg, "gitea upstream failed");
|
||||
(StatusCode::BAD_GATEWAY, format!("Gitea upstream error: {msg}")).into_response()
|
||||
}
|
||||
Self::Empty => (StatusCode::BAD_REQUEST, "messages 不能为空").into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// apps.json 是 SSOT — 前端 apps.ts 也 import 同一份。
|
||||
const APPS_JSON: &str = include_str!("../frontend/src/apps.json");
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AppInfo {
|
||||
slug: String,
|
||||
description: String,
|
||||
url: String,
|
||||
status: String,
|
||||
}
|
||||
|
||||
/// 把 apps.json 渲染成 markdown bullet list 注进 system prompt。
|
||||
/// 解析失败兜底成 raw JSON(让 LLM 自己 grok),不让 chatbot 因为 ssot 坏掉而 500。
|
||||
pub fn render_apps_list() -> String {
|
||||
match serde_json::from_str::<Vec<AppInfo>>(APPS_JSON) {
|
||||
Ok(apps) => apps
|
||||
.iter()
|
||||
.map(|a| {
|
||||
format!(
|
||||
"- **{slug}** ({status}) — {desc} <{url}>",
|
||||
slug = a.slug,
|
||||
status = a.status,
|
||||
desc = a.description,
|
||||
url = a.url,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
Err(_) => APPS_JSON.trim().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn system_prompt(repo: &str) -> String {
|
||||
format!(
|
||||
"你是 cube 平台(cube.famzheng.me,Fam 的小 app 平台)入口页上的聊天助手。\n\
|
||||
\n\
|
||||
当前 cube 上线的 app 列表(status: live=可用 / pending=迁移中 / tbd=待定):\n\
|
||||
{apps}\n\
|
||||
\n\
|
||||
你可以做两件事:\n\
|
||||
1. 回答用户关于上面这些 app 的问题,简短直接。**回答时只能基于上面列表的事实**,\n\
|
||||
不要凭训练知识瞎猜不存在的 app 或功能。如果用户问的 app 不在列表里,直说没有。\n\
|
||||
2. 当用户表达 bug / feature request / feedback / 想反馈给 Fam 时,调用 `create_issue`\n\
|
||||
工具,把它整理成 issue 创建到 `{repo}`。标题简洁明确(≤60 字符),body 包含足够上下文\n\
|
||||
(涉及哪个 app / 重现步骤 / 期望行为 / 用户原话等)。\n\
|
||||
\n\
|
||||
不要主动鼓励用户提 issue —— 只在他明确表达想反馈时才创建。\n\
|
||||
同一次对话只创建一个 issue。Reply 要简短,不写长段散文。",
|
||||
apps = render_apps_list(),
|
||||
repo = repo,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn create_issue_tool_schema() -> Value {
|
||||
json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "create_issue",
|
||||
"description": "在 fam/cube 仓库创建一个 issue,用于收集用户反馈、bug 报告或 feature request",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Issue 标题,简洁明确,不超过 60 个字符"
|
||||
},
|
||||
"body": {
|
||||
"type": "string",
|
||||
"description": "Issue 正文 Markdown,包含重现步骤 / 期望行为 / 用户原话等上下文"
|
||||
}
|
||||
},
|
||||
"required": ["title", "body"]
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn handle(
|
||||
State(cfg): State<Arc<Config>>,
|
||||
Json(req): Json<ChatRequest>,
|
||||
) -> Result<Json<ChatResponse>, ChatError> {
|
||||
if req.messages.is_empty() {
|
||||
return Err(ChatError::Empty);
|
||||
}
|
||||
|
||||
// 拼 messages:注入 system + 用户历史
|
||||
let mut messages: Vec<Value> = vec![json!({
|
||||
"role": "system",
|
||||
"content": system_prompt(&cfg.issue_repo),
|
||||
})];
|
||||
for m in &req.messages {
|
||||
messages.push(json!({ "role": m.role, "content": m.content }));
|
||||
}
|
||||
|
||||
let body = json!({
|
||||
"model": cfg.llm_model,
|
||||
"messages": messages,
|
||||
"tools": [create_issue_tool_schema()],
|
||||
"tool_choice": "auto",
|
||||
"stream": false,
|
||||
"temperature": 0.6,
|
||||
});
|
||||
|
||||
let endpoint = format!("{}/chat/completions", cfg.gateway.trim_end_matches('/'));
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(&endpoint)
|
||||
.bearer_auth(&cfg.llm_token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ChatError::UpstreamLlm(e.to_string()))?;
|
||||
|
||||
let status = resp.status();
|
||||
let bytes = resp
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| ChatError::UpstreamLlm(e.to_string()))?;
|
||||
if !status.is_success() {
|
||||
let body = String::from_utf8_lossy(&bytes);
|
||||
return Err(ChatError::UpstreamLlm(format!("{status}: {body}")));
|
||||
}
|
||||
let v: Value = serde_json::from_slice(&bytes)
|
||||
.map_err(|e| ChatError::UpstreamLlm(format!("invalid json: {e}")))?;
|
||||
|
||||
let choice = v
|
||||
.pointer("/choices/0/message")
|
||||
.ok_or_else(|| ChatError::UpstreamLlm("no choices/0/message".into()))?;
|
||||
|
||||
// 拿 tool_calls 数组;如果有 create_issue 调用就执行,否则返回 content
|
||||
if let Some(tool_call) = first_create_issue_call(choice) {
|
||||
let (title, body_md) = extract_issue_args(&tool_call).map_err(ChatError::UpstreamLlm)?;
|
||||
let created = create_gitea_issue(&cfg, &title, &body_md).await?;
|
||||
let llm_text = choice
|
||||
.get("content")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
let reply = if llm_text.is_empty() {
|
||||
format!("已记下 → issue #{}: {}", created.number, created.title)
|
||||
} else {
|
||||
format!("{llm_text}\n\n→ issue #{}: {}", created.number, created.title)
|
||||
};
|
||||
return Ok(Json(ChatResponse {
|
||||
reply,
|
||||
created_issue: Some(created),
|
||||
}));
|
||||
}
|
||||
|
||||
// 没工具调用 — 普通回复
|
||||
let text = choice
|
||||
.get("content")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
Ok(Json(ChatResponse {
|
||||
reply: if text.is_empty() {
|
||||
"嗯?没听清,再说一遍?".to_string()
|
||||
} else {
|
||||
text
|
||||
},
|
||||
created_issue: None,
|
||||
}))
|
||||
}
|
||||
|
||||
/// 从 LLM 返回的 message 里挑第一个 `create_issue` tool_call。
|
||||
pub fn first_create_issue_call(message: &Value) -> Option<Value> {
|
||||
let arr = message.get("tool_calls")?.as_array()?;
|
||||
arr.iter()
|
||||
.find(|tc| {
|
||||
tc.pointer("/function/name").and_then(Value::as_str) == Some("create_issue")
|
||||
})
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// arguments 是 JSON 字符串(OpenAI 协议),需要二次解析。
|
||||
pub fn extract_issue_args(tool_call: &Value) -> Result<(String, String), String> {
|
||||
let args_raw = tool_call
|
||||
.pointer("/function/arguments")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| "tool_call 缺少 arguments".to_string())?;
|
||||
let args: Value = serde_json::from_str(args_raw)
|
||||
.map_err(|e| format!("arguments 不是合法 JSON: {e}"))?;
|
||||
let title = args
|
||||
.get("title")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| "tool 调用缺少 title".to_string())?
|
||||
.trim()
|
||||
.to_string();
|
||||
let body = args
|
||||
.get("body")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| "tool 调用缺少 body".to_string())?
|
||||
.trim()
|
||||
.to_string();
|
||||
if title.is_empty() {
|
||||
return Err("title 为空".to_string());
|
||||
}
|
||||
Ok((title, body))
|
||||
}
|
||||
|
||||
async fn create_gitea_issue(
|
||||
cfg: &Config,
|
||||
title: &str,
|
||||
body: &str,
|
||||
) -> Result<CreatedIssue, ChatError> {
|
||||
let url = format!(
|
||||
"{}/api/v1/repos/{}/issues",
|
||||
cfg.gitea_url.trim_end_matches('/'),
|
||||
cfg.issue_repo
|
||||
);
|
||||
let body_md = format!(
|
||||
"{body}\n\n---\n_via cube portal chatbot · cube.famzheng.me_"
|
||||
);
|
||||
let payload = json!({
|
||||
"title": title,
|
||||
"body": body_md,
|
||||
"labels": ["chatbot"],
|
||||
});
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("token {}", cfg.gitea_token))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ChatError::UpstreamGitea(e.to_string()))?;
|
||||
let status = resp.status();
|
||||
let bytes = resp
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| ChatError::UpstreamGitea(e.to_string()))?;
|
||||
if !status.is_success() {
|
||||
return Err(ChatError::UpstreamGitea(format!(
|
||||
"{status}: {}",
|
||||
String::from_utf8_lossy(&bytes)
|
||||
)));
|
||||
}
|
||||
let issue: Value = serde_json::from_slice(&bytes)
|
||||
.map_err(|e| ChatError::UpstreamGitea(format!("invalid json: {e}")))?;
|
||||
Ok(CreatedIssue {
|
||||
number: issue.get("number").and_then(Value::as_u64).unwrap_or(0),
|
||||
url: issue
|
||||
.get("html_url")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
title: issue
|
||||
.get("title")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(title)
|
||||
.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn system_prompt_includes_repo() {
|
||||
let p = system_prompt("fam/cube");
|
||||
assert!(p.contains("fam/cube"));
|
||||
assert!(p.contains("create_issue"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_apps_list_parses_ssot() {
|
||||
let list = render_apps_list();
|
||||
// 兜底分支会返回 raw JSON(含 `{`),正常解析后是 markdown bullet(每行以 `- ` 起头)
|
||||
assert!(list.starts_with("- "), "render output should be markdown bullets, got: {list}");
|
||||
// 抽查几个已知 slug 在里面
|
||||
for slug in ["cube", "werewolf", "articulate", "karaoke", "music"] {
|
||||
assert!(list.contains(slug), "apps list 应该含 {slug}: {list}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_prompt_includes_apps_list() {
|
||||
let p = system_prompt("fam/cube");
|
||||
// 至少一个 app 的 slug 出现在 prompt 里,说明 list 真的被注进去了
|
||||
assert!(p.contains("werewolf"));
|
||||
assert!(p.contains("live"));
|
||||
// 防回退:旧 prompt 写死了 "werewolf / articulate / karaoke / music / simpleasm",
|
||||
// 现在不再用那种枚举句式 —— 列表是数据驱动的
|
||||
assert!(!p.contains("werewolf / articulate / karaoke / music / simpleasm"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_schema_shape() {
|
||||
let s = create_issue_tool_schema();
|
||||
assert_eq!(s.pointer("/type").and_then(Value::as_str), Some("function"));
|
||||
assert_eq!(
|
||||
s.pointer("/function/name").and_then(Value::as_str),
|
||||
Some("create_issue")
|
||||
);
|
||||
let req = s
|
||||
.pointer("/function/parameters/required")
|
||||
.and_then(Value::as_array)
|
||||
.unwrap();
|
||||
assert!(req.iter().any(|v| v == "title"));
|
||||
assert!(req.iter().any(|v| v == "body"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_create_issue_call_picks_only_matching_tool() {
|
||||
let m = json!({
|
||||
"tool_calls": [
|
||||
{"id": "x1", "type": "function", "function": {"name": "other_tool", "arguments": "{}"}},
|
||||
{"id": "x2", "type": "function", "function": {"name": "create_issue", "arguments": "{\"title\":\"t\",\"body\":\"b\"}"}}
|
||||
]
|
||||
});
|
||||
let tc = first_create_issue_call(&m).expect("should find one");
|
||||
assert_eq!(tc.pointer("/function/name").and_then(Value::as_str), Some("create_issue"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_create_issue_call_returns_none_if_absent() {
|
||||
let m = json!({ "tool_calls": [] });
|
||||
assert!(first_create_issue_call(&m).is_none());
|
||||
let m = json!({});
|
||||
assert!(first_create_issue_call(&m).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_issue_args_parses_string_arguments() {
|
||||
let tc = json!({
|
||||
"function": {
|
||||
"name": "create_issue",
|
||||
"arguments": "{\"title\":\"狼人杀: swipe 失灵\",\"body\":\" iOS Safari 上无法 swipe \"}"
|
||||
}
|
||||
});
|
||||
let (t, b) = extract_issue_args(&tc).unwrap();
|
||||
assert_eq!(t, "狼人杀: swipe 失灵");
|
||||
assert_eq!(b, "iOS Safari 上无法 swipe");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_issue_args_rejects_empty_title() {
|
||||
let tc = json!({"function": {"arguments": "{\"title\":\"\",\"body\":\"x\"}"}});
|
||||
assert!(extract_issue_args(&tc).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_issue_args_rejects_malformed_args() {
|
||||
let tc = json!({"function": {"arguments": "not json"}});
|
||||
assert!(extract_issue_args(&tc).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_issue_args_rejects_missing_field() {
|
||||
let tc = json!({"function": {"arguments": "{\"title\":\"x\"}"}});
|
||||
assert!(extract_issue_args(&tc).is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,22 @@
|
||||
//! cube.famzheng.me — 入口门户。纯静态 SPA,没有自己的 /api 路由。
|
||||
//! cube.famzheng.me — 入口门户 + 反馈聊天助手。
|
||||
//!
|
||||
//! - 静态 SPA + SPA fallback 由 cube-core::base 处理。
|
||||
//! - `POST /api/chat` 转发到 LLM gateway,工具 `create_issue` 直接调 gitea 建 issue。
|
||||
|
||||
mod chat;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
cube_core::init_tracing();
|
||||
let dist = std::env::var("CUBE_DIST_DIR").unwrap_or_else(|_| "/dist".into());
|
||||
let app = cube_core::base(dist);
|
||||
let cfg = Arc::new(chat::Config::from_env());
|
||||
|
||||
let api = axum::Router::new()
|
||||
.route("/chat", axum::routing::post(chat::handle))
|
||||
.with_state(cfg);
|
||||
|
||||
let app = cube_core::base(dist).nest("/api", api);
|
||||
cube_core::serve(app, 8080).await
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "llm-proxy"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
description = "llm.famzheng.me — gemma-4-31b-it 反向代理 + token 鉴权 + /chat web UI"
|
||||
|
||||
[dependencies]
|
||||
cube-core = { path = "../../crates/cube-core" }
|
||||
axum = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tower-http = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
@@ -0,0 +1,5 @@
|
||||
# llm-proxy — llm.famzheng.me
|
||||
FROM scratch
|
||||
COPY target/x86_64-unknown-linux-musl/release/llm-proxy /llm-proxy
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/llm-proxy"]
|
||||
@@ -0,0 +1,90 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: llm-proxy
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: llm-proxy
|
||||
namespace: llm-proxy
|
||||
labels:
|
||||
app: llm-proxy
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: llm-proxy
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: llm-proxy
|
||||
spec:
|
||||
imagePullSecrets:
|
||||
- name: registry-creds
|
||||
containers:
|
||||
- name: llm-proxy
|
||||
image: registry.famzheng.me/mochi/llm-proxy:latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
name: http
|
||||
envFrom:
|
||||
# secret `proxy-credentials` 由 kubectl 手工创建(BACKEND_TOKEN +
|
||||
# PROXY_AUTH_TOKEN),不在 git manifest 里。
|
||||
- secretRef:
|
||||
name: proxy-credentials
|
||||
env:
|
||||
- name: LLM_GATEWAY
|
||||
value: "http://3.135.65.204:8848/v1"
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: http
|
||||
initialDelaySeconds: 1
|
||||
periodSeconds: 5
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 15
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 16Mi
|
||||
limits:
|
||||
cpu: 200m
|
||||
memory: 128Mi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: llm-proxy
|
||||
namespace: llm-proxy
|
||||
spec:
|
||||
selector:
|
||||
app: llm-proxy
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: 8080
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: llm-proxy
|
||||
namespace: llm-proxy
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: llm.famzheng.me
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: llm-proxy
|
||||
port:
|
||||
number: 80
|
||||
@@ -0,0 +1,160 @@
|
||||
//! llm.famzheng.me — gemma-4-31b-it 反向代理 + 简单 token 鉴权。
|
||||
//!
|
||||
//! - `GET /` → `/chat` 跳转
|
||||
//! - `GET /chat` → 静态 web UI
|
||||
//! - `POST /v1/chat/completions` → OpenAI 兼容透传 (要 Authorization: token <PROXY_AUTH_TOKEN>)
|
||||
//! - `GET /healthz` → 不带 auth, 给 k8s probe
|
||||
|
||||
mod proxy;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::{header, StatusCode},
|
||||
middleware::{self, Next},
|
||||
response::{Html, IntoResponse, Redirect, Response},
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use tower_http::trace::TraceLayer;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
cube_core::init_tracing();
|
||||
let cfg = Arc::new(proxy::Config::from_env());
|
||||
let port: u16 = std::env::var("PORT")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(8080);
|
||||
|
||||
let chat_api = Router::new()
|
||||
.route("/v1/chat/completions", post(proxy::handle))
|
||||
.route_layer(middleware::from_fn_with_state(cfg.clone(), require_token))
|
||||
.with_state(cfg);
|
||||
|
||||
let app = Router::new()
|
||||
.route("/healthz", get(|| async { "ok" }))
|
||||
.route("/", get(|| async { Redirect::permanent("/chat") }))
|
||||
.route("/chat", get(chat_ui))
|
||||
.route("/favicon.svg", get(favicon))
|
||||
.route("/favicon.ico", get(favicon)) // 浏览器默认会请求 .ico,让它共享同一 SVG
|
||||
.merge(chat_api)
|
||||
.layer(TraceLayer::new_for_http());
|
||||
|
||||
let addr = format!("0.0.0.0:{port}");
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||
tracing::info!(%addr, "llm-proxy listening");
|
||||
axum::serve(listener, app).await
|
||||
}
|
||||
|
||||
const CHAT_HTML: &str = include_str!("../web/chat.html");
|
||||
const FAVICON_SVG: &str = include_str!("../web/favicon.svg");
|
||||
|
||||
async fn chat_ui() -> Html<&'static str> {
|
||||
Html(CHAT_HTML)
|
||||
}
|
||||
|
||||
async fn favicon() -> impl IntoResponse {
|
||||
(
|
||||
[
|
||||
(axum::http::header::CONTENT_TYPE, "image/svg+xml"),
|
||||
(axum::http::header::CACHE_CONTROL, "public, max-age=604800"),
|
||||
],
|
||||
FAVICON_SVG,
|
||||
)
|
||||
}
|
||||
|
||||
/// 验 `Authorization: token <PROXY_AUTH_TOKEN>`,错的直接 401。
|
||||
async fn require_token(
|
||||
State(cfg): State<Arc<proxy::Config>>,
|
||||
req: axum::extract::Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let header_val = req
|
||||
.headers()
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::trim);
|
||||
|
||||
match header_val {
|
||||
Some(v) if check_token(v, &cfg.proxy_auth_token) => next.run(req).await,
|
||||
_ => (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"缺少或不匹配 `Authorization: token <your-token>`",
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 接受 `token <T>` 或 `Bearer <T>`(OpenAI client 习惯发 Bearer,宽容点)。
|
||||
pub fn check_token(header_value: &str, expected: &str) -> bool {
|
||||
if expected.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let trimmed = header_value.trim();
|
||||
if let Some(rest) = trimmed.strip_prefix("token ") {
|
||||
return constant_time_eq(rest.trim().as_bytes(), expected.as_bytes());
|
||||
}
|
||||
if let Some(rest) = trimmed.strip_prefix("Bearer ") {
|
||||
return constant_time_eq(rest.trim().as_bytes(), expected.as_bytes());
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 常时间比较,防 timing attack(虽然这场景影响小,做了不亏)。
|
||||
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
let mut diff: u8 = 0;
|
||||
for (x, y) in a.iter().zip(b.iter()) {
|
||||
diff |= x ^ y;
|
||||
}
|
||||
diff == 0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn check_token_accepts_token_scheme() {
|
||||
assert!(check_token("token famzheng-llm-2026", "famzheng-llm-2026"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_token_accepts_bearer_scheme() {
|
||||
assert!(check_token("Bearer famzheng-llm-2026", "famzheng-llm-2026"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_token_rejects_wrong_value() {
|
||||
assert!(!check_token("token wrong", "famzheng-llm-2026"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_token_rejects_unknown_scheme() {
|
||||
assert!(!check_token("Basic famzheng-llm-2026", "famzheng-llm-2026"));
|
||||
assert!(!check_token("famzheng-llm-2026", "famzheng-llm-2026"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_token_rejects_empty_expected() {
|
||||
// 防 misconfigured:空 expected 不应该让任何人通过
|
||||
assert!(!check_token("token any", ""));
|
||||
assert!(!check_token("Bearer ", ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_token_strips_extra_whitespace() {
|
||||
assert!(check_token(" token famzheng-llm-2026 ", "famzheng-llm-2026"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_token_rejects_prefix_match() {
|
||||
// 防止"famzheng-llm-2026-extra" 通过
|
||||
assert!(!check_token("token famzheng-llm-2026-extra", "famzheng-llm-2026"));
|
||||
assert!(!check_token("token famzheng-llm", "famzheng-llm-2026"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//! `/v1/chat/completions` 透传 — 替换 Authorization 头,把请求 body 原样 forward 到
|
||||
//! 上游 LLM gateway,把响应 body 原样回吐给客户端。
|
||||
//!
|
||||
//! 一期只支持非 streaming(force `stream: false` 进 body),SSE 透传留给二期。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::State,
|
||||
http::{HeaderMap, HeaderValue, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Config {
|
||||
pub upstream_url: String, // http://3.135.65.204:8848/v1/chat/completions
|
||||
pub upstream_token: String,
|
||||
pub proxy_auth_token: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> Self {
|
||||
let gateway = std::env::var("LLM_GATEWAY")
|
||||
.unwrap_or_else(|_| "http://3.135.65.204:8848/v1".to_string());
|
||||
let upstream_url = format!("{}/chat/completions", gateway.trim_end_matches('/'));
|
||||
Self {
|
||||
upstream_url,
|
||||
upstream_token: std::env::var("BACKEND_TOKEN").unwrap_or_default(),
|
||||
proxy_auth_token: std::env::var("PROXY_AUTH_TOKEN").unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle(State(cfg): State<Arc<Config>>, body: Bytes) -> Response {
|
||||
// 1. parse body → 强制 stream=false(一期不支持流式)
|
||||
let body_bytes = match force_non_stream(&body) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
return (StatusCode::BAD_REQUEST, format!("bad JSON body: {e}")).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// 2. forward
|
||||
let client = reqwest::Client::new();
|
||||
let res = client
|
||||
.post(&cfg.upstream_url)
|
||||
.header("Authorization", format!("Bearer {}", cfg.upstream_token))
|
||||
.header("Content-Type", "application/json")
|
||||
.body(body_bytes)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(r) => relay_response(r).await,
|
||||
Err(e) => {
|
||||
tracing::error!(error=%e, "upstream call failed");
|
||||
(StatusCode::BAD_GATEWAY, format!("upstream error: {e}")).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// parse JSON、塞入 `stream: false`、重新 serialize。
|
||||
/// 如果不是 JSON object 就保持原样(让上游自己报错)。
|
||||
fn force_non_stream(body: &Bytes) -> Result<Vec<u8>, String> {
|
||||
if body.is_empty() {
|
||||
return Err("empty body".into());
|
||||
}
|
||||
let mut v: Value = serde_json::from_slice(body).map_err(|e| e.to_string())?;
|
||||
if let Some(obj) = v.as_object_mut() {
|
||||
obj.insert("stream".to_string(), Value::Bool(false));
|
||||
}
|
||||
serde_json::to_vec(&v).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
async fn relay_response(upstream: reqwest::Response) -> Response {
|
||||
let status = upstream.status();
|
||||
let ct = upstream
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| HeaderValue::from_static("application/json"));
|
||||
let bytes = match upstream.bytes().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
tracing::error!(error=%e, "read upstream body");
|
||||
return (StatusCode::BAD_GATEWAY, "read upstream body failed").into_response();
|
||||
}
|
||||
};
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(axum::http::header::CONTENT_TYPE, ct);
|
||||
(
|
||||
StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY),
|
||||
headers,
|
||||
bytes,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn force_non_stream_overrides_stream_true() {
|
||||
let input = Bytes::from(r#"{"model":"gemma","messages":[],"stream":true}"#);
|
||||
let out = force_non_stream(&input).unwrap();
|
||||
let v: Value = serde_json::from_slice(&out).unwrap();
|
||||
assert_eq!(v["stream"], Value::Bool(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_non_stream_injects_when_absent() {
|
||||
let input = Bytes::from(r#"{"model":"gemma","messages":[]}"#);
|
||||
let out = force_non_stream(&input).unwrap();
|
||||
let v: Value = serde_json::from_slice(&out).unwrap();
|
||||
assert_eq!(v["stream"], Value::Bool(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_non_stream_preserves_other_fields() {
|
||||
let input = Bytes::from(
|
||||
r#"{"model":"gemma-4-31b-it","temperature":0.7,"messages":[{"role":"user","content":"hi"}]}"#,
|
||||
);
|
||||
let out = force_non_stream(&input).unwrap();
|
||||
let v: Value = serde_json::from_slice(&out).unwrap();
|
||||
assert_eq!(v["model"], "gemma-4-31b-it");
|
||||
assert_eq!(v["temperature"], 0.7);
|
||||
assert_eq!(v["messages"][0]["role"], "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_non_stream_rejects_empty() {
|
||||
assert!(force_non_stream(&Bytes::new()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_non_stream_rejects_invalid_json() {
|
||||
let input = Bytes::from(r#"not json"#);
|
||||
assert!(force_non_stream(&input).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_from_env_builds_completions_url() {
|
||||
// Saved env keeps test isolation under cargo test (run in parallel)
|
||||
let prev_gateway = std::env::var("LLM_GATEWAY").ok();
|
||||
let prev_token = std::env::var("BACKEND_TOKEN").ok();
|
||||
let prev_proxy = std::env::var("PROXY_AUTH_TOKEN").ok();
|
||||
std::env::set_var("LLM_GATEWAY", "http://1.2.3.4:8848/v1/");
|
||||
std::env::set_var("BACKEND_TOKEN", "backend-xxx");
|
||||
std::env::set_var("PROXY_AUTH_TOKEN", "client-yyy");
|
||||
|
||||
let cfg = Config::from_env();
|
||||
assert_eq!(cfg.upstream_url, "http://1.2.3.4:8848/v1/chat/completions");
|
||||
assert_eq!(cfg.upstream_token, "backend-xxx");
|
||||
assert_eq!(cfg.proxy_auth_token, "client-yyy");
|
||||
|
||||
// restore
|
||||
match prev_gateway {
|
||||
Some(v) => std::env::set_var("LLM_GATEWAY", v),
|
||||
None => std::env::remove_var("LLM_GATEWAY"),
|
||||
}
|
||||
match prev_token {
|
||||
Some(v) => std::env::set_var("BACKEND_TOKEN", v),
|
||||
None => std::env::remove_var("BACKEND_TOKEN"),
|
||||
}
|
||||
match prev_proxy {
|
||||
Some(v) => std::env::set_var("PROXY_AUTH_TOKEN", v),
|
||||
None => std::env::remove_var("PROXY_AUTH_TOKEN"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0f1419" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>llm.famzheng.me</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #0f1419;
|
||||
--bg-elev: #161b22;
|
||||
--soft: rgba(255,255,255,.06);
|
||||
--softer: rgba(255,255,255,.03);
|
||||
--border: rgba(255,255,255,.12);
|
||||
--fg: rgba(255,255,255,.94);
|
||||
--dim: rgba(255,255,255,.55);
|
||||
--accent: #7c3aed;
|
||||
--accent2: #06b6d4;
|
||||
--danger: #ef4444;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0; padding: 0;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC',
|
||||
'Microsoft YaHei', system-ui, sans-serif;
|
||||
font-size: 15px;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
body {
|
||||
/* dynamic viewport — 处理移动端软键盘 */
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
@supports not (height: 100dvh) {
|
||||
body { height: 100vh; }
|
||||
}
|
||||
main {
|
||||
height: 100%;
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
padding: 12px 14px env(safe-area-inset-bottom, 12px);
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr auto;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.15rem; margin: 0; font-weight: 600;
|
||||
background: linear-gradient(135deg, #fff, var(--accent2));
|
||||
-webkit-background-clip: text; background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
header small { color: var(--dim); font-size: 0.78rem; }
|
||||
|
||||
.config { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.config input {
|
||||
flex: 1; min-width: 0;
|
||||
padding: 8px 10px;
|
||||
background: var(--soft);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--fg); font: inherit;
|
||||
}
|
||||
.config input:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
|
||||
|
||||
.thread {
|
||||
overflow-y: auto;
|
||||
padding: 6px 2px;
|
||||
display: flex; flex-direction: column; gap: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
scrollbar-gutter: stable;
|
||||
/* iOS momentum */
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.thread::-webkit-scrollbar { width: 8px; }
|
||||
.thread::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
||||
.thread::-webkit-scrollbar-thumb:hover { background: var(--dim); }
|
||||
|
||||
.empty {
|
||||
margin: auto 0; text-align: center; color: var(--dim);
|
||||
padding: 24px; line-height: 1.6; font-size: 0.92rem;
|
||||
}
|
||||
.empty kbd {
|
||||
display: inline-block; padding: 1px 6px; border-radius: 4px;
|
||||
background: var(--soft); border: 1px solid var(--border);
|
||||
font-family: inherit; font-size: 0.85em;
|
||||
}
|
||||
|
||||
.row { display: flex; }
|
||||
.row.user { justify-content: flex-end; }
|
||||
.row.assistant { justify-content: flex-start; }
|
||||
.row.err { justify-content: stretch; }
|
||||
|
||||
.bubble {
|
||||
max-width: min(85%, 640px);
|
||||
padding: 10px 13px;
|
||||
border-radius: 14px;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.5;
|
||||
font-size: 0.95rem;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.25);
|
||||
}
|
||||
.row.user .bubble {
|
||||
background: linear-gradient(135deg, var(--accent), #4f46e5);
|
||||
color: white;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
.row.assistant .bubble {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
.row.err .bubble {
|
||||
background: rgba(239,68,68,.12);
|
||||
border: 1px solid rgba(239,68,68,.4);
|
||||
color: #ff8080;
|
||||
max-width: 100%; width: 100%;
|
||||
font-size: 0.85rem;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin-top: 4px;
|
||||
font-size: 0.72rem;
|
||||
color: var(--dim);
|
||||
display: flex; gap: 8px; align-items: center;
|
||||
}
|
||||
.copy-btn {
|
||||
background: transparent; border: none;
|
||||
color: var(--dim); cursor: pointer;
|
||||
font-size: 0.72rem; padding: 0;
|
||||
}
|
||||
.copy-btn:hover { color: var(--fg); }
|
||||
|
||||
.typing {
|
||||
display: inline-flex; gap: 4px;
|
||||
padding: 14px 14px;
|
||||
}
|
||||
.typing span {
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
background: var(--dim); animation: bounce 1.2s infinite;
|
||||
}
|
||||
.typing span:nth-child(2) { animation-delay: .15s; }
|
||||
.typing span:nth-child(3) { animation-delay: .30s; }
|
||||
@keyframes bounce {
|
||||
0%,60%,100% { transform: translateY(0); opacity: .45; }
|
||||
30% { transform: translateY(-4px); opacity: 1; }
|
||||
}
|
||||
|
||||
footer {
|
||||
display: flex; gap: 8px; align-items: flex-end;
|
||||
}
|
||||
textarea {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
min-height: 44px;
|
||||
max-height: 200px;
|
||||
padding: 10px 12px;
|
||||
background: var(--soft);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
color: var(--fg);
|
||||
font: inherit; line-height: 1.4;
|
||||
overflow-y: auto;
|
||||
}
|
||||
textarea:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
|
||||
|
||||
.send {
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
||||
color: white; border: none;
|
||||
padding: 0 18px;
|
||||
height: 44px;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.send:disabled {
|
||||
background: var(--soft); color: var(--dim); cursor: not-allowed;
|
||||
}
|
||||
.ghost {
|
||||
background: transparent; border: 1px solid var(--border);
|
||||
color: var(--fg);
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ghost:hover { background: var(--soft); }
|
||||
|
||||
details {
|
||||
color: var(--dim); font-size: 0.85rem;
|
||||
grid-row: auto;
|
||||
}
|
||||
details summary { cursor: pointer; padding: 4px 0; }
|
||||
details summary:hover { color: var(--fg); }
|
||||
details pre {
|
||||
background: rgba(0,0,0,.4); padding: 10px;
|
||||
border-radius: 8px; overflow-x: auto;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--fg);
|
||||
font-size: 0.82rem;
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
main { padding: 8px 10px env(safe-area-inset-bottom, 8px); gap: 8px; }
|
||||
h1 { font-size: 1rem; }
|
||||
header small { display: none; }
|
||||
.bubble { font-size: 0.92rem; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<h1>llm.famzheng.me</h1>
|
||||
<small id="meta">gemma-4-31b-it · 反向代理</small>
|
||||
</header>
|
||||
|
||||
<div class="config">
|
||||
<input id="token" type="password" autocomplete="off" spellcheck="false"
|
||||
placeholder="your auth token" />
|
||||
<button class="ghost" id="reset" type="button">清空对话</button>
|
||||
</div>
|
||||
|
||||
<div class="thread" id="thread">
|
||||
<div class="empty" id="empty">
|
||||
填好 token 后开聊。<br />
|
||||
<kbd>Enter</kbd> 发送 · <kbd>Shift+Enter</kbd> 换行
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<textarea id="input" rows="1" placeholder="说点什么..."
|
||||
autocomplete="off" autocapitalize="off"></textarea>
|
||||
<button class="send" id="send" type="button">发送</button>
|
||||
</footer>
|
||||
|
||||
<details>
|
||||
<summary>curl example</summary>
|
||||
<pre>curl -X POST https://llm.famzheng.me/v1/chat/completions \
|
||||
-H 'Authorization: token <your-token>' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"model": "gemma-4-31b-it",
|
||||
"messages": [{"role":"user","content":"hello"}]
|
||||
}'</pre>
|
||||
</details>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const TOKEN_KEY = 'llm-proxy-token'
|
||||
const tokenInput = document.getElementById('token')
|
||||
const sendBtn = document.getElementById('send')
|
||||
const resetBtn = document.getElementById('reset')
|
||||
const input = document.getElementById('input')
|
||||
const thread = document.getElementById('thread')
|
||||
const empty = document.getElementById('empty')
|
||||
|
||||
tokenInput.value = localStorage.getItem(TOKEN_KEY) || ''
|
||||
tokenInput.addEventListener('change', () => {
|
||||
localStorage.setItem(TOKEN_KEY, tokenInput.value.trim())
|
||||
})
|
||||
|
||||
const history = []
|
||||
|
||||
function clearEmpty() {
|
||||
if (empty && empty.parentNode === thread) thread.removeChild(empty)
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
// double rAF: 一次让浏览器 layout 新节点,第二次再滚
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
thread.scrollTo({ top: thread.scrollHeight, behavior: 'smooth' })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function addBubble(role, text, opts = {}) {
|
||||
clearEmpty()
|
||||
const row = document.createElement('div')
|
||||
row.className = 'row ' + role
|
||||
const bubble = document.createElement('div')
|
||||
bubble.className = 'bubble'
|
||||
bubble.textContent = text
|
||||
row.appendChild(bubble)
|
||||
if (role === 'assistant' && !opts.err) {
|
||||
const meta = document.createElement('div')
|
||||
meta.className = 'meta'
|
||||
const copy = document.createElement('button')
|
||||
copy.className = 'copy-btn'
|
||||
copy.type = 'button'
|
||||
copy.textContent = '复制'
|
||||
copy.addEventListener('click', async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
copy.textContent = '已复制'
|
||||
setTimeout(() => (copy.textContent = '复制'), 1200)
|
||||
} catch {
|
||||
copy.textContent = '复制失败'
|
||||
}
|
||||
})
|
||||
meta.appendChild(copy)
|
||||
bubble.appendChild(document.createElement('br'))
|
||||
bubble.appendChild(meta)
|
||||
}
|
||||
thread.appendChild(row)
|
||||
scrollToBottom()
|
||||
return bubble
|
||||
}
|
||||
|
||||
function addErr(text) {
|
||||
clearEmpty()
|
||||
const row = document.createElement('div')
|
||||
row.className = 'row err'
|
||||
const b = document.createElement('div')
|
||||
b.className = 'bubble'
|
||||
b.textContent = text
|
||||
row.appendChild(b)
|
||||
thread.appendChild(row)
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
function addTyping() {
|
||||
clearEmpty()
|
||||
const row = document.createElement('div')
|
||||
row.className = 'row assistant'
|
||||
const bubble = document.createElement('div')
|
||||
bubble.className = 'bubble typing'
|
||||
bubble.innerHTML = '<span></span><span></span><span></span>'
|
||||
row.appendChild(bubble)
|
||||
thread.appendChild(row)
|
||||
scrollToBottom()
|
||||
return row
|
||||
}
|
||||
|
||||
// textarea 自动 grow
|
||||
function autoGrow() {
|
||||
input.style.height = 'auto'
|
||||
const next = Math.min(input.scrollHeight, 200)
|
||||
input.style.height = next + 'px'
|
||||
}
|
||||
input.addEventListener('input', autoGrow)
|
||||
|
||||
async function send() {
|
||||
const text = input.value.trim()
|
||||
const token = tokenInput.value.trim()
|
||||
if (!text) return
|
||||
if (!token) {
|
||||
addErr('先在上方填 token。')
|
||||
tokenInput.focus()
|
||||
return
|
||||
}
|
||||
input.value = ''
|
||||
autoGrow()
|
||||
history.push({ role: 'user', content: text })
|
||||
addBubble('user', text)
|
||||
sendBtn.disabled = true
|
||||
const dot = addTyping()
|
||||
try {
|
||||
const res = await fetch('/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'token ' + token,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'gemma-4-31b-it',
|
||||
messages: history,
|
||||
}),
|
||||
})
|
||||
const body = await res.text()
|
||||
dot.remove()
|
||||
if (!res.ok) {
|
||||
addErr(`HTTP ${res.status} — ${body || '(空响应)'}`)
|
||||
history.pop()
|
||||
return
|
||||
}
|
||||
let data
|
||||
try {
|
||||
data = JSON.parse(body)
|
||||
} catch {
|
||||
addErr('上游返回非 JSON: ' + body.slice(0, 300))
|
||||
history.pop()
|
||||
return
|
||||
}
|
||||
const reply = data?.choices?.[0]?.message?.content?.trim() || '(空回复)'
|
||||
history.push({ role: 'assistant', content: reply })
|
||||
addBubble('assistant', reply)
|
||||
} catch (e) {
|
||||
dot.remove()
|
||||
addErr('网络错误: ' + e.message)
|
||||
history.pop()
|
||||
} finally {
|
||||
sendBtn.disabled = false
|
||||
input.focus()
|
||||
}
|
||||
}
|
||||
|
||||
sendBtn.addEventListener('click', send)
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
send()
|
||||
}
|
||||
})
|
||||
resetBtn.addEventListener('click', () => {
|
||||
history.length = 0
|
||||
thread.innerHTML = ''
|
||||
thread.appendChild(empty)
|
||||
input.focus()
|
||||
})
|
||||
|
||||
// 自动聚焦:如果已有 token 聚焦输入框,否则聚焦 token 框
|
||||
window.addEventListener('load', () => {
|
||||
if (tokenInput.value) input.focus()
|
||||
else tokenInput.focus()
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#7c3aed"/>
|
||||
<stop offset="100%" stop-color="#06b6d4"/>
|
||||
</linearGradient>
|
||||
<filter id="glow">
|
||||
<feGaussianBlur stdDeviation="1.2"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="60" height="60" rx="14" fill="url(#bg)"/>
|
||||
<text x="32" y="46" text-anchor="middle"
|
||||
font-family="ui-serif, Georgia, 'Times New Roman', serif"
|
||||
font-size="42" font-weight="700" fill="white"
|
||||
style="font-style: italic;">λ</text>
|
||||
<circle cx="49" cy="49" r="6.5" fill="#4ade80" filter="url(#glow)" opacity="0.5"/>
|
||||
<circle cx="49" cy="49" r="4.5" fill="#4ade80"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 756 B |
@@ -14,6 +14,10 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"vite": "^5.4.0"
|
||||
"sharp": "^0.34.5",
|
||||
"vite": "^5.4.0",
|
||||
"vite-plugin-pwa": "^0.20.5",
|
||||
"workbox-build": "^7.1.0",
|
||||
"workbox-window": "^7.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 792 B |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env node
|
||||
// 一次性脚本:把 icon-source.svg 渲染成 PWA 所需的各尺寸 PNG。
|
||||
// 用法: node scripts/gen-icons.mjs (需先 npm i -D sharp)
|
||||
|
||||
import sharp from 'sharp'
|
||||
import { readFileSync, writeFileSync } from 'fs'
|
||||
import { dirname, resolve } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const src = readFileSync(resolve(__dirname, 'icon-source.svg'))
|
||||
const pub = resolve(__dirname, '..', 'public')
|
||||
|
||||
async function render(out, size) {
|
||||
await sharp(src).resize(size, size).png().toFile(resolve(pub, out))
|
||||
console.log('wrote', out)
|
||||
}
|
||||
|
||||
// Maskable 版本:留 ~10% safe-zone padding,避免 Android 圆形遮罩切到音符
|
||||
async function renderMaskable(out, size) {
|
||||
const pad = Math.round(size * 0.1)
|
||||
const inner = size - pad * 2
|
||||
const innerBuf = await sharp(src).resize(inner, inner).png().toBuffer()
|
||||
await sharp({
|
||||
create: { width: size, height: size, channels: 4, background: '#0f0f0f' },
|
||||
})
|
||||
.composite([{ input: innerBuf, top: pad, left: pad }])
|
||||
.png()
|
||||
.toFile(resolve(pub, out))
|
||||
console.log('wrote', out)
|
||||
}
|
||||
|
||||
await render('pwa-192x192.png', 192)
|
||||
await render('pwa-512x512.png', 512)
|
||||
await render('apple-touch-icon-180x180.png', 180)
|
||||
await render('favicon-48x48.png', 48)
|
||||
await renderMaskable('maskable-icon-512x512.png', 512)
|
||||
|
||||
console.log('done')
|
||||
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="96" ry="96" fill="#0f0f0f"/>
|
||||
<g fill="#f5b800">
|
||||
<!-- stem (slight tilt) -->
|
||||
<path d="M302 110 L322 105 L322 360 L302 365 Z"/>
|
||||
<!-- flag: bezier swoop off the stem top -->
|
||||
<path d="M322 105 C 400 130, 430 200, 380 270 C 410 210, 380 160, 322 145 Z"/>
|
||||
<!-- note head: ellipse rotated -22° -->
|
||||
<ellipse cx="250" cy="360" rx="64" ry="46" transform="rotate(-22 250 360)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 534 B |
@@ -0,0 +1,279 @@
|
||||
// 离线缓存 — pieces metadata + audio + chord PNG 都存 IndexedDB。
|
||||
// 不用 Cache API 是因为 IDB 单条删除可控,大文件 Blob 友好。
|
||||
//
|
||||
// 配置(localStorage):
|
||||
// music.cache.enabled 'true' | 'false' 默认 false
|
||||
// music.cache.wifiOnly 'true' | 'false' 默认 true
|
||||
//
|
||||
// 进度向 window 广播 CustomEvent('music-cache-progress', { detail: { done, total, busy } })
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { listPieces, getPiece, attachmentUrl } from './api.js'
|
||||
|
||||
const DB_NAME = 'music-cache'
|
||||
const DB_VERSION = 1
|
||||
const STORE_AUDIO = 'audio' // key: attachment id (number)
|
||||
const STORE_IMAGE = 'image' // key: attachment id
|
||||
const STORE_META = 'meta' // key: 'pieces' | 'updated_at' ...
|
||||
|
||||
let dbPromise = null
|
||||
|
||||
function openDb() {
|
||||
if (dbPromise) return dbPromise
|
||||
dbPromise = new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION)
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result
|
||||
if (!db.objectStoreNames.contains(STORE_AUDIO)) db.createObjectStore(STORE_AUDIO)
|
||||
if (!db.objectStoreNames.contains(STORE_IMAGE)) db.createObjectStore(STORE_IMAGE)
|
||||
if (!db.objectStoreNames.contains(STORE_META)) db.createObjectStore(STORE_META)
|
||||
}
|
||||
req.onsuccess = () => resolve(req.result)
|
||||
req.onerror = () => reject(req.error)
|
||||
})
|
||||
return dbPromise
|
||||
}
|
||||
|
||||
async function idbGet(store, key) {
|
||||
const db = await openDb()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(store, 'readonly')
|
||||
const req = tx.objectStore(store).get(key)
|
||||
req.onsuccess = () => resolve(req.result)
|
||||
req.onerror = () => reject(req.error)
|
||||
})
|
||||
}
|
||||
|
||||
async function idbPut(store, key, value) {
|
||||
const db = await openDb()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(store, 'readwrite')
|
||||
tx.objectStore(store).put(value, key)
|
||||
tx.oncomplete = () => resolve()
|
||||
tx.onerror = () => reject(tx.error)
|
||||
})
|
||||
}
|
||||
|
||||
async function idbDelete(store, key) {
|
||||
const db = await openDb()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(store, 'readwrite')
|
||||
tx.objectStore(store).delete(key)
|
||||
tx.oncomplete = () => resolve()
|
||||
tx.onerror = () => reject(tx.error)
|
||||
})
|
||||
}
|
||||
|
||||
async function idbKeys(store) {
|
||||
const db = await openDb()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(store, 'readonly')
|
||||
const req = tx.objectStore(store).getAllKeys()
|
||||
req.onsuccess = () => resolve(req.result)
|
||||
req.onerror = () => reject(req.error)
|
||||
})
|
||||
}
|
||||
|
||||
async function idbClearAll() {
|
||||
const db = await openDb()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction([STORE_AUDIO, STORE_IMAGE, STORE_META], 'readwrite')
|
||||
tx.objectStore(STORE_AUDIO).clear()
|
||||
tx.objectStore(STORE_IMAGE).clear()
|
||||
tx.objectStore(STORE_META).clear()
|
||||
tx.oncomplete = () => resolve()
|
||||
tx.onerror = () => reject(tx.error)
|
||||
})
|
||||
}
|
||||
|
||||
// ---- 配置 ----
|
||||
|
||||
export function isCacheEnabled() {
|
||||
return localStorage.getItem('music.cache.enabled') === 'true'
|
||||
}
|
||||
export function setCacheEnabled(v) {
|
||||
localStorage.setItem('music.cache.enabled', v ? 'true' : 'false')
|
||||
}
|
||||
export function isWifiOnly() {
|
||||
return localStorage.getItem('music.cache.wifiOnly') !== 'false' // 默认 true
|
||||
}
|
||||
export function setWifiOnly(v) {
|
||||
localStorage.setItem('music.cache.wifiOnly', v ? 'true' : 'false')
|
||||
}
|
||||
|
||||
// 网络感知:不是 WiFi(蜂窝 / 慢速)就跳过
|
||||
function isLikelyMetered() {
|
||||
const c = navigator.connection || navigator.mozConnection || navigator.webkitConnection
|
||||
if (!c) return false // 不知道就当 OK
|
||||
if (c.saveData) return true
|
||||
if (c.type === 'cellular') return true
|
||||
if (['slow-2g', '2g'].includes(c.effectiveType)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
// ---- 媒体取用 ----
|
||||
|
||||
const blobUrlCache = new Map() // attId -> blob URL(避免反复 createObjectURL)
|
||||
|
||||
export async function getCachedBlobUrl(store, attId) {
|
||||
if (blobUrlCache.has(attId)) return blobUrlCache.get(attId)
|
||||
const blob = await idbGet(store, attId)
|
||||
if (!blob) return null
|
||||
const url = URL.createObjectURL(blob)
|
||||
blobUrlCache.set(attId, url)
|
||||
return url
|
||||
}
|
||||
|
||||
// 短路:内存里已有 blob URL → 同步返回;未启用 cache → 直接网络 URL 不查 IDB;
|
||||
// 只有启用 cache 且内存没 cache 命中才掏 IDB
|
||||
export function getAudioUrl(attId) {
|
||||
if (blobUrlCache.has(attId)) return blobUrlCache.get(attId)
|
||||
if (!isCacheEnabled()) return attachmentUrl(attId)
|
||||
// 启用了但内存没缓存:网络立返,后台尝试 IDB 命中后下次会用上
|
||||
warmCachedBlob(STORE_AUDIO, attId)
|
||||
return attachmentUrl(attId)
|
||||
}
|
||||
|
||||
export function getImageUrl(attId) {
|
||||
if (blobUrlCache.has(attId)) return blobUrlCache.get(attId)
|
||||
if (!isCacheEnabled()) return attachmentUrl(attId)
|
||||
warmCachedBlob(STORE_IMAGE, attId)
|
||||
return attachmentUrl(attId)
|
||||
}
|
||||
|
||||
function warmCachedBlob(store, attId) {
|
||||
idbGet(store, attId).then((blob) => {
|
||||
if (blob && !blobUrlCache.has(attId)) {
|
||||
blobUrlCache.set(attId, URL.createObjectURL(blob))
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// ---- 状态 ----
|
||||
|
||||
export const cacheStats = ref({ audioCount: 0, imageCount: 0, busy: false, done: 0, total: 0 })
|
||||
|
||||
async function refreshStats() {
|
||||
const [a, i] = await Promise.all([idbKeys(STORE_AUDIO), idbKeys(STORE_IMAGE)])
|
||||
cacheStats.value.audioCount = a.length
|
||||
cacheStats.value.imageCount = i.length
|
||||
}
|
||||
|
||||
function emitProgress() {
|
||||
window.dispatchEvent(new CustomEvent('music-cache-progress', { detail: { ...cacheStats.value } }))
|
||||
}
|
||||
|
||||
// ---- 下载 worker ----
|
||||
|
||||
let workerRunning = false
|
||||
let workerAbort = null
|
||||
|
||||
async function downloadOne(url, store, key) {
|
||||
const r = await fetch(url, { cache: 'reload' })
|
||||
if (!r.ok) throw new Error(`${r.status}`)
|
||||
const blob = await r.blob()
|
||||
await idbPut(store, key, blob)
|
||||
}
|
||||
|
||||
export async function startCacheWorker() {
|
||||
if (workerRunning) return
|
||||
if (!isCacheEnabled()) return
|
||||
if (isWifiOnly() && isLikelyMetered()) {
|
||||
console.log('[cache] skip: on metered connection')
|
||||
return
|
||||
}
|
||||
// 主动申请永久存储
|
||||
if (navigator.storage && navigator.storage.persist) {
|
||||
try { await navigator.storage.persist() } catch {}
|
||||
}
|
||||
|
||||
workerRunning = true
|
||||
cacheStats.value.busy = true
|
||||
workerAbort = new AbortController()
|
||||
try {
|
||||
// 1) 拉全部 pieces metadata
|
||||
const pieces = await listPieces()
|
||||
await idbPut(STORE_META, 'pieces', pieces)
|
||||
await idbPut(STORE_META, 'updated_at', Date.now())
|
||||
|
||||
// 2) 算出所有要下载的 (store, id) 列表
|
||||
const haveAudio = new Set(await idbKeys(STORE_AUDIO))
|
||||
const haveImage = new Set(await idbKeys(STORE_IMAGE))
|
||||
const targets = []
|
||||
for (const p of pieces) {
|
||||
const detail = await getPiece(p.id)
|
||||
for (const a of detail.attachments || []) {
|
||||
if (a.kind === 'audio' && !haveAudio.has(a.id)) {
|
||||
targets.push({ store: STORE_AUDIO, id: a.id })
|
||||
} else if (a.kind === 'image' && !haveImage.has(a.id)) {
|
||||
targets.push({ store: STORE_IMAGE, id: a.id })
|
||||
}
|
||||
}
|
||||
if (workerAbort.signal.aborted) break
|
||||
}
|
||||
|
||||
cacheStats.value.total = targets.length
|
||||
cacheStats.value.done = 0
|
||||
emitProgress()
|
||||
|
||||
// 3) 串行下载(concurrency=2)
|
||||
const concurrency = 2
|
||||
const queue = [...targets]
|
||||
async function worker() {
|
||||
while (queue.length && !workerAbort.signal.aborted) {
|
||||
if (isWifiOnly() && isLikelyMetered()) break
|
||||
const t = queue.shift()
|
||||
try {
|
||||
await downloadOne(attachmentUrl(t.id), t.store, t.id)
|
||||
} catch (e) {
|
||||
console.warn('[cache] dl failed', t, e)
|
||||
}
|
||||
cacheStats.value.done++
|
||||
emitProgress()
|
||||
await new Promise((r) => setTimeout(r, 80))
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: concurrency }, () => worker()))
|
||||
} catch (e) {
|
||||
console.warn('[cache] worker error', e)
|
||||
} finally {
|
||||
workerRunning = false
|
||||
cacheStats.value.busy = false
|
||||
workerAbort = null
|
||||
await refreshStats()
|
||||
emitProgress()
|
||||
}
|
||||
}
|
||||
|
||||
export function abortCacheWorker() {
|
||||
if (workerAbort) workerAbort.abort()
|
||||
}
|
||||
|
||||
export async function clearCache() {
|
||||
abortCacheWorker()
|
||||
for (const u of blobUrlCache.values()) URL.revokeObjectURL(u)
|
||||
blobUrlCache.clear()
|
||||
await idbClearAll()
|
||||
await refreshStats()
|
||||
emitProgress()
|
||||
}
|
||||
|
||||
// ---- 存储用量 ----
|
||||
|
||||
export async function estimateUsage() {
|
||||
if (navigator.storage && navigator.storage.estimate) {
|
||||
const e = await navigator.storage.estimate()
|
||||
return { usage: e.usage || 0, quota: e.quota || 0 }
|
||||
}
|
||||
return { usage: 0, quota: 0 }
|
||||
}
|
||||
|
||||
// ---- 启动入口 ----
|
||||
|
||||
export async function initCache() {
|
||||
await refreshStats()
|
||||
if (isCacheEnabled()) {
|
||||
// 不阻塞主线程,延后 3s 让 app 先 render
|
||||
setTimeout(() => startCacheWorker(), 3000)
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,14 @@ import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router/index.js'
|
||||
import { registerPwa } from './pwa.js'
|
||||
import { initCache } from './lib/cache.js'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
|
||||
registerPwa()
|
||||
initCache() // 启动时按配置决定是否后台缓存
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// 注册 service worker(如果浏览器支持 + 不在 dev 模式下)。
|
||||
// vite-plugin-pwa injectManifest 模式:build 后 dist/sw.js 是处理过的 worker。
|
||||
import { registerSW } from 'virtual:pwa-register'
|
||||
|
||||
export function registerPwa() {
|
||||
if (typeof window === 'undefined') return
|
||||
if (!('serviceWorker' in navigator)) return
|
||||
registerSW({
|
||||
immediate: true,
|
||||
onRegisteredSW(_swUrl, _r) {
|
||||
console.log('[pwa] sw registered')
|
||||
},
|
||||
onOfflineReady() {
|
||||
console.log('[pwa] offline ready')
|
||||
},
|
||||
onNeedRefresh() {
|
||||
console.log('[pwa] update available')
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -23,6 +23,11 @@ const routes = [
|
||||
component: () => import('../views/EditView.vue'),
|
||||
props: (route) => ({ id: Number(route.params.id) }),
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'settings',
|
||||
component: () => import('../views/SettingsView.vue'),
|
||||
},
|
||||
]
|
||||
|
||||
export default createRouter({
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/// <reference lib="webworker" />
|
||||
// Music PWA service worker(injectManifest 模式)。
|
||||
// 只 precache app shell(HTML/JS/CSS/icon),媒体(audio + chord PNG)走前端 IDB
|
||||
// 显式缓存(lib/cache.js),不让 SW 自动 cache 大文件避免 quota 失控。
|
||||
|
||||
const MANIFEST = self.__WB_MANIFEST || []
|
||||
|
||||
function hashStr(s) {
|
||||
let h = 5381
|
||||
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0
|
||||
return h.toString(36)
|
||||
}
|
||||
const VERSION = hashStr(MANIFEST.map((e) => `${e.url}@${e.revision || ''}`).join('|'))
|
||||
const CACHE = `music-shell-${VERSION}`
|
||||
|
||||
const INDEX = new URL('index.html', self.location.href).href
|
||||
const URLS = Array.from(new Set([INDEX, ...MANIFEST.map((e) => new URL(e.url, self.location.href).href)]))
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const cache = await caches.open(CACHE)
|
||||
// 并发 addAll,HTTP/2 多路复用,一次过;失败回退串行
|
||||
try {
|
||||
await cache.addAll(URLS)
|
||||
} catch {
|
||||
for (const u of URLS) {
|
||||
try {
|
||||
const r = await fetch(u, { cache: 'reload' })
|
||||
if (r.ok) await cache.put(u, r)
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
await self.skipWaiting()
|
||||
})(),
|
||||
)
|
||||
})
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const keys = await caches.keys()
|
||||
await Promise.all(
|
||||
keys.filter((k) => k.startsWith('music-shell-') && k !== CACHE).map((k) => caches.delete(k)),
|
||||
)
|
||||
await self.clients.claim()
|
||||
})(),
|
||||
)
|
||||
})
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const req = event.request
|
||||
if (req.method !== 'GET') return
|
||||
const url = new URL(req.url)
|
||||
// 只接管同源 GET,跳过 /api/*(前端 cache.js 自己管)
|
||||
if (url.origin !== self.location.origin) return
|
||||
if (url.pathname.startsWith('/api/')) return
|
||||
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
const cache = await caches.open(CACHE)
|
||||
// 导航请求总是回 index.html,让 SPA 路由跑(离线也能进任何 route)
|
||||
if (req.mode === 'navigate') {
|
||||
const cached = await cache.match(INDEX)
|
||||
if (cached) return cached
|
||||
}
|
||||
const cached = await cache.match(req)
|
||||
if (cached) return cached
|
||||
try {
|
||||
const fresh = await fetch(req)
|
||||
if (fresh.ok) cache.put(req, fresh.clone()).catch(() => {})
|
||||
return fresh
|
||||
} catch (e) {
|
||||
// 离线 + 没缓存
|
||||
return new Response('offline', { status: 503 })
|
||||
}
|
||||
})(),
|
||||
)
|
||||
})
|
||||
@@ -11,6 +11,7 @@
|
||||
<span class="count">{{ filtered.length }} / {{ pieces.length }} 首</span>
|
||||
<button class="btn-inspire" title="今天练什么?" @click="openInspire">💡</button>
|
||||
<router-link to="/upload" class="btn-add" title="新增曲目">+</router-link>
|
||||
<router-link to="/settings" class="btn-settings" title="设置">⚙</router-link>
|
||||
</header>
|
||||
|
||||
<nav class="filterbar">
|
||||
@@ -43,6 +44,13 @@
|
||||
:class="{ active: activeTagName === t.name }"
|
||||
@click="setTag(t.name)"
|
||||
>{{ t.name }}<span class="chip-n">{{ t.count }}</span></button>
|
||||
|
||||
<button
|
||||
class="chip fav-chip"
|
||||
:class="{ active: favOnly }"
|
||||
:title="favOnly ? '显示全部' : '仅看收藏'"
|
||||
@click="toggleFavOnly"
|
||||
>{{ favOnly ? '★ 仅看收藏' : '☆ 仅看收藏' }}</button>
|
||||
</nav>
|
||||
|
||||
<div class="main">
|
||||
@@ -67,9 +75,14 @@
|
||||
class="row"
|
||||
:class="{ active: selectedId === p.id }"
|
||||
@click="selectPiece(p.id)"
|
||||
@dblclick="playPiece(p.id)"
|
||||
title="单击切换 / 双击切换并播放"
|
||||
>
|
||||
<div class="row-main">
|
||||
<div class="row-title">{{ p.title }}</div>
|
||||
<div class="row-title">
|
||||
<span v-if="p.favorite" class="row-fav" title="已收藏">★</span>
|
||||
{{ p.title }}
|
||||
</div>
|
||||
<div class="row-meta">
|
||||
<span v-if="p.artist">{{ p.artist }}</span>
|
||||
<span v-if="p.category" class="cat">{{ p.category }}</span>
|
||||
@@ -92,7 +105,15 @@
|
||||
|
||||
<template v-else>
|
||||
<header class="now-playing">
|
||||
<h2>{{ selected.title }}</h2>
|
||||
<h2>
|
||||
<button
|
||||
class="fav-btn"
|
||||
:class="{ on: selected.favorite }"
|
||||
:title="selected.favorite ? '取消收藏' : '收藏'"
|
||||
@click="toggleFavorite"
|
||||
>{{ selected.favorite ? '★' : '☆' }}</button>
|
||||
{{ selected.title }}
|
||||
</h2>
|
||||
<div class="np-sub">
|
||||
<span v-if="selected.artist">{{ selected.artist }}</span>
|
||||
<span v-if="selected.category">· {{ selected.category }}</span>
|
||||
@@ -386,6 +407,7 @@ import {
|
||||
streamChat,
|
||||
streamInspire,
|
||||
} from '../lib/api.js'
|
||||
import { getAudioUrl } from '../lib/cache.js'
|
||||
import { parseLrc } from '../lib/lrc.js'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -404,6 +426,7 @@ const activeTagName = ref(null)
|
||||
|
||||
const search = ref('')
|
||||
const sortMode = ref(localStorage.getItem('music.sort') || 'name')
|
||||
const favOnly = ref(localStorage.getItem('music.favOnly') === 'true')
|
||||
const repeatOne = ref(false)
|
||||
const volume = ref(parseFloat(localStorage.getItem('music.vol') || '1'))
|
||||
const muted = ref(localStorage.getItem('music.muted') === '1')
|
||||
@@ -636,6 +659,9 @@ const tabs = computed(() => {
|
||||
const filtered = computed(() => {
|
||||
const q = search.value.trim().toLowerCase()
|
||||
let arr = pieces.value
|
||||
if (favOnly.value) {
|
||||
arr = arr.filter(p => p.favorite)
|
||||
}
|
||||
if (q) {
|
||||
arr = arr.filter(p => {
|
||||
const hay = `${p.title} ${p.artist || ''} ${p.category || ''} ${(p.tags || []).join(' ')}`.toLowerCase()
|
||||
@@ -643,6 +669,7 @@ const filtered = computed(() => {
|
||||
})
|
||||
}
|
||||
arr = [...arr]
|
||||
// 名称排序时,收藏的自然置顶;其它模式按用户选的指标排,不强行打断
|
||||
switch (sortMode.value) {
|
||||
case 'hot':
|
||||
arr.sort((a, b) => b.play_count - a.play_count || a.title.localeCompare(b.title, 'zh'))
|
||||
@@ -664,11 +691,21 @@ const filtered = computed(() => {
|
||||
break
|
||||
}
|
||||
default:
|
||||
arr.sort((a, b) => a.title.localeCompare(b.title, 'zh'))
|
||||
arr.sort((a, b) => {
|
||||
const fa = a.favorite ? 1 : 0
|
||||
const fb = b.favorite ? 1 : 0
|
||||
if (fa !== fb) return fb - fa
|
||||
return a.title.localeCompare(b.title, 'zh')
|
||||
})
|
||||
}
|
||||
return arr
|
||||
})
|
||||
|
||||
function toggleFavOnly() {
|
||||
favOnly.value = !favOnly.value
|
||||
localStorage.setItem('music.favOnly', favOnly.value ? 'true' : 'false')
|
||||
}
|
||||
|
||||
function hash(id, seed) {
|
||||
let x = (id ^ Math.floor(seed * 1e9)) >>> 0
|
||||
x = (x ^ (x << 13)) >>> 0
|
||||
@@ -740,6 +777,10 @@ async function promptNewPlaylist() {
|
||||
}
|
||||
|
||||
async function loadPiece(id) {
|
||||
// 切歌前记下当前是否在播 + tab 在哪 —— 整理 notes / 看和弦谱 时不打扰
|
||||
const wasPlaying = !!(audioEl.value && !audioEl.value.paused && !audioEl.value.ended)
|
||||
const stickyTab = activeTab.value // 保持用户当前看的 tab(如果新 piece 也有)
|
||||
|
||||
selected.value = null
|
||||
notesDraft.value = ''
|
||||
// 切歌清 AB Loop(rate 保留全局)
|
||||
@@ -757,15 +798,22 @@ async function loadPiece(id) {
|
||||
selected.value = p
|
||||
notesDraft.value = p.notes || ''
|
||||
selectedId.value = p.id
|
||||
// tab 保持:sticky 在新 piece 也存在就用它,否则用第一个
|
||||
const t = tabs.value
|
||||
if (!t.find(x => x.key === activeTab.value)) {
|
||||
if (t.find(x => x.key === stickyTab)) {
|
||||
activeTab.value = stickyTab
|
||||
} else {
|
||||
activeTab.value = t[0]?.key || 'lyrics'
|
||||
}
|
||||
await nextTick()
|
||||
const first = audioAttachments.value[0]
|
||||
if (first && audioEl.value) {
|
||||
audioEl.value.src = attUrl(first.id)
|
||||
audioEl.value.src = getAudioUrl(first.id)
|
||||
// 续播条件:之前正在播 / 双击强制要求 / forceNextPlay flag
|
||||
if (wasPlaying || forceNextPlay) {
|
||||
audioEl.value.play().catch(() => {})
|
||||
}
|
||||
forceNextPlay = false
|
||||
} else if (audioEl.value) {
|
||||
audioEl.value.removeAttribute('src')
|
||||
audioEl.value.load()
|
||||
@@ -794,6 +842,18 @@ function selectPiece(id) {
|
||||
router.push({ name: 'piece', params: { id } })
|
||||
}
|
||||
|
||||
let forceNextPlay = false
|
||||
function playPiece(id) {
|
||||
forceNextPlay = true
|
||||
// 同 piece dblclick:URL 不变 watch 不触发 → 直接 play
|
||||
if (id === selectedId.value && audioEl.value) {
|
||||
audioEl.value.play().catch(() => {})
|
||||
forceNextPlay = false
|
||||
return
|
||||
}
|
||||
selectPiece(id)
|
||||
}
|
||||
|
||||
function attachmentUrl(id) { return attUrl(id) }
|
||||
|
||||
function togglePlay() {
|
||||
@@ -894,6 +954,22 @@ function setTab(k) {
|
||||
activeTab.value = k
|
||||
}
|
||||
|
||||
async function toggleFavorite() {
|
||||
if (!selectedId.value || !selected.value) return
|
||||
const next = !selected.value.favorite
|
||||
selected.value.favorite = next // optimistic
|
||||
const inList = pieces.value.find(p => p.id === selectedId.value)
|
||||
if (inList) inList.favorite = next
|
||||
try {
|
||||
await patchPiece(selectedId.value, { favorite: next })
|
||||
} catch (e) {
|
||||
// 回滚
|
||||
selected.value.favorite = !next
|
||||
if (inList) inList.favorite = !next
|
||||
alert(e.message || String(e))
|
||||
}
|
||||
}
|
||||
|
||||
// notes auto-save
|
||||
function onNotesInput() {
|
||||
if (!selectedId.value) return
|
||||
@@ -1233,6 +1309,14 @@ onBeforeUnmount(() => {
|
||||
text-decoration: none;
|
||||
}
|
||||
.topbar .btn-add:hover { background: var(--accent); text-decoration: none; }
|
||||
.topbar .btn-settings {
|
||||
width: 36px; height: 36px; border-radius: 50%;
|
||||
background: var(--bg-elev); color: var(--text-dim);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 16px; text-decoration: none;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.topbar .btn-settings:hover { background: var(--bg-hover); color: var(--text); text-decoration: none; }
|
||||
|
||||
.filterbar {
|
||||
display: flex;
|
||||
@@ -1319,6 +1403,17 @@ onBeforeUnmount(() => {
|
||||
border-color: var(--accent-strong);
|
||||
color: var(--accent);
|
||||
}
|
||||
.fav-chip.active {
|
||||
color: #f5b800 !important;
|
||||
border-color: #f5b800 !important;
|
||||
background: rgba(245, 184, 0, 0.14) !important;
|
||||
}
|
||||
|
||||
.row-fav {
|
||||
color: #f5b800;
|
||||
margin-right: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.playlist { flex: 1; overflow-y: auto; }
|
||||
.hint { padding: 40px 20px; text-align: center; color: var(--text-mute); font-size: 14px; }
|
||||
@@ -1410,7 +1505,23 @@ onBeforeUnmount(() => {
|
||||
font-size: 22px;
|
||||
color: var(--accent);
|
||||
margin-bottom: 4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.fav-btn {
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
color: var(--text-mute);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
transition: color 0.15s, transform 0.05s;
|
||||
}
|
||||
.fav-btn:hover { color: #f5b800; }
|
||||
.fav-btn.on { color: #f5b800; }
|
||||
.fav-btn:active { transform: scale(0.85); }
|
||||
.np-sub {
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<header class="bar">
|
||||
<router-link to="/" class="back">← 返回</router-link>
|
||||
<h1>设置</h1>
|
||||
</header>
|
||||
|
||||
<main class="body">
|
||||
<section class="block">
|
||||
<h2>离线缓存</h2>
|
||||
<p class="desc">
|
||||
把所有曲目的 audio 和谱面 PNG 异步下载到浏览器 IndexedDB。下载完没网也能播放、看谱。
|
||||
默认 <b>关</b>。整库约 1.5 GB,移动网络下慎开。
|
||||
</p>
|
||||
|
||||
<label class="row">
|
||||
<span class="lbl">启用自动缓存</span>
|
||||
<input type="checkbox" v-model="enabled" @change="onEnabled" />
|
||||
</label>
|
||||
|
||||
<label class="row" :class="{ disabled: !enabled }">
|
||||
<span class="lbl">仅 WiFi 时下载</span>
|
||||
<input type="checkbox" v-model="wifiOnly" :disabled="!enabled" @change="onWifi" />
|
||||
</label>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
<span class="key">已缓存 audio</span>
|
||||
<span class="val">{{ stats.audioCount }} 个</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="key">已缓存谱面</span>
|
||||
<span class="val">{{ stats.imageCount }} 个</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="key">磁盘占用</span>
|
||||
<span class="val">{{ fmtMb(usage.usage) }} / {{ fmtMb(usage.quota) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="stats.busy" class="progress">
|
||||
<div class="bar-bg">
|
||||
<div class="bar-fill" :style="{ width: pct + '%' }"></div>
|
||||
</div>
|
||||
<div class="progress-text">下载中 {{ stats.done }} / {{ stats.total }} ({{ pct }}%)</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn" :disabled="stats.busy || !enabled" @click="onStart">
|
||||
{{ stats.busy ? '运行中…' : '立即开始下载' }}
|
||||
</button>
|
||||
<button class="btn" :disabled="!stats.busy" @click="onStop">暂停</button>
|
||||
<button class="btn danger" :disabled="stats.busy" @click="onClear">清空所有缓存</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<h2>关于</h2>
|
||||
<p class="desc">
|
||||
PWA 可"加到主屏幕"作为独立 app 启动。<br/>
|
||||
数据用 IndexedDB,可在浏览器开发者工具的 Application → IndexedDB → music-cache 看到详细条目。<br/>
|
||||
清空缓存只删本地数据,不影响服务端。
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import {
|
||||
isCacheEnabled, setCacheEnabled,
|
||||
isWifiOnly, setWifiOnly,
|
||||
startCacheWorker, abortCacheWorker, clearCache,
|
||||
estimateUsage, cacheStats,
|
||||
} from '../lib/cache.js'
|
||||
|
||||
const enabled = ref(isCacheEnabled())
|
||||
const wifiOnly = ref(isWifiOnly())
|
||||
const stats = cacheStats
|
||||
const usage = ref({ usage: 0, quota: 0 })
|
||||
|
||||
const pct = ref(0)
|
||||
|
||||
function onEnabled() {
|
||||
setCacheEnabled(enabled.value)
|
||||
if (enabled.value) startCacheWorker()
|
||||
else abortCacheWorker()
|
||||
}
|
||||
function onWifi() {
|
||||
setWifiOnly(wifiOnly.value)
|
||||
}
|
||||
function onStart() {
|
||||
if (!enabled.value) {
|
||||
enabled.value = true
|
||||
setCacheEnabled(true)
|
||||
}
|
||||
startCacheWorker()
|
||||
}
|
||||
function onStop() {
|
||||
abortCacheWorker()
|
||||
}
|
||||
async function onClear() {
|
||||
if (!confirm('确认清空所有离线缓存?')) return
|
||||
await clearCache()
|
||||
await refreshUsage()
|
||||
}
|
||||
|
||||
async function refreshUsage() {
|
||||
usage.value = await estimateUsage()
|
||||
pct.value = stats.value.total > 0
|
||||
? Math.round((stats.value.done / stats.value.total) * 100)
|
||||
: 0
|
||||
}
|
||||
|
||||
function onProgress() {
|
||||
pct.value = stats.value.total > 0
|
||||
? Math.round((stats.value.done / stats.value.total) * 100)
|
||||
: 0
|
||||
refreshUsage()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshUsage()
|
||||
window.addEventListener('music-cache-progress', onProgress)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('music-cache-progress', onProgress)
|
||||
})
|
||||
|
||||
function fmtMb(b) {
|
||||
if (!b) return '?'
|
||||
if (b < 1024 * 1024) return (b / 1024).toFixed(0) + ' KB'
|
||||
if (b < 1024 * 1024 * 1024) return (b / 1024 / 1024).toFixed(1) + ' MB'
|
||||
return (b / 1024 / 1024 / 1024).toFixed(2) + ' GB'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { min-height: 100%; display: flex; flex-direction: column; background: var(--bg); }
|
||||
.bar {
|
||||
display: flex; align-items: center; gap: 18px;
|
||||
padding: 14px 22px;
|
||||
background: var(--bg-card);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
.bar h1 { font-size: 18px; font-weight: 600; }
|
||||
.back { color: var(--text-dim); font-size: 14px; }
|
||||
.back:hover { color: var(--accent); text-decoration: none; }
|
||||
|
||||
.body { max-width: 720px; margin: 0 auto; padding: 24px 22px 80px; width: 100%; }
|
||||
.block {
|
||||
background: var(--bg-card);
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.block h2 {
|
||||
font-size: 14px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.desc { color: var(--text-dim); font-size: 13px; line-height: 1.7; margin-bottom: 14px; }
|
||||
.desc b { color: var(--accent); }
|
||||
|
||||
.row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
.row:last-of-type { border-bottom: none; }
|
||||
.row.disabled { opacity: 0.5; }
|
||||
.row .lbl { font-size: 14px; }
|
||||
.row input[type=checkbox] { width: 18px; height: 18px; cursor: pointer; }
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 10px;
|
||||
margin: 14px 0;
|
||||
}
|
||||
.stat {
|
||||
background: var(--bg-elev);
|
||||
border-radius: 6px;
|
||||
padding: 10px 14px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.stat .key { color: var(--text-mute); display: block; margin-bottom: 4px; }
|
||||
.stat .val { color: var(--text); font-weight: 600; }
|
||||
|
||||
.progress { margin: 14px 0; }
|
||||
.bar-bg {
|
||||
height: 6px; background: var(--bg-elev); border-radius: 3px; overflow: hidden;
|
||||
}
|
||||
.bar-fill {
|
||||
height: 100%; background: var(--accent-strong); transition: width 0.3s;
|
||||
}
|
||||
.progress-text { font-size: 11px; color: var(--text-mute); margin-top: 6px; }
|
||||
|
||||
.actions { display: flex; gap: 10px; margin-top: 14px; flex-wrap: wrap; }
|
||||
.btn {
|
||||
font-size: 13px; padding: 8px 16px; border-radius: 6px;
|
||||
background: var(--accent-strong); color: #fff; font-weight: 600;
|
||||
}
|
||||
.btn:hover:not(:disabled) { background: var(--accent); }
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.btn.danger { background: rgba(239,68,68,0.15); color: var(--accent-red); }
|
||||
.btn.danger:hover:not(:disabled) { background: rgba(239,68,68,0.3); }
|
||||
</style>
|
||||
@@ -1,11 +1,40 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
plugins: [
|
||||
vue(),
|
||||
VitePWA({
|
||||
strategies: 'injectManifest',
|
||||
srcDir: 'src',
|
||||
filename: 'sw.js',
|
||||
injectRegister: false, // 注册由 src/pwa.js 手动处理
|
||||
injectManifest: {
|
||||
globPatterns: ['**/*.{js,css,html,svg,png,woff2}'],
|
||||
},
|
||||
includeAssets: ['favicon-48x48.png', 'apple-touch-icon-180x180.png'],
|
||||
manifest: {
|
||||
name: 'Music · Euphon',
|
||||
short_name: 'Music',
|
||||
description: '听歌 + 练琴 曲目管理(离线缓存可选)',
|
||||
lang: 'zh-CN',
|
||||
theme_color: '#0f0f0f',
|
||||
background_color: '#0f0f0f',
|
||||
display: 'standalone',
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
icons: [
|
||||
{ src: 'pwa-192x192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png' },
|
||||
{ src: 'maskable-icon-512x512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8080'
|
||||
}
|
||||
}
|
||||
'/api': 'http://localhost:8080',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -135,6 +135,11 @@ async fn main() -> std::io::Result<()> {
|
||||
CREATE INDEX IF NOT EXISTS idx_pp_piece ON playlist_pieces(piece_id);",
|
||||
)
|
||||
.expect("init schema");
|
||||
// 兼容旧 db:增量加 favorite 列
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE pieces ADD COLUMN favorite INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
tracing::info!(%db_path, blobs = %blobs_dir.display(), "music ready");
|
||||
|
||||
let chord_url =
|
||||
@@ -216,6 +221,7 @@ struct PieceSummary {
|
||||
kinds: Vec<String>,
|
||||
tags: Vec<String>,
|
||||
has_lyrics: bool,
|
||||
favorite: bool,
|
||||
created_at: String,
|
||||
}
|
||||
|
||||
@@ -232,6 +238,7 @@ struct PieceDetail {
|
||||
created_at: String,
|
||||
attachments: Vec<Attachment>,
|
||||
tags: Vec<String>,
|
||||
favorite: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -271,6 +278,7 @@ struct PatchPiece {
|
||||
tags: Option<Vec<String>>,
|
||||
/// admin / import 用:直接写 play_count(mvp 无认证)
|
||||
play_count: Option<i64>,
|
||||
favorite: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
@@ -312,12 +320,13 @@ async fn list_pieces(
|
||||
CASE WHEN p.lyrics IS NOT NULL AND length(p.lyrics) > 0 THEN 1 ELSE 0 END AS has_lyrics,
|
||||
COALESCE((SELECT GROUP_CONCAT(t.name, char(9))
|
||||
FROM piece_tags pt2 JOIN tags t ON t.id = pt2.tag_id
|
||||
WHERE pt2.piece_id = p.id), '') AS tags
|
||||
WHERE pt2.piece_id = p.id), '') AS tags,
|
||||
COALESCE(p.favorite, 0) AS favorite
|
||||
FROM pieces p
|
||||
{filter_join}
|
||||
{filter_where}
|
||||
GROUP BY p.id
|
||||
ORDER BY p.title COLLATE NOCASE ASC, p.id ASC"
|
||||
ORDER BY p.favorite DESC, p.title COLLATE NOCASE ASC, p.id ASC"
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let bind_refs: Vec<&dyn rusqlite::ToSql> = bind.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
|
||||
@@ -336,6 +345,7 @@ async fn list_pieces(
|
||||
tags_raw.split('\t').map(|x| x.to_string()).collect()
|
||||
};
|
||||
let has_lyrics: i64 = r.get(9)?;
|
||||
let fav: i64 = r.get(11)?;
|
||||
Ok(PieceSummary {
|
||||
id: r.get(0)?,
|
||||
title: r.get(1)?,
|
||||
@@ -348,6 +358,7 @@ async fn list_pieces(
|
||||
kinds,
|
||||
tags,
|
||||
has_lyrics: has_lyrics != 0,
|
||||
favorite: fav != 0,
|
||||
})
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
@@ -392,10 +403,12 @@ async fn get_piece(
|
||||
i64,
|
||||
Option<String>,
|
||||
String,
|
||||
i64,
|
||||
);
|
||||
let row: Option<PieceRow> = conn
|
||||
.query_row(
|
||||
"SELECT title, artist, category, notes, lyrics, play_count, last_played_at, created_at
|
||||
"SELECT title, artist, category, notes, lyrics, play_count, last_played_at, created_at,
|
||||
COALESCE(favorite, 0)
|
||||
FROM pieces WHERE id = ?1",
|
||||
params![id],
|
||||
|r| {
|
||||
@@ -408,11 +421,12 @@ async fn get_piece(
|
||||
r.get(5)?,
|
||||
r.get(6)?,
|
||||
r.get(7)?,
|
||||
r.get(8)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.optional()?;
|
||||
let (title, artist, category, notes, lyrics, play_count, last_played_at, created_at) =
|
||||
let (title, artist, category, notes, lyrics, play_count, last_played_at, created_at, favorite) =
|
||||
row.ok_or(AppError::NotFound)?;
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
@@ -456,6 +470,7 @@ async fn get_piece(
|
||||
created_at,
|
||||
attachments,
|
||||
tags,
|
||||
favorite: favorite != 0,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -516,6 +531,12 @@ async fn patch_piece(
|
||||
params![pc, id],
|
||||
)?;
|
||||
}
|
||||
if let Some(fav) = body.favorite {
|
||||
conn.execute(
|
||||
"UPDATE pieces SET favorite = ?1 WHERE id = ?2",
|
||||
params![fav as i64, id],
|
||||
)?;
|
||||
}
|
||||
if let Some(tags) = body.tags {
|
||||
conn.execute(
|
||||
"DELETE FROM piece_tags WHERE piece_id = ?1",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "notes"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
description = "notes.famzheng.me — 录音上传 → ASR 转写 → LLM 生成会议纪要"
|
||||
|
||||
[dependencies]
|
||||
cube-core = { path = "../../crates/cube-core" }
|
||||
axum = { workspace = true, features = ["multipart"] }
|
||||
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 }
|
||||
@@ -0,0 +1,5 @@
|
||||
FROM scratch
|
||||
COPY target/x86_64-unknown-linux-musl/release/notes /notes
|
||||
COPY apps/notes/frontend/dist /dist
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/notes"]
|
||||
@@ -0,0 +1,25 @@
|
||||
# notes feishu sidecar:跑 markdown-to-feishu 把会议纪要 push 飞书 docx。
|
||||
# 跟 notes 主容器同 pod、共享 PVC(看到主容器在 /data/feishu-tmp/<id>/ 写好的 md + 附件)。
|
||||
|
||||
FROM node:20-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 python3-pip python3-markdown ca-certificates curl ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# lark-cli postinstall 调 curl 下二进制,没 curl 会报 spawnSync ENOENT
|
||||
RUN npm install -g @larksuite/cli@1.0.29
|
||||
|
||||
RUN pip install --no-cache-dir --break-system-packages \
|
||||
fastapi==0.115.6 \
|
||||
uvicorn==0.34.0 \
|
||||
requests==2.32.3
|
||||
|
||||
COPY markdown-to-feishu /usr/local/bin/markdown-to-feishu
|
||||
RUN chmod +x /usr/local/bin/markdown-to-feishu
|
||||
COPY server.py /app/server.py
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
WORKDIR /app
|
||||
EXPOSE 8002
|
||||
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8002"]
|
||||
@@ -0,0 +1,970 @@
|
||||
#!/usr/bin/env python3
|
||||
"""markdown-to-feishu — convert a Markdown file (with rich embeds) into a Feishu
|
||||
docx, using the lark-cli wrapper. Tables, images (URL + local), Mermaid /
|
||||
PlantUML diagrams, and arbitrary attachments (PDF / CSV / log / anything) all
|
||||
get planted as real DocxXML blocks. Re-runs against the same .md by default
|
||||
update the previously-created doc instead of spawning a new one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import html as html_lib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
import time
|
||||
import uuid
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import markdown
|
||||
|
||||
|
||||
STATE_DIR = Path(os.environ.get("MD2FEISHU_STATE_DIR", str(Path.home() / ".local/share/markdown-to-feishu")))
|
||||
STATE_FILE = STATE_DIR / "state.json"
|
||||
|
||||
SENTINEL_PREFIX = "MD2FEISHU_SENTINEL"
|
||||
|
||||
VERSION = "0.1.0"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State (markdown abs path -> doc id) so re-runs update in place
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def load_state() -> dict:
|
||||
if not STATE_FILE.exists():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(STATE_FILE.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
|
||||
|
||||
def save_state(state: dict) -> None:
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
STATE_FILE.write_text(json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# lark-cli runner
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class LarkError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def run_lark(args: list[str], *, stdin: str | None = None, identity: str = "user", verbose: bool = False, cwd: str | None = None) -> dict:
|
||||
cmd = ["lark-cli", "--as", identity] + args
|
||||
if verbose:
|
||||
cwd_note = f" (cwd={cwd})" if cwd else ""
|
||||
sys.stderr.write(f"[lark] {' '.join(cmd)}{cwd_note}\n")
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
input=stdin,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=cwd,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise LarkError(
|
||||
f"lark-cli failed (exit {proc.returncode}): {' '.join(cmd)}\n"
|
||||
f"stderr: {proc.stderr.strip()}\n"
|
||||
f"stdout: {proc.stdout.strip()}"
|
||||
)
|
||||
if not proc.stdout.strip():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(proc.stdout)
|
||||
except json.JSONDecodeError:
|
||||
return {"_raw": proc.stdout}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Markdown helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def is_http_url(s: str) -> bool:
|
||||
p = urlparse(s)
|
||||
return p.scheme in ("http", "https")
|
||||
|
||||
|
||||
def is_anchor(s: str) -> bool:
|
||||
return s.startswith("#")
|
||||
|
||||
|
||||
def preprocess_markdown(text: str) -> str:
|
||||
"""Handle GFM extras python-markdown core misses."""
|
||||
# Strip BOM
|
||||
if text.startswith(""):
|
||||
text = text[1:]
|
||||
out_lines: list[str] = []
|
||||
in_fence = False
|
||||
fence_re = re.compile(r"^\s*```")
|
||||
strike_re = re.compile(r"~~(\S(?:.*?\S)?)~~")
|
||||
# GFM task-list items at top level: "- [x] text" / "* [ ] text" / "1. [x] text"
|
||||
# Convert to a stand-alone HTML <checkbox> block so python-markdown passes
|
||||
# it through. Leading whitespace becomes a marker (so nested checkboxes
|
||||
# don't get hoisted to top level).
|
||||
task_re = re.compile(r"^(\s*)(?:[-*+]|\d+\.)\s+\[([ xX])\]\s+(.*)$")
|
||||
for line in text.split("\n"):
|
||||
if fence_re.match(line):
|
||||
in_fence = not in_fence
|
||||
out_lines.append(line)
|
||||
continue
|
||||
if in_fence:
|
||||
out_lines.append(line)
|
||||
continue
|
||||
m = task_re.match(line)
|
||||
if m and not m.group(1): # top-level only; nested stays a list item
|
||||
done = "true" if m.group(2).lower() == "x" else "false"
|
||||
body = m.group(3).strip()
|
||||
# Surround with blank lines so it parses as raw HTML block
|
||||
out_lines.append("")
|
||||
out_lines.append(f'<checkbox done="{done}">{html_lib.escape(body)}</checkbox>')
|
||||
out_lines.append("")
|
||||
continue
|
||||
out_lines.append(strike_re.sub(r"<del>\1</del>", line))
|
||||
return "\n".join(out_lines)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTML -> DocxXML converter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
INLINE_TAGS = {"a", "b", "strong", "em", "i", "u", "del", "s", "strike", "code", "span", "br", "img", "cite", "latex"}
|
||||
BLOCK_PASSTHROUGH = {"p", "h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8", "h9", "hr", "br"}
|
||||
|
||||
|
||||
def xml_escape_text(s: str) -> str:
|
||||
return s.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
|
||||
def xml_escape_attr(s: str) -> str:
|
||||
return xml_escape_text(s).replace('"', """)
|
||||
|
||||
|
||||
class DocxXMLBuilder(HTMLParser):
|
||||
"""Walks python-markdown HTML and emits DocxXML.
|
||||
|
||||
Local images / attachments / non-inline-able media become placeholder
|
||||
<p>SENTINEL</p> paragraphs; each one is recorded in ``self.embeds`` so the
|
||||
caller can media-insert the real file in the correct position afterwards.
|
||||
"""
|
||||
|
||||
def __init__(self, md_dir: Path, session_tag: str):
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.md_dir = md_dir
|
||||
self.session_tag = session_tag
|
||||
self.out: list[str] = []
|
||||
self.embeds: list[dict] = [] # {sentinel, file, type, caption}
|
||||
self._code_buf: list[str] | None = None
|
||||
self._code_lang: str | None = None
|
||||
self._table_buf: list[str] | None = None # we buffer the entire table so colspan/rowspan etc. just round-trip
|
||||
self._table_depth = 0
|
||||
self._in_pre = False
|
||||
self._inline_stack: list[str] = []
|
||||
self._li_stack: list[str] = [] # track ul/ol type for current li
|
||||
self._blockquote_depth = 0
|
||||
self._p_depth = 0 # how many <p> are currently open in our output stream
|
||||
|
||||
# ---- sentinel handling ----
|
||||
def _next_sentinel(self) -> str:
|
||||
n = len(self.embeds)
|
||||
# All caps + underscores so it never collides with normal markdown prose
|
||||
return f"{SENTINEL_PREFIX}_{self.session_tag}_{n:04d}"
|
||||
|
||||
def _resolve_local(self, src: str) -> Path | None:
|
||||
# Strip query/fragment for sanity
|
||||
clean = src.split("#", 1)[0].split("?", 1)[0]
|
||||
if not clean or is_http_url(clean) or is_anchor(clean):
|
||||
return None
|
||||
p = Path(clean)
|
||||
if not p.is_absolute():
|
||||
p = (self.md_dir / p).resolve()
|
||||
return p if p.exists() and p.is_file() else None
|
||||
|
||||
# ---- emit helpers ----
|
||||
def _emit(self, s: str) -> None:
|
||||
# If we're buffering a table, append there instead
|
||||
if self._table_buf is not None:
|
||||
self._table_buf.append(s)
|
||||
else:
|
||||
self.out.append(s)
|
||||
|
||||
def _emit_placeholder(self, file: Path, kind: str, caption: str | None = None) -> None:
|
||||
sentinel = self._next_sentinel()
|
||||
self.embeds.append({
|
||||
"sentinel": sentinel,
|
||||
"file": str(file),
|
||||
"type": kind,
|
||||
"caption": caption,
|
||||
})
|
||||
# The placeholder must end up as its own top-level <p> so media-insert
|
||||
# can anchor on it cleanly and the cleanup pass can block_delete it.
|
||||
# If we're currently inside a <p>, split: close, emit standalone, reopen.
|
||||
if self._table_buf is not None:
|
||||
# Inside a table cell — best we can do is emit the sentinel as
|
||||
# inline text and rely on str_replace cleanup. Media still lands at
|
||||
# top level (per --selection-with-ellipsis semantics).
|
||||
self._emit(sentinel)
|
||||
return
|
||||
if self._p_depth > 0:
|
||||
self.out.append("</p>")
|
||||
self.out.append(f"<p>{sentinel}</p>")
|
||||
self.out.append("<p>")
|
||||
return
|
||||
self._emit(f"<p>{sentinel}</p>")
|
||||
|
||||
# ---- HTMLParser hooks ----
|
||||
def handle_starttag(self, tag, attrs):
|
||||
attrd = dict(attrs)
|
||||
|
||||
# Inside <pre><code>: capture verbatim
|
||||
if self._in_pre:
|
||||
# Don't recurse, but still record raw markup if any nested tags appear
|
||||
if tag == "code":
|
||||
self._code_lang = self._extract_lang(attrd.get("class", ""))
|
||||
self._code_buf = []
|
||||
return
|
||||
|
||||
# Table buffer mode: just copy markup through, no transformations needed
|
||||
if self._table_buf is not None:
|
||||
self._table_buf.append(self._raw_tag(tag, attrd))
|
||||
if tag == "table":
|
||||
self._table_depth += 1
|
||||
return
|
||||
|
||||
if tag == "table":
|
||||
self._table_buf = []
|
||||
self._table_depth = 1
|
||||
self._table_buf.append(self._raw_tag(tag, attrd))
|
||||
return
|
||||
|
||||
if tag == "pre":
|
||||
self._in_pre = True
|
||||
return
|
||||
|
||||
if tag == "img":
|
||||
self._emit_img(attrd)
|
||||
return
|
||||
|
||||
if tag == "a":
|
||||
href = attrd.get("href", "")
|
||||
local = self._resolve_local(href) if href else None
|
||||
if local is not None:
|
||||
# Inline attachment: keep the link text in the prose so the
|
||||
# paragraph still reads naturally, and queue a placeholder so
|
||||
# the attachment block appears right after this paragraph.
|
||||
caption = attrd.get("title") or None
|
||||
self._emit_placeholder(local, "file", caption)
|
||||
# Drop the <a> tags (keep their text children) by pushing
|
||||
# a "transparent" marker on the inline stack.
|
||||
self._inline_stack.append("__TRANSPARENT_A__")
|
||||
return
|
||||
# Regular link
|
||||
self._inline_stack.append("a")
|
||||
attrs_s = self._attrs_string({"href": href})
|
||||
self._emit(f"<a{attrs_s}>")
|
||||
return
|
||||
|
||||
if tag in {"b", "strong"}:
|
||||
self._inline_stack.append("b")
|
||||
self._emit("<b>")
|
||||
return
|
||||
if tag in {"em", "i"}:
|
||||
self._inline_stack.append("em")
|
||||
self._emit("<em>")
|
||||
return
|
||||
if tag in {"u"}:
|
||||
self._inline_stack.append("u")
|
||||
self._emit("<u>")
|
||||
return
|
||||
if tag in {"del", "s", "strike"}:
|
||||
self._inline_stack.append("del")
|
||||
self._emit("<del>")
|
||||
return
|
||||
if tag == "code":
|
||||
self._inline_stack.append("code")
|
||||
self._emit("<code>")
|
||||
return
|
||||
if tag == "br":
|
||||
self._emit("<br/>")
|
||||
return
|
||||
|
||||
if tag == "ul":
|
||||
self._li_stack.append("ul")
|
||||
self._emit("<ul>")
|
||||
return
|
||||
if tag == "ol":
|
||||
self._li_stack.append("ol")
|
||||
self._emit("<ol>")
|
||||
return
|
||||
if tag == "li":
|
||||
if self._li_stack and self._li_stack[-1] == "ol":
|
||||
self._emit('<li seq="auto">')
|
||||
else:
|
||||
self._emit("<li>")
|
||||
return
|
||||
|
||||
if tag == "blockquote":
|
||||
self._blockquote_depth += 1
|
||||
self._emit("<blockquote>")
|
||||
return
|
||||
|
||||
if tag == "p":
|
||||
self._p_depth += 1
|
||||
self._emit("<p>")
|
||||
return
|
||||
|
||||
if tag == "checkbox":
|
||||
# Emitted by our preprocessor for GFM task list items.
|
||||
done = attrd.get("done", "false")
|
||||
self._emit(f'<checkbox done="{xml_escape_attr(done)}">')
|
||||
self._inline_stack.append("checkbox")
|
||||
return
|
||||
|
||||
if tag in BLOCK_PASSTHROUGH:
|
||||
self._emit(f"<{tag}>")
|
||||
return
|
||||
|
||||
# span etc.
|
||||
if tag == "span":
|
||||
self._inline_stack.append("span")
|
||||
self._emit("<span>")
|
||||
return
|
||||
|
||||
# Anything else we don't recognise — drop the tag, keep its text
|
||||
self._inline_stack.append("__UNKNOWN__")
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if self._in_pre:
|
||||
if tag == "code":
|
||||
self._flush_code()
|
||||
elif tag == "pre":
|
||||
self._in_pre = False
|
||||
return
|
||||
|
||||
if self._table_buf is not None:
|
||||
self._table_buf.append(f"</{tag}>")
|
||||
if tag == "table":
|
||||
self._table_depth -= 1
|
||||
if self._table_depth == 0:
|
||||
table_xml = "".join(self._table_buf)
|
||||
self._table_buf = None
|
||||
# Clean the buffered HTML so it's valid DocxXML
|
||||
self.out.append(self._sanitise_table(table_xml))
|
||||
return
|
||||
|
||||
if tag == "pre":
|
||||
self._in_pre = False
|
||||
return
|
||||
|
||||
if tag == "img":
|
||||
return
|
||||
|
||||
if tag == "a":
|
||||
top = self._inline_stack.pop() if self._inline_stack else None
|
||||
if top == "__TRANSPARENT_A__":
|
||||
return
|
||||
self._emit("</a>")
|
||||
return
|
||||
|
||||
if tag in {"b", "strong"}:
|
||||
if self._inline_stack and self._inline_stack[-1] == "b":
|
||||
self._inline_stack.pop()
|
||||
self._emit("</b>")
|
||||
return
|
||||
if tag in {"em", "i"}:
|
||||
if self._inline_stack and self._inline_stack[-1] == "em":
|
||||
self._inline_stack.pop()
|
||||
self._emit("</em>")
|
||||
return
|
||||
if tag in {"u"}:
|
||||
if self._inline_stack and self._inline_stack[-1] == "u":
|
||||
self._inline_stack.pop()
|
||||
self._emit("</u>")
|
||||
return
|
||||
if tag in {"del", "s", "strike"}:
|
||||
if self._inline_stack and self._inline_stack[-1] == "del":
|
||||
self._inline_stack.pop()
|
||||
self._emit("</del>")
|
||||
return
|
||||
if tag == "code":
|
||||
if self._inline_stack and self._inline_stack[-1] == "code":
|
||||
self._inline_stack.pop()
|
||||
self._emit("</code>")
|
||||
return
|
||||
if tag == "span":
|
||||
if self._inline_stack and self._inline_stack[-1] == "span":
|
||||
self._inline_stack.pop()
|
||||
self._emit("</span>")
|
||||
return
|
||||
|
||||
if tag == "ul":
|
||||
if self._li_stack and self._li_stack[-1] == "ul":
|
||||
self._li_stack.pop()
|
||||
self._emit("</ul>")
|
||||
return
|
||||
if tag == "ol":
|
||||
if self._li_stack and self._li_stack[-1] == "ol":
|
||||
self._li_stack.pop()
|
||||
self._emit("</ol>")
|
||||
return
|
||||
if tag == "li":
|
||||
self._emit("</li>")
|
||||
return
|
||||
|
||||
if tag == "blockquote":
|
||||
self._blockquote_depth = max(0, self._blockquote_depth - 1)
|
||||
self._emit("</blockquote>")
|
||||
return
|
||||
|
||||
if tag == "p":
|
||||
self._p_depth = max(0, self._p_depth - 1)
|
||||
self._emit("</p>")
|
||||
return
|
||||
|
||||
if tag == "checkbox":
|
||||
if self._inline_stack and self._inline_stack[-1] == "checkbox":
|
||||
self._inline_stack.pop()
|
||||
self._emit("</checkbox>")
|
||||
return
|
||||
|
||||
if tag in BLOCK_PASSTHROUGH:
|
||||
self._emit(f"</{tag}>")
|
||||
return
|
||||
|
||||
if self._inline_stack and self._inline_stack[-1] == "__UNKNOWN__":
|
||||
self._inline_stack.pop()
|
||||
|
||||
def handle_startendtag(self, tag, attrs):
|
||||
attrd = dict(attrs)
|
||||
if tag == "img":
|
||||
self._emit_img(attrd)
|
||||
return
|
||||
if tag == "br":
|
||||
self._emit("<br/>")
|
||||
return
|
||||
if tag == "hr":
|
||||
self._emit("<hr/>")
|
||||
return
|
||||
# Treat as start+end
|
||||
self.handle_starttag(tag, attrs)
|
||||
self.handle_endtag(tag)
|
||||
|
||||
def handle_data(self, data):
|
||||
if not data:
|
||||
return
|
||||
if self._in_pre and self._code_buf is not None:
|
||||
self._code_buf.append(data)
|
||||
return
|
||||
if self._table_buf is not None:
|
||||
self._table_buf.append(xml_escape_text(data))
|
||||
return
|
||||
# Preserve user text but escape XML specials
|
||||
# In <pre> outside <code> we also escape (shouldn't normally happen)
|
||||
self._emit(xml_escape_text(data))
|
||||
|
||||
# ---- code / language extraction ----
|
||||
@staticmethod
|
||||
def _extract_lang(class_attr: str) -> str:
|
||||
# python-markdown fenced_code emits e.g. class="language-mermaid"
|
||||
for tok in class_attr.split():
|
||||
if tok.startswith("language-"):
|
||||
return tok[len("language-"):]
|
||||
if tok.startswith("lang-"):
|
||||
return tok[len("lang-"):]
|
||||
return ""
|
||||
|
||||
def _flush_code(self) -> None:
|
||||
body = "".join(self._code_buf or [])
|
||||
lang = (self._code_lang or "").strip().lower()
|
||||
self._code_buf = None
|
||||
self._code_lang = None
|
||||
# Mermaid / PlantUML get rendered as whiteboards
|
||||
if lang in {"mermaid"}:
|
||||
self._emit(f'<whiteboard type="mermaid">{xml_escape_text(body.rstrip())}</whiteboard>')
|
||||
return
|
||||
if lang in {"plantuml", "puml"}:
|
||||
self._emit(f'<whiteboard type="plantuml">{xml_escape_text(body.rstrip())}</whiteboard>')
|
||||
return
|
||||
# Strip trailing newline that python-markdown adds inside <code>
|
||||
body = body.rstrip("\n")
|
||||
lang_attr = f' lang="{xml_escape_attr(lang)}"' if lang else ""
|
||||
self._emit(f"<pre{lang_attr}><code>{xml_escape_text(body)}</code></pre>")
|
||||
|
||||
# ---- image emit ----
|
||||
def _emit_img(self, attrd: dict) -> None:
|
||||
src = attrd.get("src", "").strip()
|
||||
alt = attrd.get("alt", "").strip()
|
||||
title = attrd.get("title", "").strip()
|
||||
caption = title or alt or None
|
||||
if not src:
|
||||
return
|
||||
if is_http_url(src):
|
||||
attrs_s = self._attrs_string({"href": src, "caption": caption, "name": alt or None})
|
||||
self._emit(f"<img{attrs_s}/>")
|
||||
return
|
||||
local = self._resolve_local(src)
|
||||
if local is None:
|
||||
sys.stderr.write(f"[warn] image not found, dropping: {src}\n")
|
||||
return
|
||||
self._emit_placeholder(local, "image", caption)
|
||||
|
||||
# ---- attrs helpers ----
|
||||
@staticmethod
|
||||
def _attrs_string(d: dict) -> str:
|
||||
parts = []
|
||||
for k, v in d.items():
|
||||
if v is None or v == "":
|
||||
continue
|
||||
parts.append(f' {k}="{xml_escape_attr(str(v))}"')
|
||||
return "".join(parts)
|
||||
|
||||
@staticmethod
|
||||
def _raw_tag(tag: str, attrd: dict) -> str:
|
||||
return f"<{tag}{DocxXMLBuilder._attrs_string(attrd)}>"
|
||||
|
||||
@staticmethod
|
||||
def _sanitise_table(html: str) -> str:
|
||||
"""Coerce python-markdown's HTML table into DocxXML-legal markup:
|
||||
- <strong>/<em>/<i> become <b>/<em>
|
||||
- Drop style="..." attributes (DocxXML uses background-color /
|
||||
vertical-align, not CSS)
|
||||
- Drop unknown attributes on cells
|
||||
"""
|
||||
# tag rename
|
||||
html = re.sub(r"<(/?)strong\b", r"<\1b", html)
|
||||
html = re.sub(r"<(/?)i\b", r"<\1em", html)
|
||||
# drop style="..." on th/td/tr/table
|
||||
html = re.sub(r'\s+style="[^"]*"', "", html)
|
||||
# drop align="..." on th/td (we don't try to map to DocxXML alignment)
|
||||
html = re.sub(r'\s+align="[^"]*"', "", html)
|
||||
return html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Driver
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def derive_title(md_text: str, md_path: Path) -> str:
|
||||
for line in md_text.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("# "):
|
||||
return line[2:].strip()
|
||||
# fallback: filename without extension
|
||||
return md_path.stem
|
||||
|
||||
|
||||
def strip_first_h1(md_text: str) -> str:
|
||||
"""Drop the first H1 line if present — we'll convey it via <title> instead."""
|
||||
out_lines: list[str] = []
|
||||
dropped = False
|
||||
for line in md_text.splitlines():
|
||||
if not dropped and line.strip().startswith("# "):
|
||||
dropped = True
|
||||
continue
|
||||
out_lines.append(line)
|
||||
return "\n".join(out_lines)
|
||||
|
||||
|
||||
def build_xml(md_path: Path, *, title: str, session_tag: str) -> tuple[str, list[dict]]:
|
||||
raw = md_path.read_text(encoding="utf-8")
|
||||
raw = preprocess_markdown(raw)
|
||||
body_md = strip_first_h1(raw)
|
||||
html = markdown.markdown(
|
||||
body_md,
|
||||
extensions=["fenced_code", "tables", "sane_lists"],
|
||||
output_format="xhtml",
|
||||
)
|
||||
builder = DocxXMLBuilder(md_dir=md_path.parent, session_tag=session_tag)
|
||||
builder.feed(html)
|
||||
builder.close()
|
||||
body_xml = "".join(builder.out)
|
||||
# Unwrap stray <p>...</p> around block-level <checkbox> (python-markdown
|
||||
# wraps unknown HTML tags in <p>); then collapse empty <p></p> left over
|
||||
# from the placeholder split.
|
||||
body_xml = re.sub(
|
||||
r"<p>\s*(<checkbox\s+done=\"(?:true|false)\">[^<]*</checkbox>)\s*</p>",
|
||||
r"\1",
|
||||
body_xml,
|
||||
)
|
||||
body_xml = re.sub(r"<p>\s*</p>", "", body_xml)
|
||||
title_xml = f"<title>{xml_escape_text(title)}</title>"
|
||||
return title_xml + body_xml, builder.embeds
|
||||
|
||||
|
||||
def create_or_overwrite_doc(*, doc_id: str | None, content: str, identity: str, parent_token: str | None, parent_position: str | None, verbose: bool) -> dict:
|
||||
if doc_id:
|
||||
if verbose:
|
||||
sys.stderr.write(f"[md2feishu] overwriting existing doc {doc_id}\n")
|
||||
# Use stdin for content to avoid argv length / shell escaping pitfalls
|
||||
args = [
|
||||
"docs", "+update",
|
||||
"--api-version", "v2",
|
||||
"--doc", doc_id,
|
||||
"--command", "overwrite",
|
||||
"--doc-format", "xml",
|
||||
"--content", "-",
|
||||
]
|
||||
res = run_lark(args, stdin=content, identity=identity, verbose=verbose)
|
||||
return {"doc_id": doc_id, "result": res}
|
||||
if verbose:
|
||||
sys.stderr.write("[md2feishu] creating new doc\n")
|
||||
args = [
|
||||
"docs", "+create",
|
||||
"--api-version", "v2",
|
||||
"--doc-format", "xml",
|
||||
"--content", "-",
|
||||
]
|
||||
if parent_token:
|
||||
args += ["--parent-token", parent_token]
|
||||
if parent_position:
|
||||
args += ["--parent-position", parent_position]
|
||||
res = run_lark(args, stdin=content, identity=identity, verbose=verbose)
|
||||
document = (res.get("data") or {}).get("document") or {}
|
||||
new_id = document.get("document_id")
|
||||
if not new_id:
|
||||
raise LarkError(f"docs +create did not return a document_id: {json.dumps(res, ensure_ascii=False)}")
|
||||
return {"doc_id": new_id, "url": document.get("url"), "result": res}
|
||||
|
||||
|
||||
def insert_embed(doc_id: str, embed: dict, *, identity: str, verbose: bool) -> None:
|
||||
# lark-cli refuses absolute paths for --file. cd into the file's parent
|
||||
# and pass just the basename.
|
||||
file_path = Path(embed["file"]).resolve()
|
||||
args = [
|
||||
"docs", "+media-insert",
|
||||
"--doc", doc_id,
|
||||
"--file", file_path.name,
|
||||
"--type", embed["type"],
|
||||
"--selection-with-ellipsis", embed["sentinel"],
|
||||
"--before",
|
||||
]
|
||||
if embed.get("caption") and embed["type"] == "image":
|
||||
args += ["--caption", embed["caption"]]
|
||||
run_lark(args, identity=identity, verbose=verbose, cwd=str(file_path.parent))
|
||||
|
||||
|
||||
def cleanup_sentinels(doc_id: str, session_tag: str, embeds: list[dict], *, identity: str, verbose: bool) -> None:
|
||||
"""Two-pass cleanup:
|
||||
1. block_delete any paragraph whose entire text is a sentinel
|
||||
2. str_replace any remaining sentinel occurrences (handles sentinels
|
||||
that ended up inline inside table cells or mixed prose)
|
||||
"""
|
||||
res = run_lark([
|
||||
"docs", "+fetch",
|
||||
"--api-version", "v2",
|
||||
"--doc", doc_id,
|
||||
"--detail", "with-ids",
|
||||
"--doc-format", "xml",
|
||||
], identity=identity, verbose=verbose)
|
||||
xml_payload = ((res.get("data") or {}).get("document") or {}).get("content") or ""
|
||||
if not xml_payload:
|
||||
xml_payload = json.dumps(res, ensure_ascii=False)
|
||||
sentinel_re = re.compile(
|
||||
rf'<p[^>]*\bid="([^"]+)"[^>]*>\s*{SENTINEL_PREFIX}_{session_tag}_\d+\s*</p>'
|
||||
)
|
||||
ids = sentinel_re.findall(xml_payload)
|
||||
if ids:
|
||||
if verbose:
|
||||
sys.stderr.write(f"[md2feishu] deleting {len(ids)} sentinel paragraph(s)\n")
|
||||
try:
|
||||
run_lark([
|
||||
"docs", "+update",
|
||||
"--api-version", "v2",
|
||||
"--doc", doc_id,
|
||||
"--command", "block_delete",
|
||||
"--block-id", ",".join(ids),
|
||||
], identity=identity, verbose=verbose)
|
||||
except LarkError as e:
|
||||
sys.stderr.write(f"[warn] block_delete cleanup failed: {e}\n")
|
||||
# Fallback: scrub any inline sentinel text still in the doc
|
||||
for embed in embeds:
|
||||
sentinel = embed["sentinel"]
|
||||
if sentinel in xml_payload and (not ids or f">{sentinel}<" not in xml_payload):
|
||||
try:
|
||||
run_lark([
|
||||
"docs", "+update",
|
||||
"--api-version", "v2",
|
||||
"--doc", doc_id,
|
||||
"--command", "str_replace",
|
||||
"--pattern", sentinel,
|
||||
"--content", "",
|
||||
], identity=identity, verbose=verbose)
|
||||
except LarkError as e:
|
||||
sys.stderr.write(f"[warn] str_replace cleanup for {sentinel} failed: {e}\n")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
HELP_EPILOG = textwrap.dedent("""
|
||||
EXAMPLES
|
||||
# First run — creates a new Feishu doc, remembers the mapping
|
||||
markdown-to-feishu ./report.md
|
||||
|
||||
# Re-run on the same file — updates the same doc in place (no new doc spawned)
|
||||
markdown-to-feishu ./report.md
|
||||
|
||||
# Force a brand-new doc even when state already has a mapping
|
||||
markdown-to-feishu --new ./report.md
|
||||
|
||||
# Update a specific doc explicitly, ignoring state file
|
||||
markdown-to-feishu --update doxcnAbc123 ./report.md
|
||||
|
||||
# Drop into a particular folder when creating
|
||||
markdown-to-feishu --parent-token fldcnXXXX ./report.md
|
||||
|
||||
# Put it under your personal knowledge library
|
||||
markdown-to-feishu --parent-position my_library ./report.md
|
||||
|
||||
# Override the document title (default = first H1 or filename stem)
|
||||
markdown-to-feishu --title "2026 Q2 OKR" ./okr.md
|
||||
|
||||
# Inspect the generated XML and embed plan, without touching Feishu
|
||||
markdown-to-feishu --dry-run ./report.md
|
||||
|
||||
# Forget the mapping for a file (does NOT delete the Feishu doc)
|
||||
markdown-to-feishu --forget ./report.md
|
||||
|
||||
# Show the recorded mapping for this file
|
||||
markdown-to-feishu --show ./report.md
|
||||
|
||||
SUPPORTED MARKDOWN -> FEISHU BLOCK MAPPING
|
||||
# / ## / ... / ###### -> <h1> ... <h9> (the first H1 becomes the
|
||||
document <title>)
|
||||
paragraphs -> <p>
|
||||
**bold** / __bold__ -> <b>
|
||||
*italic* / _italic_ -> <em>
|
||||
~~strike~~ (GFM) -> <del>
|
||||
`inline code` -> <code>
|
||||
[text](https://...) -> <a href="...">text</a>
|
||||
[text](./local.pdf) -> attachment block (file uploaded via
|
||||
docs +media-insert --type file)
|
||||
 -> <img href="https://..."/> (URL is fetched
|
||||
server-side by Feishu)
|
||||
 -> inline image block (file uploaded via
|
||||
docs +media-insert --type image; alt /
|
||||
title becomes caption)
|
||||
> blockquote -> <blockquote>
|
||||
--- / *** -> <hr/>
|
||||
- item / * item / 1. item -> <ul> / <ol> with seq="auto"
|
||||
nested lists (4-space indent) -> nested <ul> / <ol>
|
||||
| a | b | GFM tables -> <table><thead><tr><th>...
|
||||
```lang ... ``` -> <pre lang="lang"><code>...</code></pre>
|
||||
```mermaid ... ``` -> <whiteboard type="mermaid">...</whiteboard>
|
||||
```plantuml ... ``` -> <whiteboard type="plantuml">...</whiteboard>
|
||||
|
||||
ATTACHMENT DETECTION
|
||||
Any [text](path) link whose href is NOT an http(s) URL and NOT an in-doc
|
||||
anchor (#foo), and which resolves to an existing local file (relative to
|
||||
the markdown file's directory), is uploaded as a Feishu file block. The
|
||||
visible link text is dropped — the attachment block carries the filename
|
||||
itself. This is what makes pasting PDFs / CSVs / logs / arbitrary binaries
|
||||
feel "native".
|
||||
|
||||
Caveat: if a link resolves to a missing local file, it falls through to a
|
||||
regular <a> link (the path will appear as-is). Run with --verbose to see
|
||||
each resolution decision.
|
||||
|
||||
IDENTITY
|
||||
Defaults to --as user so the created doc is owned by YOUR Feishu account,
|
||||
not the bot. This means you can manage / move / delete it directly from
|
||||
Feishu without any ownership transfer dance. Use --as bot only if you
|
||||
explicitly want bot-owned documents.
|
||||
|
||||
UPDATE-BY-DEFAULT BEHAVIOUR
|
||||
State lives at ~/.local/share/markdown-to-feishu/state.json (override with
|
||||
$MD2FEISHU_STATE_DIR or --state-file). Keyed by the markdown file's
|
||||
absolute path. When state has a doc_id for the given path:
|
||||
|
||||
- default -> overwrite that doc in place
|
||||
- --new -> ignore state, create a fresh doc, replace
|
||||
the mapping with the new id
|
||||
- --update <id> -> overwrite the given id and update state
|
||||
|
||||
overwrite replays the full XML and re-uploads every local media file from
|
||||
source, so the doc always matches the markdown 1:1. Comments on the doc
|
||||
survive overwrite; manual edits inside the doc do NOT (markdown is the
|
||||
source of truth).
|
||||
|
||||
EXIT CODES
|
||||
0 success
|
||||
1 generic error (bad args, file not found, lark-cli failure)
|
||||
2 partial success — doc created/updated but at least one embed failed
|
||||
|
||||
ENVIRONMENT
|
||||
MD2FEISHU_STATE_DIR override the directory holding state.json
|
||||
LARK_CLI_PROFILE passed through; honoured by lark-cli itself
|
||||
|
||||
DEPENDENCIES
|
||||
python3, python3-markdown, lark-cli (must be authenticated as user via
|
||||
`lark-cli auth login`)
|
||||
""")
|
||||
|
||||
|
||||
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(
|
||||
prog="markdown-to-feishu",
|
||||
description="Convert a Markdown file (with rich embeds: tables, images, mermaid, attachments) into a Feishu docx. Re-runs update the previously-created doc by default.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=HELP_EPILOG,
|
||||
)
|
||||
p.add_argument("markdown", nargs="?", help="path to the .md file")
|
||||
p.add_argument("--new", action="store_true", help="force-create a new doc even if state already has a mapping for this file")
|
||||
p.add_argument("--update", metavar="DOC_ID", help="overwrite the given doc id (URL also accepted); ignores and then updates state")
|
||||
p.add_argument("--title", help="override document title (default: first H1, else filename stem)")
|
||||
p.add_argument("--parent-token", help="parent folder or wiki node token (only used when creating)")
|
||||
p.add_argument("--parent-position", help="parent position keyword, e.g. my_library (only used when creating)")
|
||||
p.add_argument("--as", dest="identity", choices=["user", "bot"], default="user", help="identity for lark-cli (default: user, so you own the doc)")
|
||||
p.add_argument("--dry-run", action="store_true", help="print generated XML + embed plan without calling lark-cli")
|
||||
p.add_argument("--state-file", help="override path to state.json (default: ~/.local/share/markdown-to-feishu/state.json)")
|
||||
p.add_argument("--forget", action="store_true", help="remove the state mapping for this file (does not delete the Feishu doc) and exit")
|
||||
p.add_argument("--show", action="store_true", help="print the recorded mapping for this file (if any) and exit")
|
||||
p.add_argument("-v", "--verbose", action="store_true", help="verbose logging (every lark-cli invocation)")
|
||||
p.add_argument("--version", action="version", version=f"markdown-to-feishu {VERSION}")
|
||||
return p.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
args = parse_args(argv)
|
||||
global STATE_FILE, STATE_DIR
|
||||
if args.state_file:
|
||||
STATE_FILE = Path(args.state_file).expanduser().resolve()
|
||||
STATE_DIR = STATE_FILE.parent
|
||||
|
||||
if not args.markdown:
|
||||
sys.stderr.write("error: missing markdown file (use --help)\n")
|
||||
return 1
|
||||
|
||||
md_path = Path(args.markdown).expanduser().resolve()
|
||||
if not md_path.exists() or not md_path.is_file():
|
||||
sys.stderr.write(f"error: {md_path} is not a file\n")
|
||||
return 1
|
||||
key = str(md_path)
|
||||
|
||||
state = load_state()
|
||||
|
||||
if args.show:
|
||||
entry = state.get(key)
|
||||
if entry is None:
|
||||
print(f"no mapping recorded for {md_path}")
|
||||
else:
|
||||
print(json.dumps(entry, indent=2, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
if args.forget:
|
||||
if key in state:
|
||||
state.pop(key)
|
||||
save_state(state)
|
||||
print(f"forgot mapping for {md_path}")
|
||||
else:
|
||||
print(f"no mapping recorded for {md_path}")
|
||||
return 0
|
||||
|
||||
md_text = md_path.read_text(encoding="utf-8")
|
||||
title = args.title or derive_title(md_text, md_path)
|
||||
session_tag = uuid.uuid4().hex[:8].upper()
|
||||
|
||||
try:
|
||||
content, embeds = build_xml(md_path, title=title, session_tag=session_tag)
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"error: failed to build XML: {e}\n")
|
||||
return 1
|
||||
|
||||
if args.dry_run:
|
||||
print("=== GENERATED DOCXXML ===")
|
||||
print(content)
|
||||
print()
|
||||
print("=== EMBED PLAN ===")
|
||||
if not embeds:
|
||||
print("(no out-of-band embeds)")
|
||||
else:
|
||||
for e in embeds:
|
||||
print(json.dumps(e, ensure_ascii=False))
|
||||
target = "new doc"
|
||||
if args.update:
|
||||
target = f"update doc {args.update}"
|
||||
elif not args.new and key in state:
|
||||
target = f"update existing doc {state[key].get('doc_id')}"
|
||||
print()
|
||||
print(f"=== TARGET ===\n{target}")
|
||||
return 0
|
||||
|
||||
# Decide create-vs-update
|
||||
explicit_doc = args.update
|
||||
if explicit_doc and explicit_doc.startswith("http"):
|
||||
# extract /docx/<id>
|
||||
m = re.search(r"/docx/([A-Za-z0-9]+)", explicit_doc)
|
||||
if m:
|
||||
explicit_doc = m.group(1)
|
||||
target_doc_id = None
|
||||
if explicit_doc:
|
||||
target_doc_id = explicit_doc
|
||||
elif not args.new and key in state:
|
||||
target_doc_id = state[key].get("doc_id")
|
||||
|
||||
try:
|
||||
outcome = create_or_overwrite_doc(
|
||||
doc_id=target_doc_id,
|
||||
content=content,
|
||||
identity=args.identity,
|
||||
parent_token=args.parent_token,
|
||||
parent_position=args.parent_position,
|
||||
verbose=args.verbose,
|
||||
)
|
||||
except LarkError as e:
|
||||
sys.stderr.write(f"error: {e}\n")
|
||||
return 1
|
||||
|
||||
doc_id = outcome["doc_id"]
|
||||
failed_embeds: list[dict] = []
|
||||
for embed in embeds:
|
||||
try:
|
||||
insert_embed(doc_id, embed, identity=args.identity, verbose=args.verbose)
|
||||
except LarkError as e:
|
||||
sys.stderr.write(f"[warn] failed to insert {embed['file']}: {e}\n")
|
||||
failed_embeds.append(embed)
|
||||
|
||||
# Always try to clean up sentinels we managed to anchor
|
||||
if embeds:
|
||||
try:
|
||||
cleanup_sentinels(doc_id, session_tag, embeds, identity=args.identity, verbose=args.verbose)
|
||||
except LarkError as e:
|
||||
sys.stderr.write(f"[warn] cleanup failed: {e}\n")
|
||||
|
||||
# Save state
|
||||
entry = state.get(key, {})
|
||||
entry.update({
|
||||
"doc_id": doc_id,
|
||||
"url": outcome.get("url") or entry.get("url"),
|
||||
"updated_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
||||
"title": title,
|
||||
})
|
||||
if entry.get("url") is None and not target_doc_id:
|
||||
# Fetch URL via a separate call if it wasn't returned (shouldn't happen on create)
|
||||
pass
|
||||
state[key] = entry
|
||||
save_state(state)
|
||||
|
||||
print(json.dumps({
|
||||
"doc_id": doc_id,
|
||||
"url": entry.get("url"),
|
||||
"title": title,
|
||||
"embeds_inserted": len(embeds) - len(failed_embeds),
|
||||
"embeds_failed": len(failed_embeds),
|
||||
}, indent=2, ensure_ascii=False))
|
||||
|
||||
return 2 if failed_embeds else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -0,0 +1,271 @@
|
||||
"""notes 多用途 sidecar:
|
||||
POST /transcribe — 用 ffmpeg 切片 + 串行调外部 ASR,绕过单请求大小限制
|
||||
POST /convert — markdown-to-feishu,把会议纪要 push 飞书 docx
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
def probe_duration(src: Path) -> float:
|
||||
"""browser-recorded webm/m4a 经常没在 metadata 里写 duration(录到一半结束没法 finalize)。
|
||||
先 try ffprobe format.duration,N/A 时 fallback 让 ffmpeg null-muxer 解码一遍统计。
|
||||
"""
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
['ffprobe', '-v', 'quiet', '-show_entries', 'format=duration',
|
||||
'-of', 'csv=p=0', str(src)],
|
||||
timeout=60,
|
||||
).decode().strip()
|
||||
if out and out != 'N/A':
|
||||
return float(out)
|
||||
except (subprocess.CalledProcessError, ValueError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
log.info("ffprobe format.duration=N/A, decoding to count time")
|
||||
proc = subprocess.run(
|
||||
['ffmpeg', '-i', str(src), '-f', 'null', '-'],
|
||||
stderr=subprocess.PIPE, stdout=subprocess.DEVNULL,
|
||||
timeout=900,
|
||||
)
|
||||
matches = re.findall(rb'time=(\d+):(\d+):(\d+(?:\.\d+)?)', proc.stderr)
|
||||
if not matches:
|
||||
raise HTTPException(500, f'cannot determine duration; ffmpeg stderr tail: {proc.stderr[-300:].decode("utf-8","replace")}')
|
||||
h, m, s = matches[-1]
|
||||
return int(h) * 3600 + int(m) * 60 + float(s)
|
||||
|
||||
logging.basicConfig(level=logging.INFO,
|
||||
format='%(asctime)s %(levelname)s %(name)s: %(message)s')
|
||||
log = logging.getLogger('feishu')
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
@app.get('/healthz')
|
||||
def healthz():
|
||||
return {'ok': True}
|
||||
|
||||
|
||||
class TranscribeReq(BaseModel):
|
||||
audio_path: str
|
||||
chunk_seconds: int = 65 # 单段长度,远低于 Qwen3-ASR 8192-token cache(~7min)
|
||||
overlap_seconds: int = 10 # 相邻段重叠,给 LLM stitching 留 anchor
|
||||
|
||||
|
||||
@app.post('/transcribe')
|
||||
def transcribe(req: TranscribeReq):
|
||||
"""ffmpeg 切 overlap 片 → 串行 ASR → LLM 拼接去重。"""
|
||||
src = Path(req.audio_path)
|
||||
if not src.exists():
|
||||
raise HTTPException(400, f'audio not found: {src}')
|
||||
asr_url = os.environ.get('ASR_URL', '')
|
||||
asr_token = os.environ.get('ASR_TOKEN', '')
|
||||
if not asr_url or not asr_token:
|
||||
raise HTTPException(500, 'ASR_URL/ASR_TOKEN not configured in sidecar')
|
||||
|
||||
tmp = Path(tempfile.gettempdir()) / f'transcribe-{uuid.uuid4().hex}'
|
||||
tmp.mkdir(parents=True)
|
||||
try:
|
||||
# 1) 拿总时长(ffprobe N/A 时回退 null-muxer 解码)
|
||||
duration = probe_duration(src)
|
||||
log.info("duration=%.1fs", duration)
|
||||
|
||||
# 2) 切 chunk_seconds 段,stride = chunk_seconds - overlap_seconds
|
||||
stride = max(1, req.chunk_seconds - req.overlap_seconds)
|
||||
ext = src.suffix.lstrip('.') or 'm4a'
|
||||
chunks_meta = []
|
||||
i = 0
|
||||
start = 0.0
|
||||
# 短录音单段够:不切,直接整段
|
||||
single_shot = duration <= req.chunk_seconds + 5
|
||||
if single_shot:
|
||||
chunks_meta = [{'start': 0.0, 'path': src, 'idx': 0}]
|
||||
else:
|
||||
while start < duration:
|
||||
cp = tmp / f'chunk_{i:03d}.{ext}'
|
||||
# -ss 在 -i 前:input seek,快;-c copy 不重新编码
|
||||
try:
|
||||
subprocess.run(
|
||||
['ffmpeg', '-y', '-ss', f'{start:.2f}',
|
||||
'-t', f'{req.chunk_seconds}',
|
||||
'-i', str(src), '-c', 'copy', str(cp)],
|
||||
check=True, capture_output=True, timeout=120,
|
||||
)
|
||||
except subprocess.CalledProcessError:
|
||||
subprocess.run(
|
||||
['ffmpeg', '-y', '-ss', f'{start:.2f}',
|
||||
'-t', f'{req.chunk_seconds}',
|
||||
'-i', str(src),
|
||||
'-c:a', 'aac', '-b:a', '64k', '-ac', '1', '-ar', '16000',
|
||||
str(cp)],
|
||||
check=True, capture_output=True, timeout=180,
|
||||
)
|
||||
if cp.stat().st_size < 1024:
|
||||
break
|
||||
chunks_meta.append({'start': start, 'path': cp, 'idx': i})
|
||||
start += stride
|
||||
i += 1
|
||||
|
||||
if not chunks_meta:
|
||||
raise HTTPException(500, 'no chunks produced')
|
||||
log.info("chunks=%d, stride=%ds, overlap=%ds",
|
||||
len(chunks_meta), stride, req.overlap_seconds)
|
||||
|
||||
# 3) 串行 ASR
|
||||
chunk_texts = []
|
||||
for m in chunks_meta:
|
||||
log.info("ASR chunk %d/%d (start=%.1fs, %dKB)",
|
||||
m['idx'] + 1, len(chunks_meta), m['start'],
|
||||
m['path'].stat().st_size // 1024)
|
||||
with open(m['path'], 'rb') as f:
|
||||
r = requests.post(
|
||||
asr_url,
|
||||
headers={'Authorization': f'Bearer {asr_token}'},
|
||||
files={'file': (m['path'].name, f, 'audio/mp4')},
|
||||
data={'model': 'qwen3-asr', 'response_format': 'json'},
|
||||
timeout=300,
|
||||
)
|
||||
if not r.ok:
|
||||
raise HTTPException(502, f'ASR chunk {m["idx"]} {r.status_code}: {r.text[:300]}')
|
||||
try:
|
||||
text = r.json().get('text', '').strip()
|
||||
except Exception:
|
||||
raise HTTPException(502, f'ASR chunk {m["idx"]} bad json: {r.text[:200]}')
|
||||
chunk_texts.append(text)
|
||||
|
||||
# 4) 单段直接返回
|
||||
if len(chunk_texts) == 1:
|
||||
return {'text': chunk_texts[0], 'chunks': 1, 'stitched_by': 'single'}
|
||||
|
||||
# 5) LLM 拼接(gemma 一次性看所有 chunks 去重 + 拼)
|
||||
stitched = llm_stitch(chunk_texts, req.overlap_seconds)
|
||||
return {
|
||||
'text': stitched,
|
||||
'chunks': len(chunk_texts),
|
||||
'stitched_by': 'llm',
|
||||
}
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def llm_stitch(chunks: list[str], overlap_seconds: int) -> str:
|
||||
"""让 LLM 把相邻段重叠部分去重 + 修正边界字。失败 fallback 朴素拼接。"""
|
||||
gw = os.environ.get('LLM_GATEWAY', '').rstrip('/')
|
||||
tok = os.environ.get('LLM_TOKEN', '')
|
||||
model = os.environ.get('LLM_MODEL', 'gemma-4-31b-it')
|
||||
naive = '\n'.join(chunks)
|
||||
if not gw or not tok:
|
||||
log.warning("LLM not configured, fall back to naive concat")
|
||||
return naive
|
||||
|
||||
parts = []
|
||||
for i, c in enumerate(chunks):
|
||||
parts.append(f"段 {i + 1}:\n{c}")
|
||||
user = (
|
||||
f"下面是一段会议录音的 ASR 转写,被切成 {len(chunks)} 段。"
|
||||
f"相邻段有约 {overlap_seconds} 秒(几句话)的重叠。\n\n"
|
||||
+ "\n\n".join(parts)
|
||||
+ "\n\n请把所有段拼成一段连续文本:去掉相邻段交界处的重复、"
|
||||
"修正明显 ASR 错字(结合上下文)、补回被切断的词。\n"
|
||||
"不要加任何解释、标题、段号;只输出拼好的连续文本。"
|
||||
)
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "system", "content": "你是 ASR 转写后处理助手,专门做去重拼接和错字修正。"},
|
||||
{"role": "user", "content": user},
|
||||
],
|
||||
"temperature": 0.1,
|
||||
}
|
||||
try:
|
||||
r = requests.post(
|
||||
gw + '/chat/completions',
|
||||
headers={'Authorization': f'Bearer {tok}'},
|
||||
json=payload,
|
||||
timeout=600,
|
||||
)
|
||||
if not r.ok:
|
||||
log.warning("stitch LLM %s: %s", r.status_code, r.text[:200])
|
||||
return naive
|
||||
d = r.json()
|
||||
text = d['choices'][0]['message']['content'].strip()
|
||||
return text or naive
|
||||
except Exception as e:
|
||||
log.warning("stitch LLM call failed: %s", e)
|
||||
return naive
|
||||
|
||||
|
||||
class ConvertReq(BaseModel):
|
||||
md_path: str
|
||||
title: Optional[str] = None
|
||||
existing_doc_id: Optional[str] = None
|
||||
|
||||
|
||||
@app.post('/convert')
|
||||
def convert(req: ConvertReq):
|
||||
md = Path(req.md_path)
|
||||
if not md.exists():
|
||||
raise HTTPException(400, f'md not found: {md}')
|
||||
|
||||
# user identity = fam 自己拥有 doc(host 上手动跑过 OAuth 授权一次)
|
||||
cmd = ['/usr/local/bin/markdown-to-feishu', str(md), '--as', 'user']
|
||||
if req.existing_doc_id:
|
||||
cmd += ['--update', req.existing_doc_id]
|
||||
if req.title:
|
||||
cmd += ['--title', req.title]
|
||||
log.info("run: %s", ' '.join(cmd))
|
||||
|
||||
env = os.environ.copy()
|
||||
# markdown-to-feishu state file 放 PVC,重启不丢
|
||||
env['MD2FEISHU_STATE_DIR'] = '/data/feishu-state'
|
||||
Path('/data/feishu-state').mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=600, env=env,
|
||||
cwd=str(md.parent),
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise HTTPException(504, 'markdown-to-feishu timeout (>10min)')
|
||||
|
||||
# exit code 2 = embeds 有失败,但 doc 创建成功,仍 parse stdout
|
||||
if proc.returncode not in (0, 2):
|
||||
log.warning("md2feishu exit=%d stderr=%s", proc.returncode, proc.stderr[-500:])
|
||||
raise HTTPException(502, f'md2feishu exit {proc.returncode}: '
|
||||
f'{proc.stderr.strip()[-400:]}')
|
||||
|
||||
# 取 stdout 里最后一段 JSON 对象(script 的 final print)
|
||||
out = proc.stdout.strip()
|
||||
# 从后往前找第一个 '{',取到末尾
|
||||
last_open = out.rfind('{')
|
||||
if last_open < 0:
|
||||
raise HTTPException(502, f'md2feishu no json output. stdout tail: {out[-400:]}')
|
||||
try:
|
||||
data = json.loads(out[last_open:])
|
||||
except json.JSONDecodeError as e:
|
||||
raise HTTPException(502, f'md2feishu json parse: {e}; tail: {out[-400:]}')
|
||||
|
||||
doc_id = data.get('doc_id')
|
||||
url = data.get('url')
|
||||
if not doc_id or not url:
|
||||
raise HTTPException(502, f'md2feishu missing doc_id/url: {data}')
|
||||
log.info("ok: doc_id=%s url=%s embeds=%s",
|
||||
doc_id, url, data.get('embeds_inserted'))
|
||||
|
||||
return {
|
||||
'doc_id': doc_id,
|
||||
'url': url,
|
||||
'embeds_inserted': data.get('embeds_inserted', 0),
|
||||
'embeds_failed': data.get('embeds_failed', 0),
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="theme-color" content="#0f0f0f">
|
||||
<title>Notes</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0%25' stop-color='%237c5cbf'/%3E%3Cstop offset='100%25' stop-color='%23c084fc'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='64' height='64' rx='14' fill='url(%23g)'/%3E%3Crect x='25' y='13' width='14' height='24' rx='7' fill='white'/%3E%3Cpath d='M18 30 Q 18 42 32 42 Q 46 42 46 30' stroke='white' stroke-width='3.5' fill='none' stroke-linecap='round'/%3E%3Cline x1='32' y1='42' x2='32' y2='52' stroke='white' stroke-width='3.5' stroke-linecap='round'/%3E%3Cline x1='25' y1='52' x2='39' y2='52' stroke='white' stroke-width='3.5' stroke-linecap='round'/%3E%3Ccircle cx='50' cy='14' r='4' fill='%23ef4444'/%3E%3C/svg%3E">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "notes",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.21",
|
||||
"vue-router": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,817 @@
|
||||
<template>
|
||||
<!-- 没 pass 时强制弹输入框 -->
|
||||
<div v-if="needPass" class="auth-overlay">
|
||||
<div class="auth-modal">
|
||||
<h2>🔒 输入访问令牌</h2>
|
||||
<p class="auth-hint">notes 是私密录音库,需要 passphrase 才能访问。</p>
|
||||
<form @submit.prevent="submitPass">
|
||||
<input
|
||||
v-model="passDraft"
|
||||
type="password"
|
||||
autofocus
|
||||
placeholder="passphrase"
|
||||
class="auth-input"
|
||||
/>
|
||||
<button class="auth-btn" :disabled="!passDraft.trim()">进入</button>
|
||||
</form>
|
||||
<p v-if="authError" class="auth-err">{{ authError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="root">
|
||||
<aside class="sidebar">
|
||||
<header class="side-head">
|
||||
<h1>📝 Notes</h1>
|
||||
<button class="logout-btn" @click="logout" title="退出 / 改 passphrase">⎋</button>
|
||||
</header>
|
||||
<div class="upload-row">
|
||||
<button
|
||||
v-if="recState === 'idle'"
|
||||
class="rec-btn"
|
||||
:disabled="uploading"
|
||||
@click="startRec"
|
||||
>🎙️ 直接录</button>
|
||||
<button
|
||||
v-else
|
||||
class="rec-btn recording"
|
||||
@click="stopRec"
|
||||
>⏹ {{ fmtSec(recDuration) }}</button>
|
||||
<label class="upload-pick">
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="audio/*,video/*"
|
||||
@change="onFile"
|
||||
/>
|
||||
<span class="upload-btn small" :class="{ uploading }">{{ uploading ? '⏳' : '+ 文件' }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="uploadErr" class="upload-err">{{ uploadErr }}</p>
|
||||
<ul class="list">
|
||||
<li v-if="loading" class="list-empty">加载…</li>
|
||||
<li v-else-if="!list.length" class="list-empty">还没录音,点上面 + 传一个</li>
|
||||
<li
|
||||
v-for="r in list"
|
||||
:key="r.id"
|
||||
class="item"
|
||||
:class="{ active: selectedId === r.id, [r.status]: true }"
|
||||
@click="select(r.id)"
|
||||
>
|
||||
<div class="item-title">{{ r.title }}</div>
|
||||
<div class="item-meta">
|
||||
<span class="status">{{ statusLabel(r.status) }}</span>
|
||||
<span>· {{ fmtSize(r.size_bytes) }}</span>
|
||||
<span v-if="r.has_summary">· ✓ 纪要</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<main class="content">
|
||||
<p v-if="!selected" class="empty">← 从左边挑一条</p>
|
||||
<template v-else>
|
||||
<header class="cont-head">
|
||||
<div class="title-row">
|
||||
<h2>
|
||||
{{ selected.title }}
|
||||
<button class="rename-btn" title="重命名" @click="rename">✏️</button>
|
||||
</h2>
|
||||
<div class="actions">
|
||||
<button
|
||||
class="action-btn"
|
||||
:disabled="['pending','transcribing','cleaning','summarizing'].includes(selected.status)"
|
||||
:title="selected.transcript ? '已有 transcript,只重跑 LLM 润色 + 纪要' : '重新 ASR + 润色 + 纪要'"
|
||||
@click="retry"
|
||||
>↻ 重跑</button>
|
||||
<button class="action-btn danger" @click="remove">🗑 删除</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="head-meta">
|
||||
<span>{{ statusLabel(selected.status) }}</span>
|
||||
<span>· {{ fmtSize(selected.size_bytes) }}</span>
|
||||
<span>· {{ selected.created_at }}</span>
|
||||
</div>
|
||||
<div v-if="selected.status === 'done'" class="feishu-row">
|
||||
<a
|
||||
v-if="selected.feishu_url"
|
||||
:href="selected.feishu_url"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="feishu-link"
|
||||
>📄 飞书文档 · {{ selected.feishu_url.replace(/^https?:\/\//, '').slice(0, 40) }}…</a>
|
||||
<button
|
||||
class="feishu-btn"
|
||||
:disabled="feishuPushing"
|
||||
@click="pushFeishu"
|
||||
>
|
||||
{{ feishuPushing ? '⏳ 推送中…'
|
||||
: selected.feishu_url ? '↻ 重新生成' : '📤 一键转飞书文档' }}
|
||||
</button>
|
||||
<p v-if="feishuErr" class="feishu-err">{{ feishuErr }}</p>
|
||||
</div>
|
||||
</header>
|
||||
<audio :src="audioUrl(selected.id)" controls class="audio" />
|
||||
|
||||
<section v-if="selected.error" class="block err">
|
||||
<h3>错误</h3>
|
||||
<pre>{{ selected.error }}</pre>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<h3>📋 会议纪要</h3>
|
||||
<p v-if="!selected.summary && selected.status === 'done'" class="muted">空</p>
|
||||
<p v-else-if="['pending','transcribing','summarizing'].includes(selected.status)" class="muted">
|
||||
{{ progressText(selected.status) }}…
|
||||
</p>
|
||||
<div v-else class="prose" v-html="mdLite(selected.summary)"></div>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<h3>✨ 清理润色</h3>
|
||||
<p v-if="!selected.cleaned && selected.status === 'done'" class="muted">空(cleanup step 失败,看下方原文)</p>
|
||||
<p v-else-if="['pending','transcribing','cleaning','summarizing'].includes(selected.status)" class="muted">
|
||||
{{ progressText(selected.status) }}…
|
||||
</p>
|
||||
<div v-else class="prose" v-html="mdLite(selected.cleaned)"></div>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<details>
|
||||
<summary><h3 style="display:inline">🎙️ 转写原文(默认折叠)</h3></summary>
|
||||
<p v-if="!selected.transcript" class="muted">{{ selected.status === 'failed' ? '转写失败' : '尚未生成' }}</p>
|
||||
<pre v-else class="transcript">{{ selected.transcript }}</pre>
|
||||
</details>
|
||||
</section>
|
||||
</template>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import {
|
||||
listRecordings,
|
||||
getRecording,
|
||||
uploadRecording,
|
||||
deleteRecording,
|
||||
retryRecording,
|
||||
renameRecording,
|
||||
convertFeishu,
|
||||
audioUrl as audioUrlFn,
|
||||
getPass,
|
||||
setPass,
|
||||
clearPass,
|
||||
} from './lib/api.js'
|
||||
|
||||
const needPass = ref(!getPass())
|
||||
const passDraft = ref('')
|
||||
const authError = ref('')
|
||||
|
||||
const list = ref([])
|
||||
const loading = ref(false)
|
||||
const selected = ref(null)
|
||||
const selectedId = ref(null)
|
||||
const uploading = ref(false)
|
||||
const uploadErr = ref('')
|
||||
const feishuPushing = ref(false)
|
||||
const feishuErr = ref('')
|
||||
let pollTimer = null
|
||||
|
||||
// 浏览器内录音(iOS 没法选录音机 App 文件,直接 web record 更顺)
|
||||
const recState = ref('idle') // 'idle' | 'recording'
|
||||
const recDuration = ref(0)
|
||||
let mediaRecorder = null
|
||||
let recChunks = []
|
||||
let recStream = null
|
||||
let recTimer = null
|
||||
|
||||
async function startRec() {
|
||||
uploadErr.value = ''
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
uploadErr.value = '浏览器不支持 mic 录音'
|
||||
return
|
||||
}
|
||||
try {
|
||||
recStream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
} catch (e) {
|
||||
uploadErr.value = 'mic 权限被拒:' + (e.message || e.name)
|
||||
return
|
||||
}
|
||||
// Safari 偏向 audio/mp4,Chrome/Edge 优先 audio/webm
|
||||
const tries = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', '']
|
||||
let mimeType = ''
|
||||
for (const t of tries) {
|
||||
if (!t || (window.MediaRecorder && MediaRecorder.isTypeSupported(t))) {
|
||||
mimeType = t
|
||||
break
|
||||
}
|
||||
}
|
||||
recChunks = []
|
||||
mediaRecorder = mimeType
|
||||
? new MediaRecorder(recStream, { mimeType })
|
||||
: new MediaRecorder(recStream)
|
||||
mediaRecorder.ondataavailable = (e) => { if (e.data && e.data.size) recChunks.push(e.data) }
|
||||
mediaRecorder.onstop = onRecStop
|
||||
mediaRecorder.start(1000) // 1s chunks 保证 stop 时有数据
|
||||
recState.value = 'recording'
|
||||
recDuration.value = 0
|
||||
recTimer = setInterval(() => recDuration.value++, 1000)
|
||||
}
|
||||
|
||||
function stopRec() {
|
||||
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
||||
mediaRecorder.stop()
|
||||
}
|
||||
if (recTimer) { clearInterval(recTimer); recTimer = null }
|
||||
}
|
||||
|
||||
async function onRecStop() {
|
||||
const mimeType = mediaRecorder?.mimeType || 'audio/webm'
|
||||
const blob = new Blob(recChunks, { type: mimeType })
|
||||
if (recStream) {
|
||||
recStream.getTracks().forEach(t => t.stop())
|
||||
recStream = null
|
||||
}
|
||||
recState.value = 'idle'
|
||||
// 生成文件名
|
||||
const ext = mimeType.includes('mp4') ? 'm4a'
|
||||
: mimeType.includes('webm') ? 'webm'
|
||||
: mimeType.includes('ogg') ? 'ogg'
|
||||
: 'bin'
|
||||
const ts = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')
|
||||
const file = new File([blob], `录音-${ts}.${ext}`, { type: mimeType })
|
||||
if (file.size < 1024) {
|
||||
uploadErr.value = '录音太短(< 1KB),没保存'
|
||||
return
|
||||
}
|
||||
await doUpload(file)
|
||||
}
|
||||
|
||||
function fmtSec(s) {
|
||||
const m = Math.floor(s / 60)
|
||||
const sec = s % 60
|
||||
return m + ':' + (sec < 10 ? '0' : '') + sec
|
||||
}
|
||||
|
||||
async function submitPass() {
|
||||
setPass(passDraft.value.trim())
|
||||
try {
|
||||
await listRecordings()
|
||||
needPass.value = false
|
||||
authError.value = ''
|
||||
await refresh()
|
||||
syncFromUrl()
|
||||
startPoll()
|
||||
} catch (e) {
|
||||
if (e.unauthorized) {
|
||||
authError.value = '令牌不对'
|
||||
clearPass()
|
||||
} else {
|
||||
authError.value = e.message || String(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
clearPass()
|
||||
needPass.value = true
|
||||
list.value = []
|
||||
selected.value = null
|
||||
selectedId.value = null
|
||||
history.replaceState(null, '', window.location.pathname)
|
||||
stopPoll()
|
||||
}
|
||||
|
||||
async function refresh(silent = false) {
|
||||
if (!silent) loading.value = true
|
||||
try {
|
||||
const fresh = await listRecordings()
|
||||
// 增量更新:尽量复用已有 ref,避免整 array 替换导致闪动
|
||||
if (!list.value.length) {
|
||||
list.value = fresh
|
||||
} else {
|
||||
const byId = new Map(list.value.map(r => [r.id, r]))
|
||||
list.value = fresh.map(r => {
|
||||
const old = byId.get(r.id)
|
||||
if (old) {
|
||||
Object.assign(old, r)
|
||||
return old
|
||||
}
|
||||
return r
|
||||
})
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
if (e.unauthorized) { logout(); return }
|
||||
}
|
||||
finally { if (!silent) loading.value = false }
|
||||
// 同步当前选中
|
||||
if (selectedId.value) {
|
||||
try {
|
||||
const fresh = await getRecording(selectedId.value)
|
||||
if (selected.value) {
|
||||
Object.assign(selected.value, fresh)
|
||||
} else {
|
||||
selected.value = fresh
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
async function select(id) {
|
||||
selectedId.value = id
|
||||
// URL 同步:?id=N,方便刷新 / 分享 / bookmark
|
||||
const q = new URLSearchParams(window.location.search)
|
||||
q.set('id', String(id))
|
||||
history.replaceState(null, '', '?' + q.toString())
|
||||
try { selected.value = await getRecording(id) }
|
||||
catch (e) {
|
||||
if (e.unauthorized) { logout(); return }
|
||||
}
|
||||
}
|
||||
|
||||
function syncFromUrl() {
|
||||
const id = parseInt(new URLSearchParams(window.location.search).get('id'))
|
||||
if (id && id !== selectedId.value) select(id)
|
||||
}
|
||||
|
||||
function onFile(e) {
|
||||
const f = e.target.files?.[0]
|
||||
if (!f) return
|
||||
doUpload(f).then(() => { e.target.value = '' })
|
||||
}
|
||||
|
||||
async function doUpload(file) {
|
||||
uploading.value = true
|
||||
uploadErr.value = ''
|
||||
try {
|
||||
const title = file.name.replace(/\.[^.]+$/, '')
|
||||
const r = await uploadRecording(title, file)
|
||||
await refresh()
|
||||
select(r.id)
|
||||
} catch (e) {
|
||||
if (e.unauthorized) { logout(); return }
|
||||
uploadErr.value = e.message || String(e)
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!confirm('删除这条录音 + 转写 + 纪要?')) return
|
||||
try {
|
||||
await deleteRecording(selectedId.value)
|
||||
selectedId.value = null
|
||||
selected.value = null
|
||||
history.replaceState(null, '', window.location.pathname)
|
||||
await refresh()
|
||||
} catch (e) { alert(e.message) }
|
||||
}
|
||||
|
||||
async function rename() {
|
||||
const cur = selected.value?.title || ''
|
||||
const t = prompt('改个名字', cur)
|
||||
if (t == null) return
|
||||
const trimmed = t.trim()
|
||||
if (!trimmed || trimmed === cur) return
|
||||
try {
|
||||
await renameRecording(selectedId.value, trimmed)
|
||||
if (selected.value) selected.value.title = trimmed
|
||||
const inList = list.value.find(r => r.id === selectedId.value)
|
||||
if (inList) inList.title = trimmed
|
||||
} catch (e) { alert(e.message) }
|
||||
}
|
||||
|
||||
async function retry() {
|
||||
try {
|
||||
await retryRecording(selectedId.value)
|
||||
await refresh()
|
||||
} catch (e) { alert(e.message) }
|
||||
}
|
||||
|
||||
async function pushFeishu() {
|
||||
if (feishuPushing.value) return
|
||||
feishuPushing.value = true
|
||||
feishuErr.value = ''
|
||||
try {
|
||||
const r = await convertFeishu(selectedId.value)
|
||||
if (selected.value) {
|
||||
selected.value.feishu_doc_id = r.doc_id
|
||||
selected.value.feishu_url = r.url
|
||||
}
|
||||
} catch (e) {
|
||||
feishuErr.value = e.message || String(e)
|
||||
} finally {
|
||||
feishuPushing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function audioUrl(id) { return audioUrlFn(id) }
|
||||
|
||||
function statusLabel(s) {
|
||||
return ({
|
||||
pending: '⏳ 排队',
|
||||
transcribing: '🎙️ 转写中',
|
||||
cleaning: '✨ 清理润色中',
|
||||
summarizing: '📋 总结中',
|
||||
done: '✓ 完成',
|
||||
failed: '✗ 失败',
|
||||
})[s] || s
|
||||
}
|
||||
function progressText(s) {
|
||||
return ({
|
||||
pending: '等候处理',
|
||||
transcribing: '语音转写中(视音频长度可能要几分钟)',
|
||||
cleaning: 'LLM 分段 + 去口语 + 润色 + 高亮',
|
||||
summarizing: 'LLM 生成会议纪要',
|
||||
})[s] || s
|
||||
}
|
||||
function fmtSize(b) {
|
||||
if (!b) return '?'
|
||||
if (b < 1024) return b + 'B'
|
||||
if (b < 1024 * 1024) return (b / 1024).toFixed(1) + 'KB'
|
||||
if (b < 1024 * 1024 * 1024) return (b / 1024 / 1024).toFixed(1) + 'MB'
|
||||
return (b / 1024 / 1024 / 1024).toFixed(2) + 'GB'
|
||||
}
|
||||
|
||||
// 极简 markdown
|
||||
function mdLite(s) {
|
||||
if (!s) return ''
|
||||
let h = s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
h = h.replace(/^### (.+)$/gm, '<h4>$1</h4>')
|
||||
h = h.replace(/^## (.+)$/gm, '<h3>$1</h3>')
|
||||
h = h.replace(/^# (.+)$/gm, '<h2>$1</h2>')
|
||||
h = h.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>')
|
||||
h = h.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
h = h.replace(/(^|\n)\s*[-*]\s+/g, '$1• ')
|
||||
return h.split(/\n{2,}/).map(p => `<p>${p.replace(/\n/g, '<br>')}</p>`).join('')
|
||||
}
|
||||
|
||||
function startPoll() {
|
||||
stopPoll()
|
||||
pollTimer = setInterval(() => refresh(true), 5000)
|
||||
}
|
||||
function stopPoll() {
|
||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!needPass.value) {
|
||||
await refresh()
|
||||
syncFromUrl()
|
||||
startPoll()
|
||||
}
|
||||
// 浏览器前进/后退按钮也同步
|
||||
window.addEventListener('popstate', syncFromUrl)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
stopPoll()
|
||||
window.removeEventListener('popstate', syncFromUrl)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f0f0f;
|
||||
--bg-elev: #161616;
|
||||
--bg-card: #1a1a2e;
|
||||
--bg-hover: #232342;
|
||||
--bg-active: #2a1a3e;
|
||||
--border: #2a2a3a;
|
||||
--border-soft: #1f1f2a;
|
||||
--text: #e0e0e0;
|
||||
--text-dim: #a0a0a0;
|
||||
--text-mute: #666;
|
||||
--accent: #c084fc;
|
||||
--accent-strong: #7c5cbf;
|
||||
--accent-cyan: #06b6d4;
|
||||
--accent-green: #4ade80;
|
||||
--accent-amber: #f59e0b;
|
||||
--accent-red: #ef4444;
|
||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body, #app { height: 100%; }
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
button { font-family: inherit; cursor: pointer; border: none; background: none; color: inherit; }
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
input, textarea { font-family: inherit; background: transparent; border: none; color: inherit; outline: none; }
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.auth-overlay {
|
||||
position: fixed; inset: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: var(--bg);
|
||||
}
|
||||
.auth-modal {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 28px;
|
||||
width: 360px;
|
||||
max-width: calc(100vw - 32px);
|
||||
}
|
||||
.auth-modal h2 { font-size: 20px; margin-bottom: 8px; }
|
||||
.auth-hint { color: var(--text-mute); font-size: 13px; margin-bottom: 20px; }
|
||||
.auth-input {
|
||||
width: 100%;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.auth-input:focus { border-color: var(--accent-strong); }
|
||||
.auth-btn {
|
||||
width: 100%;
|
||||
background: var(--accent-strong);
|
||||
color: #fff;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.auth-btn:hover:not(:disabled) { background: var(--accent); }
|
||||
.auth-err {
|
||||
color: var(--accent-red);
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
background: rgba(239,68,68,0.08);
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.root { height: 100%; display: flex; }
|
||||
|
||||
.sidebar {
|
||||
width: 340px;
|
||||
border-right: 1px solid var(--border-soft);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-elev);
|
||||
}
|
||||
.side-head {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
.side-head h1 { font-size: 17px; font-weight: 600; }
|
||||
.logout-btn {
|
||||
width: 28px; height: 28px; border-radius: 50%;
|
||||
font-size: 14px; color: var(--text-mute);
|
||||
}
|
||||
.logout-btn:hover { background: rgba(255,255,255,0.06); color: var(--text); }
|
||||
|
||||
.upload-row {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.rec-btn {
|
||||
flex: 1;
|
||||
background: var(--accent-strong);
|
||||
color: #fff;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.rec-btn:hover:not(:disabled) { background: var(--accent); }
|
||||
.rec-btn.recording {
|
||||
background: var(--accent-red);
|
||||
animation: rec-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes rec-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
.upload-pick { position: relative; display: block; cursor: pointer; flex-shrink: 0; }
|
||||
.upload-pick input[type=file] { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
|
||||
.upload-btn {
|
||||
display: block;
|
||||
text-align: center;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.upload-btn:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.upload-btn.uploading { background: var(--bg-card); color: var(--text-dim); }
|
||||
.upload-btn.small { padding: 10px 12px; }
|
||||
.upload-err {
|
||||
color: var(--accent-red);
|
||||
font-size: 12px;
|
||||
margin: 0 12px 8px;
|
||||
background: rgba(239,68,68,0.08);
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.list { list-style: none; flex: 1; overflow-y: auto; }
|
||||
.list-empty { padding: 40px 16px; text-align: center; color: var(--text-mute); font-size: 13px; }
|
||||
.item {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
cursor: pointer;
|
||||
}
|
||||
.item:hover { background: var(--bg-card); }
|
||||
.item.active { background: var(--bg-active); }
|
||||
.item.active .item-title { color: var(--accent); }
|
||||
.item.failed .status { color: var(--accent-red); }
|
||||
.item.done .status { color: var(--accent-green); }
|
||||
.item-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.item-meta {
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: var(--text-mute);
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px 32px;
|
||||
}
|
||||
.empty {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-mute);
|
||||
font-size: 15px;
|
||||
}
|
||||
.cont-head { margin-bottom: 18px; }
|
||||
.cont-head h2 { font-size: 22px; margin-bottom: 6px; }
|
||||
.head-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-mute);
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
/* 旧 .danger-btn / .retry-btn 已被 .action-btn 替代 */
|
||||
|
||||
.title-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.title-row h2 { flex: 1; min-width: 0; }
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.action-btn {
|
||||
font-size: 12px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-elev);
|
||||
color: var(--text-dim);
|
||||
border: 1px solid var(--border);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.action-btn:hover:not(:disabled) { background: var(--bg-hover); color: var(--text); }
|
||||
.action-btn.danger { color: var(--accent-red); }
|
||||
.action-btn.danger:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
border-color: rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
|
||||
.feishu-row {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.feishu-link {
|
||||
color: var(--accent-cyan);
|
||||
background: rgba(6, 182, 212, 0.1);
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
text-decoration: none;
|
||||
}
|
||||
.feishu-link:hover { background: rgba(6, 182, 212, 0.2); }
|
||||
.feishu-btn {
|
||||
background: var(--accent-strong);
|
||||
color: #fff;
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.feishu-btn:hover:not(:disabled) { background: var(--accent); }
|
||||
.feishu-err {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
color: var(--accent-red);
|
||||
background: rgba(239,68,68,0.08);
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.retry-btn { background: rgba(124, 92, 191, 0.15); color: var(--accent); }
|
||||
.retry-btn:hover { background: rgba(124, 92, 191, 0.3); }
|
||||
.danger-btn { background: rgba(239, 68, 68, 0.1); color: var(--accent-red); }
|
||||
.danger-btn:hover { background: rgba(239, 68, 68, 0.25); }
|
||||
|
||||
.audio { width: 100%; margin-bottom: 20px; }
|
||||
|
||||
.block {
|
||||
background: var(--bg-card);
|
||||
border-radius: 8px;
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.block.err { background: rgba(239,68,68,0.08); }
|
||||
.block h3 {
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.muted { color: var(--text-mute); font-size: 13px; }
|
||||
.transcript {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--text-dim);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
.prose { font-size: 14px; line-height: 1.7; }
|
||||
.prose :deep(p) { margin-bottom: 10px; }
|
||||
.prose :deep(h2), .prose :deep(h3), .prose :deep(h4) {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
margin: 14px 0 6px;
|
||||
}
|
||||
.prose :deep(b) { color: var(--accent); }
|
||||
.prose :deep(code) {
|
||||
background: var(--bg-elev);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
.block.err pre { white-space: pre-wrap; color: var(--accent-red); font-size: 12px; }
|
||||
|
||||
.block details > summary {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.block details > summary::-webkit-details-marker { display: none; }
|
||||
.block details > summary::before {
|
||||
content: '▶';
|
||||
display: inline-block;
|
||||
margin-right: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--text-mute);
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.block details[open] > summary::before { transform: rotate(90deg); }
|
||||
.block details > summary h3 {
|
||||
margin: 0 !important;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.root { flex-direction: column; }
|
||||
.sidebar { width: 100%; height: 45vh; border-right: none; border-bottom: 1px solid var(--border-soft); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,54 @@
|
||||
// 鉴权:每个请求加 Authorization: token <pass>,<audio> 用 ?token= 兜底。
|
||||
|
||||
const KEY = 'notes.pass'
|
||||
|
||||
export function getPass() {
|
||||
return localStorage.getItem(KEY) || ''
|
||||
}
|
||||
export function setPass(v) {
|
||||
localStorage.setItem(KEY, v || '')
|
||||
}
|
||||
export function clearPass() {
|
||||
localStorage.removeItem(KEY)
|
||||
}
|
||||
|
||||
async function jreq(path, opts = {}) {
|
||||
const pass = getPass()
|
||||
const h = { 'Authorization': 'token ' + pass, ...(opts.headers || {}) }
|
||||
if (opts.body && !(opts.body instanceof FormData) && !h['Content-Type']) {
|
||||
h['Content-Type'] = 'application/json'
|
||||
}
|
||||
const r = await fetch(path, { ...opts, headers: h })
|
||||
if (r.status === 401) {
|
||||
const err = new Error('unauthorized')
|
||||
err.unauthorized = true
|
||||
throw err
|
||||
}
|
||||
if (!r.ok) {
|
||||
const t = await r.text().catch(() => '')
|
||||
throw new Error(t || `${r.status}`)
|
||||
}
|
||||
return r.json()
|
||||
}
|
||||
|
||||
export function listRecordings() { return jreq('/api/recordings') }
|
||||
export function getRecording(id) { return jreq('/api/recordings/' + id) }
|
||||
export function deleteRecording(id) { return jreq('/api/recordings/' + id, { method: 'DELETE' }) }
|
||||
export function renameRecording(id, title) {
|
||||
return jreq('/api/recordings/' + id, { method: 'PATCH', body: JSON.stringify({ title }) })
|
||||
}
|
||||
export function retryRecording(id) { return jreq('/api/recordings/' + id + '/retry', { method: 'POST' }) }
|
||||
export function convertFeishu(id) {
|
||||
return jreq('/api/recordings/' + id + '/feishu', { method: 'POST' })
|
||||
}
|
||||
|
||||
export function uploadRecording(title, file) {
|
||||
const fd = new FormData()
|
||||
if (title) fd.append('title', title)
|
||||
fd.append('audio', file, file.name)
|
||||
return jreq('/api/recordings', { method: 'POST', body: fd })
|
||||
}
|
||||
|
||||
export function audioUrl(id) {
|
||||
return `/api/recordings/${id}/audio?token=${encodeURIComponent(getPass())}`
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
createApp(App).mount('#app')
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8080'
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,204 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: cube-notes
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: notes-data
|
||||
namespace: cube-notes
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 30Gi
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: notes
|
||||
namespace: cube-notes
|
||||
labels:
|
||||
app: notes
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app: notes
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: notes
|
||||
spec:
|
||||
imagePullSecrets:
|
||||
- name: registry-creds
|
||||
initContainers:
|
||||
# lark-cli auth 分两处:
|
||||
# ~/.lark-cli/config.json — app id / open id 索引
|
||||
# ~/.local/share/lark-cli/*.enc + master.key — 加密的 OAuth user token
|
||||
# secret volume 只读但 lark-cli 跑时要刷 token 写回。先 cp 到 PVC 让它可写。
|
||||
# 已存在不覆盖:保留运行时 refresh 过的 token,免每次重启回滚到老 token。
|
||||
- name: lark-config-init
|
||||
image: busybox:1.36
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
mkdir -p /data/lark-cli /data/lark-share
|
||||
if [ ! -f /data/lark-cli/config.json ]; then
|
||||
cp /secrets/lark-cli/config.json /data/lark-cli/config.json
|
||||
echo "seeded ~/.lark-cli/config.json"
|
||||
fi
|
||||
for f in master.key appsecret_cli_a3f21503fbb8900e.enc cli_a3f21503fbb8900e_ou_1d4fb299843b6a341c1942b056181ca8.enc; do
|
||||
if [ ! -f "/data/lark-share/$f" ]; then
|
||||
cp "/secrets/lark-cli/$f" "/data/lark-share/$f"
|
||||
echo "seeded ~/.local/share/lark-cli/$f"
|
||||
fi
|
||||
done
|
||||
chmod -R 600 /data/lark-cli /data/lark-share 2>/dev/null || true
|
||||
volumeMounts:
|
||||
- name: lark-cli-secret
|
||||
mountPath: /secrets/lark-cli
|
||||
readOnly: true
|
||||
- name: data
|
||||
mountPath: /data
|
||||
containers:
|
||||
- name: notes
|
||||
image: registry.famzheng.me/mochi/notes:latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
name: http
|
||||
env:
|
||||
- name: DB_PATH
|
||||
value: /data/app.db
|
||||
- name: BLOBS_DIR
|
||||
value: /data/blobs
|
||||
- name: LLM_GATEWAY
|
||||
value: http://3.135.65.204:8848/v1
|
||||
- name: LLM_MODEL
|
||||
value: gemma-4-31b-it
|
||||
- name: PASSPHRASE
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: notes-creds
|
||||
key: passphrase
|
||||
- name: LLM_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: notes-creds
|
||||
key: llm_token
|
||||
- name: FEISHU_URL
|
||||
value: http://localhost:8002
|
||||
readinessProbe:
|
||||
httpGet: { path: /healthz, port: http }
|
||||
initialDelaySeconds: 1
|
||||
periodSeconds: 5
|
||||
livenessProbe:
|
||||
httpGet: { path: /healthz, port: http }
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 15
|
||||
resources:
|
||||
requests: { cpu: 10m, memory: 32Mi }
|
||||
limits: { cpu: 1000m, memory: 512Mi }
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
- name: feishu
|
||||
image: registry.famzheng.me/mochi/notes-feishu:latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- containerPort: 8002
|
||||
name: feishu
|
||||
env:
|
||||
- name: ASR_URL
|
||||
value: http://18.159.112.195:8848/v1/audio/transcriptions
|
||||
- name: ASR_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: notes-creds
|
||||
key: asr_token
|
||||
- name: LLM_GATEWAY
|
||||
value: http://3.135.65.204:8848/v1
|
||||
- name: LLM_MODEL
|
||||
value: gemma-4-31b-it
|
||||
- name: LLM_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: notes-creds
|
||||
key: llm_token
|
||||
readinessProbe:
|
||||
httpGet: { path: /healthz, port: feishu }
|
||||
initialDelaySeconds: 3
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
httpGet: { path: /healthz, port: feishu }
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 30
|
||||
resources:
|
||||
requests: { cpu: 20m, memory: 64Mi }
|
||||
limits: { cpu: 500m, memory: 384Mi }
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
- name: data
|
||||
mountPath: /root/.lark-cli
|
||||
subPath: lark-cli
|
||||
- name: data
|
||||
mountPath: /root/.local/share/lark-cli
|
||||
subPath: lark-share
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: notes-data
|
||||
- name: lark-cli-secret
|
||||
secret:
|
||||
secretName: lark-cli-creds
|
||||
# 默认挂全部 keys(config.json + master.key + 两个 .enc)
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: notes
|
||||
namespace: cube-notes
|
||||
spec:
|
||||
selector:
|
||||
app: notes
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: 8080
|
||||
---
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: bodylimit
|
||||
namespace: cube-notes
|
||||
spec:
|
||||
buffering:
|
||||
maxRequestBodyBytes: 629145600
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: notes
|
||||
namespace: cube-notes
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/router.middlewares: cube-notes-bodylimit@kubernetescrd
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: notes.famzheng.me
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: notes
|
||||
port:
|
||||
number: 80
|
||||
@@ -0,0 +1,925 @@
|
||||
//! notes.famzheng.me — 录音 → ASR → LLM 会议纪要。
|
||||
//!
|
||||
//! 鉴权:所有 /api/* 必须带 `Authorization: token <PASSPHRASE>` header
|
||||
//! (audio 流式播放支持 ?token=<PASSPHRASE> query 兜底,因为 <audio>
|
||||
//! 标签没法塞自定义 header)。
|
||||
//! 配置:全部通过环境变量注入(PASSPHRASE / ASR_* / LLM_*);k8s Secret 挂进来。
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{DefaultBodyLimit, Multipart, Path, Request, State},
|
||||
http::{header, StatusCode},
|
||||
middleware::{from_fn_with_state, Next},
|
||||
response::{IntoResponse, Json as JsonResp, Response},
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tower::ServiceExt;
|
||||
|
||||
const SINGLE_FILE_BYTES: usize = 500 * 1024 * 1024; // 500 MiB / 单录音
|
||||
const REQUEST_BYTES: usize = 600 * 1024 * 1024;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
db: Arc<Mutex<Connection>>,
|
||||
blobs_dir: PathBuf,
|
||||
passphrase: String,
|
||||
llm_gateway: String,
|
||||
llm_token: String,
|
||||
llm_model: String,
|
||||
feishu_url: String,
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
cube_core::init_tracing();
|
||||
|
||||
let db_path = std::env::var("DB_PATH").unwrap_or_else(|_| "/data/app.db".into());
|
||||
let blobs_dir =
|
||||
PathBuf::from(std::env::var("BLOBS_DIR").unwrap_or_else(|_| "/data/blobs".into()));
|
||||
let dist = std::env::var("NOTES_DIST_DIR").unwrap_or_else(|_| "/dist".into());
|
||||
|
||||
let passphrase = std::env::var("PASSPHRASE").unwrap_or_default();
|
||||
if passphrase.is_empty() {
|
||||
tracing::warn!("PASSPHRASE not set — all /api/* will return 401");
|
||||
}
|
||||
// ASR 现在由 sidecar 调(切片串行),主容器不再直接调外部 ASR
|
||||
let llm_gateway =
|
||||
std::env::var("LLM_GATEWAY").unwrap_or_else(|_| "http://3.135.65.204:8848/v1".into());
|
||||
let llm_token = std::env::var("LLM_TOKEN").unwrap_or_default();
|
||||
let llm_model = std::env::var("LLM_MODEL").unwrap_or_else(|_| "gemma-4-31b-it".into());
|
||||
let feishu_url =
|
||||
std::env::var("FEISHU_URL").unwrap_or_else(|_| "http://localhost:8002".into());
|
||||
|
||||
std::fs::create_dir_all(&blobs_dir).expect("mkdir blobs_dir");
|
||||
|
||||
let conn = Connection::open(&db_path).expect("open sqlite");
|
||||
conn.execute_batch(
|
||||
"PRAGMA journal_mode=WAL;
|
||||
CREATE TABLE IF NOT EXISTS recordings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
mime TEXT NOT NULL,
|
||||
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
transcript TEXT,
|
||||
summary TEXT,
|
||||
error TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);",
|
||||
)
|
||||
.expect("init schema");
|
||||
// 兼容旧 db 增量加列;已存在忽略错误
|
||||
let _ = conn.execute("ALTER TABLE recordings ADD COLUMN feishu_doc_id TEXT", []);
|
||||
let _ = conn.execute("ALTER TABLE recordings ADD COLUMN feishu_url TEXT", []);
|
||||
let _ = conn.execute("ALTER TABLE recordings ADD COLUMN cleaned TEXT", []);
|
||||
tracing::info!(%db_path, blobs = %blobs_dir.display(), "notes ready");
|
||||
|
||||
let http = reqwest::Client::builder()
|
||||
.build()
|
||||
.expect("build reqwest client");
|
||||
|
||||
let state = AppState {
|
||||
db: Arc::new(Mutex::new(conn)),
|
||||
blobs_dir,
|
||||
passphrase,
|
||||
llm_gateway,
|
||||
llm_token,
|
||||
llm_model,
|
||||
feishu_url,
|
||||
http,
|
||||
};
|
||||
|
||||
// 鉴权 middleware 包到 /api 上
|
||||
let protected_api = Router::new()
|
||||
.route("/recordings", get(list_recordings).post(upload_recording).layer(
|
||||
DefaultBodyLimit::max(REQUEST_BYTES),
|
||||
))
|
||||
.route("/recordings/:id", get(get_recording).patch(patch_recording).delete(delete_recording))
|
||||
.route("/recordings/:id/audio", get(stream_audio))
|
||||
.route("/recordings/:id/retry", post(retry_recording))
|
||||
.route("/recordings/:id/feishu", post(convert_feishu))
|
||||
.with_state(state.clone())
|
||||
.layer(from_fn_with_state(state.clone(), auth_middleware));
|
||||
|
||||
let api = Router::new()
|
||||
.route("/health", get(|| async { "ok" }))
|
||||
.merge(protected_api);
|
||||
|
||||
// 启动时把上次 pod 死前卡在中间状态的 recording 重新喂给 worker。
|
||||
// 状态 transcribing/summarizing 是 worker 进程内存的,pod 重启就丢,
|
||||
// db 还停留在原状态 → 不 resume 永远不会再动。
|
||||
{
|
||||
let stuck: Vec<i64> = {
|
||||
let conn = state.db.lock().unwrap();
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id FROM recordings
|
||||
WHERE status IN ('pending', 'transcribing', 'summarizing')
|
||||
ORDER BY id ASC",
|
||||
)
|
||||
.expect("prepare resume query");
|
||||
stmt.query_map([], |r| r.get::<_, i64>(0))
|
||||
.expect("run resume query")
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.expect("collect resume ids")
|
||||
};
|
||||
if !stuck.is_empty() {
|
||||
tracing::info!(count = stuck.len(), ids = ?stuck, "resuming stuck recordings");
|
||||
for id in stuck {
|
||||
let s = state.clone();
|
||||
tokio::spawn(async move {
|
||||
// 改回 pending 让 worker 从头跑(idempotent)
|
||||
{
|
||||
let conn = s.db.lock().unwrap();
|
||||
let _ = conn.execute(
|
||||
"UPDATE recordings SET status = 'pending', error = NULL WHERE id = ?1",
|
||||
params![id],
|
||||
);
|
||||
}
|
||||
process_recording(s, id).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let app = cube_core::base(dist).nest("/api", api);
|
||||
cube_core::serve(app, 8080).await
|
||||
}
|
||||
|
||||
// ---------- 鉴权 middleware ----------
|
||||
|
||||
async fn auth_middleware(
|
||||
State(s): State<AppState>,
|
||||
req: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
if s.passphrase.is_empty() {
|
||||
return (StatusCode::UNAUTHORIZED, "server not configured").into_response();
|
||||
}
|
||||
// 优先看 Authorization header
|
||||
let header_ok = req
|
||||
.headers()
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|v| {
|
||||
v.strip_prefix("token ")
|
||||
.or_else(|| v.strip_prefix("Token "))
|
||||
.or_else(|| v.strip_prefix("Bearer "))
|
||||
.map(|t| t.trim() == s.passphrase)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
// 再看 ?token= query(给 <audio src> 兜底)
|
||||
let query_ok = req.uri().query().and_then(|q| {
|
||||
for kv in q.split('&') {
|
||||
if let Some(v) = kv.strip_prefix("token=") {
|
||||
let decoded = percent_decode(v);
|
||||
if decoded == s.passphrase {
|
||||
return Some(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}).unwrap_or(false);
|
||||
if header_ok || query_ok {
|
||||
next.run(req).await
|
||||
} else {
|
||||
(StatusCode::UNAUTHORIZED, "unauthorized").into_response()
|
||||
}
|
||||
}
|
||||
|
||||
fn percent_decode(s: &str) -> String {
|
||||
let bytes = s.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%' && i + 2 < bytes.len() {
|
||||
if let (Some(h), Some(l)) = (hex(bytes[i + 1]), hex(bytes[i + 2])) {
|
||||
out.push((h << 4) | l);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(if bytes[i] == b'+' { b' ' } else { bytes[i] });
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8(out).unwrap_or_default()
|
||||
}
|
||||
fn hex(b: u8) -> Option<u8> {
|
||||
match b {
|
||||
b'0'..=b'9' => Some(b - b'0'),
|
||||
b'a'..=b'f' => Some(b - b'a' + 10),
|
||||
b'A'..=b'F' => Some(b - b'A' + 10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- types ----------
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RecordingSummary {
|
||||
id: i64,
|
||||
title: String,
|
||||
filename: String,
|
||||
mime: String,
|
||||
size_bytes: i64,
|
||||
status: String,
|
||||
created_at: String,
|
||||
has_transcript: bool,
|
||||
has_summary: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RecordingDetail {
|
||||
id: i64,
|
||||
title: String,
|
||||
filename: String,
|
||||
mime: String,
|
||||
size_bytes: i64,
|
||||
status: String,
|
||||
transcript: Option<String>,
|
||||
cleaned: Option<String>,
|
||||
summary: Option<String>,
|
||||
error: Option<String>,
|
||||
created_at: String,
|
||||
feishu_doc_id: Option<String>,
|
||||
feishu_url: Option<String>,
|
||||
}
|
||||
|
||||
// ---------- handlers ----------
|
||||
|
||||
async fn list_recordings(
|
||||
State(s): State<AppState>,
|
||||
) -> Result<JsonResp<Value>, AppError> {
|
||||
let conn = s.db.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, title, filename, mime, size_bytes, status, created_at,
|
||||
CASE WHEN transcript IS NOT NULL AND length(transcript) > 0 THEN 1 ELSE 0 END,
|
||||
CASE WHEN summary IS NOT NULL AND length(summary) > 0 THEN 1 ELSE 0 END
|
||||
FROM recordings ORDER BY created_at DESC, id DESC",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map([], |r| {
|
||||
let ht: i64 = r.get(7)?;
|
||||
let hs: i64 = r.get(8)?;
|
||||
Ok(RecordingSummary {
|
||||
id: r.get(0)?,
|
||||
title: r.get(1)?,
|
||||
filename: r.get(2)?,
|
||||
mime: r.get(3)?,
|
||||
size_bytes: r.get(4)?,
|
||||
status: r.get(5)?,
|
||||
created_at: r.get(6)?,
|
||||
has_transcript: ht != 0,
|
||||
has_summary: hs != 0,
|
||||
})
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(JsonResp(json!(rows)))
|
||||
}
|
||||
|
||||
async fn get_recording(
|
||||
State(s): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<JsonResp<RecordingDetail>, AppError> {
|
||||
let conn = s.db.lock().unwrap();
|
||||
type Row = (
|
||||
String, String, String, i64, String,
|
||||
Option<String>, Option<String>, Option<String>, Option<String>, String,
|
||||
Option<String>, Option<String>,
|
||||
);
|
||||
let row: Option<Row> = conn
|
||||
.query_row(
|
||||
"SELECT title, filename, mime, size_bytes, status,
|
||||
transcript, cleaned, summary, error, created_at,
|
||||
feishu_doc_id, feishu_url
|
||||
FROM recordings WHERE id = ?1",
|
||||
params![id],
|
||||
|r| {
|
||||
Ok((
|
||||
r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?,
|
||||
r.get(5)?, r.get(6)?, r.get(7)?, r.get(8)?, r.get(9)?,
|
||||
r.get(10)?, r.get(11)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.optional()?;
|
||||
let (title, filename, mime, size_bytes, status, transcript, cleaned, summary, error, created_at,
|
||||
feishu_doc_id, feishu_url) = row.ok_or(AppError::NotFound)?;
|
||||
Ok(JsonResp(RecordingDetail {
|
||||
id, title, filename, mime, size_bytes, status,
|
||||
transcript, cleaned, summary, error, created_at,
|
||||
feishu_doc_id, feishu_url,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn upload_recording(
|
||||
State(s): State<AppState>,
|
||||
mut form: Multipart,
|
||||
) -> Result<JsonResp<Value>, AppError> {
|
||||
let mut title: Option<String> = None;
|
||||
let mut filename: Option<String> = None;
|
||||
let mut mime: Option<String> = None;
|
||||
let mut tmp_path: Option<PathBuf> = None;
|
||||
let mut size: usize = 0;
|
||||
|
||||
while let Some(mut field) = form
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|e| AppError::bad_request(format!("multipart: {e}")))?
|
||||
{
|
||||
let name = field.name().unwrap_or("").to_string();
|
||||
match name.as_str() {
|
||||
"title" => {
|
||||
let s = field
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| AppError::bad_request(format!("title: {e}")))?;
|
||||
title = Some(s.trim().to_string());
|
||||
}
|
||||
"audio" | "file" => {
|
||||
let fn_ = field
|
||||
.file_name()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| "recording".to_string());
|
||||
let m = field
|
||||
.content_type()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||
if !m.starts_with("audio/") && !m.starts_with("video/") && m != "application/octet-stream" {
|
||||
return Err(AppError::bad_request(format!("unsupported mime '{m}'")));
|
||||
}
|
||||
filename = Some(fn_);
|
||||
mime = Some(m);
|
||||
// 流式落 tmp
|
||||
let tmp = s.blobs_dir.join(format!("upload-{}.tmp", std::process::id()));
|
||||
let mut f = tokio::fs::File::create(&tmp).await.map_err(AppError::Io)?;
|
||||
while let Some(chunk) = field
|
||||
.chunk()
|
||||
.await
|
||||
.map_err(|e| AppError::bad_request(format!("upload: {e}")))?
|
||||
{
|
||||
size += chunk.len();
|
||||
if size > SINGLE_FILE_BYTES {
|
||||
let _ = tokio::fs::remove_file(&tmp).await;
|
||||
return Err(AppError::bad_request(format!(
|
||||
"file too large (>{SINGLE_FILE_BYTES} bytes)"
|
||||
)));
|
||||
}
|
||||
f.write_all(&chunk).await.map_err(AppError::Io)?;
|
||||
}
|
||||
f.sync_all().await.map_err(AppError::Io)?;
|
||||
tmp_path = Some(tmp);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let filename = filename.ok_or_else(|| AppError::bad_request("missing audio file"))?;
|
||||
let mime = mime.unwrap_or_else(|| "audio/mpeg".to_string());
|
||||
let tmp_path = tmp_path.ok_or_else(|| AppError::bad_request("no file uploaded"))?;
|
||||
// title 完全可选;空时用本地时间 "录音 YYYY-MM-DD HH:MM",比丑的 filename 好读
|
||||
let title = title.filter(|x| !x.is_empty()).unwrap_or_else(|| {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0) as i64;
|
||||
let bst_offset = 3600; // 简单 BST/UTC+1,cube 在伦敦
|
||||
let t = secs + bst_offset;
|
||||
let day = t / 86400;
|
||||
let h = (t % 86400) / 3600;
|
||||
let m = (t % 3600) / 60;
|
||||
// 简化日期计算(够看就行)
|
||||
let y = 1970 + day / 365;
|
||||
let yday = (day % 365) as i64;
|
||||
let mo = (yday / 30 + 1).min(12);
|
||||
let d = (yday % 30 + 1).min(28);
|
||||
format!("录音 {:04}-{:02}-{:02} {:02}:{:02}", y, mo, d, h, m)
|
||||
});
|
||||
|
||||
let id = {
|
||||
let conn = s.db.lock().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO recordings (title, filename, mime, size_bytes, status)
|
||||
VALUES (?1, ?2, ?3, ?4, 'pending')",
|
||||
params![title, filename, mime, size as i64],
|
||||
)?;
|
||||
conn.last_insert_rowid()
|
||||
};
|
||||
|
||||
let final_path = s.blobs_dir.join(id.to_string());
|
||||
if let Err(e) = tokio::fs::rename(&tmp_path, &final_path).await {
|
||||
let _ = tokio::fs::remove_file(&tmp_path).await;
|
||||
let conn = s.db.lock().unwrap();
|
||||
let _ = conn.execute("DELETE FROM recordings WHERE id = ?1", params![id]);
|
||||
return Err(AppError::Io(e));
|
||||
}
|
||||
|
||||
// 后台处理
|
||||
let state_clone = s.clone();
|
||||
tokio::spawn(async move {
|
||||
process_recording(state_clone, id).await;
|
||||
});
|
||||
|
||||
Ok(JsonResp(json!({ "id": id, "status": "pending" })))
|
||||
}
|
||||
|
||||
async fn process_recording(s: AppState, id: i64) {
|
||||
let path = s.blobs_dir.join(id.to_string());
|
||||
// 取已有的 transcript(让 retry 跳过 ASR 直接 cleanup + summary)
|
||||
let (filename, existing_transcript): (String, Option<String>) = {
|
||||
let conn = s.db.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT filename, transcript FROM recordings WHERE id = ?1",
|
||||
params![id],
|
||||
|r| Ok((r.get::<_, String>(0)?, r.get::<_, Option<String>>(1)?)),
|
||||
)
|
||||
.unwrap_or_else(|_| ("audio".to_string(), None))
|
||||
};
|
||||
let has_transcript = existing_transcript
|
||||
.as_deref()
|
||||
.map(|t| !t.trim().is_empty())
|
||||
.unwrap_or(false);
|
||||
|
||||
let transcript = if has_transcript {
|
||||
tracing::info!(%id, "transcript exists, skip ASR");
|
||||
existing_transcript.unwrap()
|
||||
} else {
|
||||
set_status(&s, id, "transcribing", None, None);
|
||||
match call_asr(&s, &path, &filename).await {
|
||||
Ok(t) => {
|
||||
let conn = s.db.lock().unwrap();
|
||||
let _ = conn.execute(
|
||||
"UPDATE recordings SET transcript = ?1, status = 'cleaning' WHERE id = ?2",
|
||||
params![&t, id],
|
||||
);
|
||||
t
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(%id, error = %e, "ASR failed");
|
||||
set_status(&s, id, "failed", None, Some(&format!("ASR: {e}")));
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
// 不管走哪条都进 cleaning
|
||||
set_status(&s, id, "cleaning", None, None);
|
||||
|
||||
// LLM cleanup:分段 + 去口语 + 润色 + 高亮(失败也继续 summary,不阻塞)
|
||||
match call_llm_cleanup(&s, &transcript).await {
|
||||
Ok(c) => {
|
||||
let conn = s.db.lock().unwrap();
|
||||
let _ = conn.execute(
|
||||
"UPDATE recordings SET cleaned = ?1, status = 'summarizing' WHERE id = ?2",
|
||||
params![&c, id],
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(%id, error = %e, "cleanup failed, skip and continue to summary");
|
||||
let conn = s.db.lock().unwrap();
|
||||
let _ = conn.execute(
|
||||
"UPDATE recordings SET status = 'summarizing' WHERE id = ?1",
|
||||
params![id],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// LLM:生成会议纪要 + 标题
|
||||
let raw = match call_llm_summary(&s, &transcript).await {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
tracing::error!(%id, error = %e, "LLM failed");
|
||||
set_status(&s, id, "failed", None, Some(&format!("LLM: {e}")));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (new_title, summary_body) = parse_title_from_summary(&raw);
|
||||
{
|
||||
let conn = s.db.lock().unwrap();
|
||||
if let Some(t) = new_title.as_deref() {
|
||||
let _ = conn.execute(
|
||||
"UPDATE recordings SET title = ?1, summary = ?2, status = 'done', error = NULL WHERE id = ?3",
|
||||
params![t, &summary_body, id],
|
||||
);
|
||||
} else {
|
||||
let _ = conn.execute(
|
||||
"UPDATE recordings SET summary = ?1, status = 'done', error = NULL WHERE id = ?2",
|
||||
params![&summary_body, id],
|
||||
);
|
||||
}
|
||||
}
|
||||
tracing::info!(%id, title = ?new_title, "done");
|
||||
}
|
||||
|
||||
/// 从 LLM 输出剥离 `TITLE: ...\n---\n` 头部。
|
||||
/// 返回 (Option<title>, summary_body),title 失败时返回 None + 原文。
|
||||
fn parse_title_from_summary(raw: &str) -> (Option<String>, String) {
|
||||
let mut lines = raw.lines();
|
||||
let first = lines.next().unwrap_or("").trim();
|
||||
let Some(rest) = first
|
||||
.strip_prefix("TITLE:")
|
||||
.or_else(|| first.strip_prefix("Title:"))
|
||||
.or_else(|| first.strip_prefix("标题:"))
|
||||
.or_else(|| first.strip_prefix("标题:"))
|
||||
else {
|
||||
return (None, raw.to_string());
|
||||
};
|
||||
let title: String = rest.trim().chars().take(80).collect();
|
||||
if title.is_empty() {
|
||||
return (None, raw.to_string());
|
||||
}
|
||||
// 吃掉接下来的 `---` separator + 空行
|
||||
let body: String = lines
|
||||
.skip_while(|l| {
|
||||
let t = l.trim();
|
||||
t.is_empty() || t == "---" || t.starts_with("---")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
(Some(title), body)
|
||||
}
|
||||
|
||||
fn set_status(s: &AppState, id: i64, status: &str, transcript: Option<&str>, error: Option<&str>) {
|
||||
let conn = s.db.lock().unwrap();
|
||||
let _ = conn.execute(
|
||||
"UPDATE recordings SET status = ?1, error = ?2,
|
||||
transcript = COALESCE(?3, transcript)
|
||||
WHERE id = ?4",
|
||||
params![status, error, transcript, id],
|
||||
);
|
||||
}
|
||||
|
||||
async fn call_asr(
|
||||
s: &AppState,
|
||||
path: &std::path::Path,
|
||||
_filename: &str,
|
||||
) -> Result<String, String> {
|
||||
// 走 sidecar /transcribe:sidecar 用 ffmpeg 切片 + 串行调外部 ASR,绕过 ASR server 单文件大小限制
|
||||
let url = format!("{}/transcribe", s.feishu_url.trim_end_matches('/'));
|
||||
let payload = json!({ "audio_path": path.to_string_lossy() });
|
||||
let resp = s
|
||||
.http
|
||||
.post(&url)
|
||||
.json(&payload)
|
||||
.timeout(std::time::Duration::from_secs(3600))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("connect sidecar: {e}"))?;
|
||||
if !resp.status().is_success() {
|
||||
let st = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("sidecar /transcribe {st}: {body}"));
|
||||
}
|
||||
let v: Value = resp.json().await.map_err(|e| format!("decode: {e}"))?;
|
||||
let text = v
|
||||
.get("text")
|
||||
.and_then(|x| x.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| format!("no 'text' in response: {v}"))?;
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
async fn call_llm_cleanup(s: &AppState, transcript: &str) -> Result<String, String> {
|
||||
let trimmed = if transcript.chars().count() > 12000 {
|
||||
let mut out = String::new();
|
||||
for (i, c) in transcript.chars().enumerate() {
|
||||
if i >= 12000 { break; }
|
||||
out.push(c);
|
||||
}
|
||||
out + "\n\n[... 后文截断]"
|
||||
} else {
|
||||
transcript.to_string()
|
||||
};
|
||||
let payload = json!({
|
||||
"model": s.llm_model,
|
||||
"messages": [
|
||||
{ "role": "system", "content":
|
||||
"你是 ASR 转写后处理助手。把下面这段连续无标点的转写整理成可读版本:\n\
|
||||
\n\
|
||||
1. **自动分段**:按话题/语义换段,每段 2-5 句\n\
|
||||
2. **加标点**:句号、问号、感叹号、逗号、引号\n\
|
||||
3. **去口语噪音**:删掉「嗯/啊/那个/就是/对/然后...」等填充词,但保留实际含义的连接词\n\
|
||||
4. **轻度润色**:通顺、语法、错别字(结合上下文修 ASR 错字),但**不要总结、不要改变原意、不要添加内容**\n\
|
||||
5. **重点高亮**:把关键判断、结论、决定、数字、名词用 markdown `**...**` 加粗\n\
|
||||
\n\
|
||||
输出纯 markdown 段落,不要标题、不要列表、不要解释。" },
|
||||
{ "role": "user", "content": trimmed },
|
||||
],
|
||||
"temperature": 0.3,
|
||||
});
|
||||
let url = format!("{}/chat/completions", s.llm_gateway.trim_end_matches('/'));
|
||||
let resp = s
|
||||
.http
|
||||
.post(&url)
|
||||
.bearer_auth(&s.llm_token)
|
||||
.json(&payload)
|
||||
.timeout(std::time::Duration::from_secs(600))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("connect: {e}"))?;
|
||||
if !resp.status().is_success() {
|
||||
let st = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("LLM {st}: {body}"));
|
||||
}
|
||||
let v: Value = resp.json().await.map_err(|e| format!("decode: {e}"))?;
|
||||
let text = v
|
||||
.get("choices").and_then(|c| c.get(0))
|
||||
.and_then(|c| c.get("message"))
|
||||
.and_then(|m| m.get("content"))
|
||||
.and_then(|c| c.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| format!("LLM no content: {v}"))?;
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
async fn call_llm_summary(s: &AppState, transcript: &str) -> Result<String, String> {
|
||||
let trimmed = if transcript.chars().count() > 12000 {
|
||||
let mut out = String::new();
|
||||
for (i, c) in transcript.chars().enumerate() {
|
||||
if i >= 12000 { break; }
|
||||
out.push(c);
|
||||
}
|
||||
out + "\n\n[... 后文截断]"
|
||||
} else {
|
||||
transcript.to_string()
|
||||
};
|
||||
let payload = json!({
|
||||
"model": s.llm_model,
|
||||
"messages": [
|
||||
{ "role": "system", "content":
|
||||
"你是一个会议纪要助手。根据语音转写输出:\n\
|
||||
\n\
|
||||
第一行:`TITLE: <8-20 字符的会议主题>`(不含日期/时间,提取核心议题)\n\
|
||||
第二行:`---`\n\
|
||||
之后是 markdown 纪要:\n\
|
||||
1. **概要**:1-2 句话总结\n\
|
||||
2. **关键讨论点**:bullet 列出\n\
|
||||
3. **决定 / 结论**\n\
|
||||
4. **行动项 (action items)**:每条用 markdown checkbox 格式 `- [ ] 谁 · 做什么 · 何时`\n\
|
||||
5. **待跟进 / 未决问题**:bullet 列出\n\
|
||||
\n\
|
||||
转写可能有 ASR 错字,结合上下文合理修正;遇到模糊处标 [?]。\n\
|
||||
不要编造没说过的内容。" },
|
||||
{ "role": "user", "content": trimmed },
|
||||
],
|
||||
"temperature": 0.3,
|
||||
});
|
||||
let url = format!("{}/chat/completions", s.llm_gateway.trim_end_matches('/'));
|
||||
let resp = s
|
||||
.http
|
||||
.post(&url)
|
||||
.bearer_auth(&s.llm_token)
|
||||
.json(&payload)
|
||||
.timeout(std::time::Duration::from_secs(300))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("connect: {e}"))?;
|
||||
if !resp.status().is_success() {
|
||||
let st = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("LLM {st}: {body}"));
|
||||
}
|
||||
let v: Value = resp.json().await.map_err(|e| format!("decode: {e}"))?;
|
||||
let text = v
|
||||
.get("choices").and_then(|c| c.get(0))
|
||||
.and_then(|c| c.get("message"))
|
||||
.and_then(|m| m.get("content"))
|
||||
.and_then(|c| c.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| format!("LLM no content: {v}"))?;
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct PatchRecording {
|
||||
title: Option<String>,
|
||||
}
|
||||
|
||||
async fn patch_recording(
|
||||
State(s): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
JsonResp(body): JsonResp<PatchRecording>,
|
||||
) -> Result<JsonResp<Value>, AppError> {
|
||||
let conn = s.db.lock().unwrap();
|
||||
let exists: bool = conn
|
||||
.query_row("SELECT 1 FROM recordings WHERE id = ?1", params![id], |_| Ok(true))
|
||||
.optional()?
|
||||
.unwrap_or(false);
|
||||
if !exists { return Err(AppError::NotFound); }
|
||||
if let Some(t) = body.title.as_ref() {
|
||||
let t = t.trim();
|
||||
if t.is_empty() { return Err(AppError::bad_request("title can't be blank")); }
|
||||
let t: String = t.chars().take(120).collect();
|
||||
conn.execute("UPDATE recordings SET title = ?1 WHERE id = ?2", params![&t, id])?;
|
||||
}
|
||||
Ok(JsonResp(json!({ "ok": true })))
|
||||
}
|
||||
|
||||
async fn delete_recording(
|
||||
State(s): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<JsonResp<Value>, AppError> {
|
||||
let n = {
|
||||
let conn = s.db.lock().unwrap();
|
||||
conn.execute("DELETE FROM recordings WHERE id = ?1", params![id])?
|
||||
};
|
||||
if n == 0 {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
let _ = tokio::fs::remove_file(s.blobs_dir.join(id.to_string())).await;
|
||||
Ok(JsonResp(json!({ "ok": true })))
|
||||
}
|
||||
|
||||
async fn retry_recording(
|
||||
State(s): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<JsonResp<Value>, AppError> {
|
||||
{
|
||||
let conn = s.db.lock().unwrap();
|
||||
let exists: bool = conn
|
||||
.query_row("SELECT 1 FROM recordings WHERE id = ?1", params![id], |_| Ok(true))
|
||||
.optional()?
|
||||
.unwrap_or(false);
|
||||
if !exists { return Err(AppError::NotFound); }
|
||||
conn.execute(
|
||||
"UPDATE recordings SET status = 'pending', error = NULL WHERE id = ?1",
|
||||
params![id],
|
||||
)?;
|
||||
}
|
||||
let sc = s.clone();
|
||||
tokio::spawn(async move { process_recording(sc, id).await; });
|
||||
Ok(JsonResp(json!({ "ok": true, "status": "pending" })))
|
||||
}
|
||||
|
||||
/// `POST /api/recordings/:id/feishu` — 把转写 + 纪要 push 成飞书 docx。
|
||||
/// 已经转过的 piece 仍 update 同一个 doc(markdown-to-feishu 自带 --update)。
|
||||
async fn convert_feishu(
|
||||
State(s): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<JsonResp<Value>, AppError> {
|
||||
let row: (String, String, Option<String>, Option<String>, String, Option<String>) = {
|
||||
let conn = s.db.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT title, filename, transcript, summary, status, feishu_doc_id
|
||||
FROM recordings WHERE id = ?1",
|
||||
params![id],
|
||||
|r| {
|
||||
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?))
|
||||
},
|
||||
)
|
||||
.optional()?
|
||||
.ok_or(AppError::NotFound)?
|
||||
};
|
||||
let (title, filename, transcript, summary, status, existing_doc) = row;
|
||||
if status != "done" {
|
||||
return Err(AppError::bad_request(format!(
|
||||
"recording not ready (status={status})"
|
||||
)));
|
||||
}
|
||||
let summary = summary.unwrap_or_default();
|
||||
let transcript = transcript.unwrap_or_default();
|
||||
|
||||
// 拼 markdown
|
||||
let ext = std::path::Path::new(&filename)
|
||||
.extension()
|
||||
.and_then(|x| x.to_str())
|
||||
.unwrap_or("m4a")
|
||||
.to_string();
|
||||
let audio_name = format!("audio.{ext}");
|
||||
let md = format!(
|
||||
"# {title}\n\n\
|
||||
## 📋 会议纪要\n\n\
|
||||
{summary}\n\n\
|
||||
---\n\n\
|
||||
## 📎 原始材料\n\n\
|
||||
- [📄 转录原文](./transcript.txt)\n\
|
||||
- [🎙️ 原始录音](./{audio_name})\n\n\
|
||||
---\n\n\
|
||||
## 🎙️ 转录全文\n\n\
|
||||
{transcript}\n",
|
||||
);
|
||||
|
||||
// 落到 PVC 共享目录,sidecar 同样挂这个卷
|
||||
let work_dir = std::path::PathBuf::from(format!("/data/feishu-tmp/{id}"));
|
||||
tokio::fs::create_dir_all(&work_dir).await.map_err(AppError::Io)?;
|
||||
let md_path = work_dir.join("note.md");
|
||||
tokio::fs::write(&md_path, md).await.map_err(AppError::Io)?;
|
||||
tokio::fs::write(work_dir.join("transcript.txt"), &transcript)
|
||||
.await
|
||||
.map_err(AppError::Io)?;
|
||||
// 拷 audio(用 copy,sidecar 跑期间不会被改)
|
||||
let audio_src = s.blobs_dir.join(id.to_string());
|
||||
let audio_dst = work_dir.join(&audio_name);
|
||||
tokio::fs::copy(&audio_src, &audio_dst).await.map_err(AppError::Io)?;
|
||||
|
||||
// 调 sidecar
|
||||
let url = format!("{}/convert", s.feishu_url.trim_end_matches('/'));
|
||||
let mut payload = json!({
|
||||
"md_path": md_path.to_string_lossy(),
|
||||
"title": title,
|
||||
});
|
||||
if let Some(d) = existing_doc.as_deref().filter(|x| !x.is_empty()) {
|
||||
payload["existing_doc_id"] = json!(d);
|
||||
}
|
||||
let resp = s
|
||||
.http
|
||||
.post(&url)
|
||||
.json(&payload)
|
||||
.timeout(std::time::Duration::from_secs(300))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::bad_request(format!("feishu sidecar: {e}")))?;
|
||||
if !resp.status().is_success() {
|
||||
let st = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(AppError::bad_request(format!("feishu {st}: {body}")));
|
||||
}
|
||||
let body: Value = resp.json().await.map_err(|e| AppError::bad_request(format!("decode: {e}")))?;
|
||||
let doc_id = body.get("doc_id").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let doc_url = body.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
if doc_id.is_empty() || doc_url.is_empty() {
|
||||
return Err(AppError::bad_request(format!("feishu bad response: {body}")));
|
||||
}
|
||||
|
||||
{
|
||||
let conn = s.db.lock().unwrap();
|
||||
conn.execute(
|
||||
"UPDATE recordings SET feishu_doc_id = ?1, feishu_url = ?2 WHERE id = ?3",
|
||||
params![&doc_id, &doc_url, id],
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(JsonResp(json!({ "doc_id": doc_id, "url": doc_url })))
|
||||
}
|
||||
|
||||
async fn stream_audio(
|
||||
State(s): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
req: Request<Body>,
|
||||
) -> Result<Response, AppError> {
|
||||
let row: Option<(String, String)> = {
|
||||
let conn = s.db.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT mime, filename FROM recordings WHERE id = ?1",
|
||||
params![id],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.optional()?
|
||||
};
|
||||
let (mime, _filename) = row.ok_or(AppError::NotFound)?;
|
||||
let path = s.blobs_dir.join(id.to_string());
|
||||
let mime_hv: header::HeaderValue = mime
|
||||
.parse()
|
||||
.unwrap_or_else(|_| header::HeaderValue::from_static("application/octet-stream"));
|
||||
let svc = tower_http::services::ServeFile::new(&path);
|
||||
let mut resp = svc
|
||||
.oneshot(req)
|
||||
.await
|
||||
.map_err(|e| AppError::Io(std::io::Error::other(e.to_string())))?
|
||||
.into_response();
|
||||
resp.headers_mut().insert(header::CONTENT_TYPE, mime_hv);
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
// ---------- error type ----------
|
||||
|
||||
enum AppError {
|
||||
BadRequest(String),
|
||||
NotFound,
|
||||
Db(rusqlite::Error),
|
||||
Io(std::io::Error),
|
||||
}
|
||||
impl AppError {
|
||||
fn bad_request(m: impl Into<String>) -> Self { Self::BadRequest(m.into()) }
|
||||
}
|
||||
impl From<rusqlite::Error> for AppError {
|
||||
fn from(e: rusqlite::Error) -> Self { Self::Db(e) }
|
||||
}
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
match self {
|
||||
Self::BadRequest(m) => (StatusCode::BAD_REQUEST, m).into_response(),
|
||||
Self::NotFound => (StatusCode::NOT_FOUND, "not found").into_response(),
|
||||
Self::Db(e) => {
|
||||
tracing::error!(error = %e, "db");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "db error").into_response()
|
||||
}
|
||||
Self::Io(e) => {
|
||||
tracing::error!(error = %e, "io");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "io error").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,13 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#1a1a2e" />
|
||||
<link rel="icon" type="image/png" href="/favicon-48x48.png" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="狼人杀" />
|
||||
<title>狼人杀发牌器</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -7,15 +7,19 @@
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest"
|
||||
"test": "vitest",
|
||||
"gen:icons": "node scripts/gen-icons.mjs",
|
||||
"compress:images": "node scripts/compress-images.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"sharp": "^0.34.5",
|
||||
"typescript": "~5.7.2",
|
||||
"vite": "^6.0.5",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vitest": "^2.1.8",
|
||||
"vue-tsc": "^2.2.0"
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 336 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 558 KiB |
|
Before Width: | Height: | Size: 648 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 697 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 784 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 716 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 721 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 712 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 638 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 701 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 586 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 676 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 734 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 663 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 728 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 628 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 726 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 700 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 746 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 280 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 717 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 708 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 722 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 675 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 741 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 731 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 714 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 639 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 618 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 689 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 644 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 663 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 657 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 692 KiB After Width: | Height: | Size: 93 KiB |
@@ -0,0 +1,60 @@
|
||||
// 把 public/werewolf 下的牌图压到每张 <= 200KB(原地覆盖)。
|
||||
// 策略:最长边限 900px,mozjpeg 质量从高到低递减,直到达标。
|
||||
// 已经 <= 目标的文件跳过,避免重复编码反复掉质量。
|
||||
// 运行: npm run compress:images
|
||||
import sharp from 'sharp'
|
||||
import { readdir, stat, writeFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, resolve, join } from 'node:path'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const root = resolve(here, '../public/werewolf')
|
||||
|
||||
const TARGET = 200 * 1024
|
||||
const MAX_EDGE = 900
|
||||
const QUALITIES = [82, 76, 70, 64, 58, 52, 46]
|
||||
|
||||
async function listJpgs(dir) {
|
||||
const out = []
|
||||
for (const name of await readdir(dir, { withFileTypes: true })) {
|
||||
const p = join(dir, name.name)
|
||||
if (name.isDirectory()) out.push(...(await listJpgs(p)))
|
||||
else if (/\.jpe?g$/i.test(name.name)) out.push(p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function compress(file) {
|
||||
const before = (await stat(file)).size
|
||||
if (before <= TARGET) return { file, skipped: true, before }
|
||||
let chosen = null
|
||||
for (const quality of QUALITIES) {
|
||||
const buf = await sharp(file)
|
||||
.rotate() // 按 EXIF 摆正
|
||||
.resize({ width: MAX_EDGE, height: MAX_EDGE, fit: 'inside', withoutEnlargement: true })
|
||||
.jpeg({ quality, mozjpeg: true })
|
||||
.toBuffer()
|
||||
chosen = { buf, quality }
|
||||
if (buf.length <= TARGET) break
|
||||
}
|
||||
await writeFile(file, chosen.buf)
|
||||
return { file, before, after: chosen.buf.length, quality: chosen.quality }
|
||||
}
|
||||
|
||||
const files = await listJpgs(root)
|
||||
let totalBefore = 0
|
||||
let totalAfter = 0
|
||||
for (const f of files) {
|
||||
const r = await compress(f)
|
||||
const rel = f.slice(root.length + 1)
|
||||
if (r.skipped) {
|
||||
totalBefore += r.before
|
||||
totalAfter += r.before
|
||||
console.log(`skip ${rel} (${(r.before / 1024) | 0}K)`)
|
||||
} else {
|
||||
totalBefore += r.before
|
||||
totalAfter += r.after
|
||||
console.log(`ok ${rel} ${(r.before / 1024) | 0}K -> ${(r.after / 1024) | 0}K q${r.quality}`)
|
||||
}
|
||||
}
|
||||
console.log(`\n${files.length} files: ${(totalBefore / 1024 / 1024).toFixed(1)}MB -> ${(totalAfter / 1024 / 1024).toFixed(1)}MB`)
|
||||
@@ -0,0 +1,46 @@
|
||||
// 从 public/werewolf/back.jpg 生成 PWA 图标。
|
||||
// 运行: npm run gen:icons
|
||||
import sharp from 'sharp'
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const pub = resolve(here, '../public')
|
||||
const src = resolve(pub, 'werewolf/back.jpg')
|
||||
|
||||
// 卡背是竖图,狼头菱形大致在水平居中、垂直 ~46% 处。
|
||||
// 裁一个聚焦狼头 logo 的方形区域作为图标基底(按实际尺寸夹紧,避免越界)。
|
||||
const meta = await sharp(src).metadata()
|
||||
const SIDE = Math.min(meta.width, Math.round(meta.width * 0.82), meta.height)
|
||||
const left = Math.round(Math.max(0, Math.min(meta.width - SIDE, meta.width / 2 - SIDE / 2)))
|
||||
const top = Math.round(Math.max(0, Math.min(meta.height - SIDE, meta.height * 0.46 - SIDE / 2)))
|
||||
const square = await sharp(src)
|
||||
.extract({ left, top, width: SIDE, height: SIDE })
|
||||
.toBuffer()
|
||||
|
||||
const RED = { r: 0xc0, g: 0x39, b: 0x2b, alpha: 1 } // 贴近卡面红,用于 maskable 安全区留白
|
||||
|
||||
async function out(name, size, { maskable = false } = {}) {
|
||||
await mkdir(pub, { recursive: true })
|
||||
const target = resolve(pub, name)
|
||||
if (maskable) {
|
||||
// maskable: 内容缩到 ~80%,四周用卡红留白,保证安全区不被裁切
|
||||
const inner = Math.round(size * 0.8)
|
||||
const fg = await sharp(square).resize(inner, inner).toBuffer()
|
||||
await sharp({ create: { width: size, height: size, channels: 4, background: RED } })
|
||||
.composite([{ input: fg, gravity: 'center' }])
|
||||
.png()
|
||||
.toFile(target)
|
||||
} else {
|
||||
await sharp(square).resize(size, size).png().toFile(target)
|
||||
}
|
||||
console.log('wrote', name)
|
||||
}
|
||||
|
||||
await out('pwa-192x192.png', 192)
|
||||
await out('pwa-512x512.png', 512)
|
||||
await out('maskable-icon-512x512.png', 512, { maskable: true })
|
||||
await out('apple-touch-icon-180x180.png', 180)
|
||||
await out('favicon-48x48.png', 48)
|
||||
console.log('done')
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
import { setupPWA } from './pwa'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
setupPWA()
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// PWA 客户端:注册 Service Worker,并在首次预缓存(或版本更新)时
|
||||
// 显示一个全屏 modal 进度条,反映离线资源的真实下载进度。
|
||||
|
||||
type SWMessage =
|
||||
| { type: 'precache-start'; total: number }
|
||||
| { type: 'precache-progress'; loaded: number; total: number }
|
||||
| { type: 'precache-done'; total: number }
|
||||
|
||||
let overlay: HTMLDivElement | null = null
|
||||
let barFill: HTMLDivElement | null = null
|
||||
let label: HTMLDivElement | null = null
|
||||
|
||||
function injectStyle(): void {
|
||||
if (document.getElementById('pwa-precache-style')) return
|
||||
const style = document.createElement('style')
|
||||
style.id = 'pwa-precache-style'
|
||||
style.textContent = `
|
||||
.pwa-precache {
|
||||
position: fixed; inset: 0; z-index: 9999;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 22px; padding: 32px;
|
||||
background: var(--bg, #1a1a2e);
|
||||
color: var(--fg, rgba(255,255,255,0.92));
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif;
|
||||
transition: opacity .4s ease;
|
||||
}
|
||||
.pwa-precache.hide { opacity: 0; pointer-events: none; }
|
||||
.pwa-precache__icon { width: 96px; height: 96px; border-radius: 22px; box-shadow: 0 8px 30px rgba(0,0,0,.4); }
|
||||
.pwa-precache__title { font-size: 17px; font-weight: 600; }
|
||||
.pwa-precache__sub { font-size: 13px; color: var(--fg-dim, rgba(255,255,255,0.6)); margin-top: -10px; }
|
||||
.pwa-precache__track {
|
||||
width: min(78vw, 320px); height: 8px; border-radius: 999px;
|
||||
background: var(--bg-soft, rgba(255,255,255,0.06));
|
||||
overflow: hidden;
|
||||
}
|
||||
.pwa-precache__fill {
|
||||
height: 100%; width: 0%;
|
||||
background: linear-gradient(90deg, var(--accent, #7c3aed), var(--accent-2, #06b6d4));
|
||||
border-radius: 999px; transition: width .25s ease;
|
||||
}
|
||||
.pwa-precache__pct { font-size: 13px; color: var(--fg-dim, rgba(255,255,255,0.6)); }
|
||||
`
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
|
||||
function show(): void {
|
||||
if (overlay) return
|
||||
injectStyle()
|
||||
overlay = document.createElement('div')
|
||||
overlay.className = 'pwa-precache'
|
||||
overlay.innerHTML = `
|
||||
<img class="pwa-precache__icon" src="/pwa-192x192.png" alt="" />
|
||||
<div class="pwa-precache__title">正在缓存离线资源…</div>
|
||||
<div class="pwa-precache__sub">完成后断网也能发牌</div>
|
||||
<div class="pwa-precache__track"><div class="pwa-precache__fill"></div></div>
|
||||
<div class="pwa-precache__pct">0%</div>
|
||||
`
|
||||
document.body.appendChild(overlay)
|
||||
barFill = overlay.querySelector('.pwa-precache__fill')
|
||||
label = overlay.querySelector('.pwa-precache__pct')
|
||||
}
|
||||
|
||||
function update(loaded: number, total: number): void {
|
||||
if (!overlay) show()
|
||||
const pct = total > 0 ? Math.round((loaded / total) * 100) : 0
|
||||
if (barFill) barFill.style.width = `${pct}%`
|
||||
if (label) label.textContent = `${pct}% · ${loaded}/${total}`
|
||||
}
|
||||
|
||||
function done(): void {
|
||||
if (!overlay) return
|
||||
if (barFill) barFill.style.width = '100%'
|
||||
overlay.classList.add('hide')
|
||||
const el = overlay
|
||||
overlay = null
|
||||
barFill = null
|
||||
label = null
|
||||
setTimeout(() => el.remove(), 450)
|
||||
}
|
||||
|
||||
export function setupPWA(): void {
|
||||
if (!('serviceWorker' in navigator)) return
|
||||
|
||||
navigator.serviceWorker.addEventListener('message', (event) => {
|
||||
const msg = event.data as SWMessage
|
||||
if (!msg || typeof msg !== 'object') return
|
||||
if (msg.type === 'precache-start') show()
|
||||
else if (msg.type === 'precache-progress') update(msg.loaded, msg.total)
|
||||
else if (msg.type === 'precache-done') done()
|
||||
})
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch((err) => {
|
||||
console.error('[pwa] SW 注册失败', err)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/// <reference lib="webworker" />
|
||||
// 自定义 Service Worker(injectManifest 模式)。
|
||||
// install 时逐个抓取预缓存清单中的资源,并向所有页面广播进度,
|
||||
// 用于驱动首屏的全屏加载进度条;之后断网也能完整发牌。
|
||||
export {}
|
||||
|
||||
declare const self: ServiceWorkerGlobalScope & typeof globalThis
|
||||
|
||||
interface PrecacheEntry {
|
||||
url: string
|
||||
revision: string | null
|
||||
}
|
||||
|
||||
// injectManifest 会把构建产物清单注入到这里
|
||||
const MANIFEST = self.__WB_MANIFEST as PrecacheEntry[]
|
||||
|
||||
// 由清单内容派生缓存版本号:任一资源变化版本即变,旧缓存自动淘汰
|
||||
function hashStr(s: string): string {
|
||||
let h = 5381
|
||||
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0
|
||||
return h.toString(36)
|
||||
}
|
||||
const VERSION = hashStr(MANIFEST.map((e) => `${e.url}@${e.revision ?? ''}`).join('|'))
|
||||
const CACHE = `werewolf-precache-${VERSION}`
|
||||
|
||||
const INDEX = new URL('index.html', self.location.href).href
|
||||
const URLS = Array.from(new Set([INDEX, ...MANIFEST.map((e) => new URL(e.url, self.location.href).href)]))
|
||||
|
||||
async function broadcast(message: unknown): Promise<void> {
|
||||
const clients = await self.clients.matchAll({ includeUncontrolled: true, type: 'window' })
|
||||
for (const client of clients) client.postMessage(message)
|
||||
}
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const cache = await caches.open(CACHE)
|
||||
const total = URLS.length
|
||||
let loaded = 0
|
||||
await broadcast({ type: 'precache-start', total })
|
||||
|
||||
const queue = [...URLS]
|
||||
const CONCURRENCY = 6
|
||||
const worker = async () => {
|
||||
for (let url = queue.shift(); url !== undefined; url = queue.shift()) {
|
||||
try {
|
||||
const res = await fetch(url, { cache: 'no-cache' })
|
||||
if (res.ok) await cache.put(url, res.clone())
|
||||
} catch {
|
||||
// 单个资源失败不阻塞安装,下次启动或运行时再补
|
||||
}
|
||||
loaded++
|
||||
await broadcast({ type: 'precache-progress', loaded, total })
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: CONCURRENCY }, worker))
|
||||
|
||||
await broadcast({ type: 'precache-done', total })
|
||||
await self.skipWaiting()
|
||||
})(),
|
||||
)
|
||||
})
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const keys = await caches.keys()
|
||||
await Promise.all(
|
||||
keys.filter((k) => k.startsWith('werewolf-precache-') && k !== CACHE).map((k) => caches.delete(k)),
|
||||
)
|
||||
await self.clients.claim()
|
||||
})(),
|
||||
)
|
||||
})
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const req = event.request
|
||||
if (req.method !== 'GET') return
|
||||
const url = new URL(req.url)
|
||||
if (url.origin !== self.location.origin) return
|
||||
|
||||
// 导航请求:优先网络(便于上线后拿到新版),断网回退缓存的 index.html
|
||||
if (req.mode === 'navigate') {
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
try {
|
||||
return await fetch(req)
|
||||
} catch {
|
||||
const cache = await caches.open(CACHE)
|
||||
return (await cache.match(INDEX)) ?? Response.error()
|
||||
}
|
||||
})(),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// 其它同源资源:cache-first,未命中再走网络并回填
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
const cache = await caches.open(CACHE)
|
||||
const hit = await cache.match(req.url)
|
||||
if (hit) return hit
|
||||
try {
|
||||
const res = await fetch(req)
|
||||
if (res.ok && res.type !== 'opaque') cache.put(req.url, res.clone())
|
||||
return res
|
||||
} catch {
|
||||
return hit ?? Response.error()
|
||||
}
|
||||
})(),
|
||||
)
|
||||
})
|
||||
@@ -18,5 +18,6 @@
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"types": ["vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue", "vite-env.d.ts"]
|
||||
"include": ["src/**/*.ts", "src/**/*.vue", "vite-env.d.ts"],
|
||||
"exclude": ["src/sw.ts"]
|
||||
}
|
||||
|
||||
@@ -1,8 +1,46 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
plugins: [
|
||||
vue(),
|
||||
VitePWA({
|
||||
// 自定义 SW:install 时逐个抓取资源并向页面广播进度(驱动首屏进度条)
|
||||
strategies: 'injectManifest',
|
||||
srcDir: 'src',
|
||||
filename: 'sw.ts',
|
||||
injectRegister: false, // 注册由 src/pwa.ts 手动处理,避免插件的自动重载
|
||||
includeAssets: ['favicon-48x48.png', 'apple-touch-icon-180x180.png'],
|
||||
manifest: {
|
||||
name: '狼人杀发牌器',
|
||||
short_name: '狼人杀',
|
||||
description: '离线可用的狼人杀发牌器',
|
||||
lang: 'zh-CN',
|
||||
theme_color: '#1a1a2e',
|
||||
background_color: '#1a1a2e',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait',
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
icons: [
|
||||
{ src: 'pwa-192x192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png' },
|
||||
{
|
||||
src: 'maskable-icon-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'maskable',
|
||||
},
|
||||
],
|
||||
},
|
||||
injectManifest: {
|
||||
// 预缓存应用外壳 + 全部牌图,保证彻底断网也能发牌
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,jpg,JPG,webmanifest}'],
|
||||
maximumFileSizeToCacheInBytes: 1024 * 1024,
|
||||
},
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
target: 'es2020',
|
||||
},
|
||||
|
||||