Compare commits

...

44 Commits

Author SHA1 Message Date
Fam Zheng 0b87085ca9 music(pwa): 自家 icon — 黑底圆角 + 金黄八分音符
deploy music / build-and-deploy (push) Successful in 2m3s
2026-05-27 17:37:24 +01:00
Fam Zheng e99a032852 music(player): '仅看收藏' chip 跟 tag 排一起,不再推到最右
deploy music / build-and-deploy (push) Successful in 2m8s
2026-05-26 12:02:50 +01:00
Fam Zheng ae9c08aa35 music(player): '仅看收藏' 切换移到 filterbar(标签那行)
deploy music / build-and-deploy (push) Successful in 2m7s
2026-05-26 10:37:40 +01:00
Fam Zheng 089de84396 music(player): 侧边栏 ★ 标记 + sort-bar '仅看收藏' 切换
deploy music / build-and-deploy (push) Successful in 1m59s
2026-05-26 10:24:00 +01:00
Fam Zheng 83418c198f music(player): 收藏功能 — title 旁 ★/☆,收藏的曲目置顶
deploy music / build-and-deploy (push) Successful in 1m58s
2026-05-26 10:04:21 +01:00
Fam Zheng 0756362d14 music(player): sidebar 双击切歌并播放(单击只切换不打扰)
deploy music / build-and-deploy (push) Successful in 2m10s
2026-05-26 09:58:22 +01:00
Fam Zheng adbd259a32 music(perf): 切歌延迟修 — getAudioUrl 同步短路 + SW install 并发
deploy music / build-and-deploy (push) Successful in 2m4s
诊断:之前 loadPiece 链上加了 `audio.src = await getAudioUrl(...)`,await IDB
即使 cache disabled 也排队个 microtask;叠加 SW install 串行 23 个 fetch
让首次部署后明显卡。

修法:
- getAudioUrl 改同步:内存 blob 命中 / cache 关 → 立返;启用 cache 时内存没
  → 仍返网络 URL,后台 warm IDB 下次用
- audio.src = getAudioUrl(id) 不再 await,零等待
- SW install 改 cache.addAll 并发(HTTP/2 多路),失败回退串行
2026-05-26 09:37:24 +01:00
Fam Zheng 8991033f70 music(pwa): PWA + 可选离线缓存全库(IndexedDB),默认关
deploy music / build-and-deploy (push) Successful in 2m1s
- vite-plugin-pwa injectManifest 模式,自定义 sw.js precache app shell
- manifest 支持加桌面 + standalone(icon 暂借 werewolf 紫色调,后续换)
- src/lib/cache.js IDB 缓存层:audio + 谱面 PNG 单 attachment id 存放,blob URL 复用
- 启动 initCache 按 localStorage 'music.cache.enabled' 决定是否后台开始下载
- 后台 worker:串行 concurrency=2 + 80ms 间隔,仅 WiFi 时跑(默认)
- audio src 优先走 IDB blob URL,没缓存才走网络
- /settings 配置页:开关 + 仅 WiFi 切换 + 进度条 + 用量/quota + 清空缓存
- topbar 加 ⚙ 按钮

默认关,首次明确 prompt-by-checkbox 才开。整库 ~1.5GB。
2026-05-25 22:09:54 +01:00
Fam Zheng bcf99ec454 werewolf(pwa): 离线 PWA — 自定义 SW 预缓存 + 全屏进度条,牌图 21M→2.8M
deploy werewolf / build-and-deploy (push) Successful in 1m1s
- vite-plugin-pwa(injectManifest)自定义 SW:install 逐个抓取并向页面广播进度,
  cache-first 服务,导航离线回退 index.html,缓存版本随清单哈希自动淘汰旧缓存
- 全屏 modal 进度条(src/pwa.ts),反映首屏预缓存真实下载进度
- 牌图 mozjpeg 压缩 + 限长边 900px,每张 ≤200K(21.2MB→2.8MB)
- 生成 PWA 图标 + manifest + apple-touch meta,index.html 接入
- 新增脚本:npm run gen:icons / compress:images
2026-05-25 18:44:46 +01:00
Fam Zheng 1a62ec6658 music(player): 切歌不打扰 — 暂停状态切别的不自动开播 + tab 保持
deploy music / build-and-deploy (push) Successful in 2m0s
- loadPiece 进来先 snapshot wasPlaying / stickyTab
- 新 piece 也有同样 tab 就保持(chord/notes/简谱…),否则才回第一个
- 只有切歌前 audio 正在播才 .play();暂停 / 第一次进入 → 只 set src 等用户点 ▶
- 整理 notes / 看和弦谱的场景从此不会被切歌打断
2026-05-25 10:39:49 +01:00
Fam Zheng 915b91d986 write(ui): 对话框填满 + 🎙/发送按钮浮在 textarea 框内右下
deploy write / build-and-deploy (push) Successful in 1m26s
- input-row 改 position: relative + flex:1 撑满 input-bar 高度
- textarea width/height 100% 填满,右/下加 padding (130px / 14px) 给按钮腾位
- btn-tray absolute right:8 bottom:8,pointer-events: none 让 textarea 仍可点
- 按钮 height 40→32 更紧凑
2026-05-24 21:20:28 +01:00
Fam Zheng b2bec0406f write(ui): 全套 dark 主题,配色对齐 notes / cube portal
deploy write / build-and-deploy (push) Successful in 1m28s
- 引入 :root --bg/--bg-elev/--bg-card/--border/--text/--accent 等 vars(跟 notes 一致)
- 主要 surface:body / sidebar / editor / preview / input-bar / modal / mobile-bar
- accent 紫 (#c084fc + #7c5cbf) 替代 #5566ee 蓝
- preview markdown:h2-h4 紫,inline code amber,pre block 加 border
- splitter hover 用 accent-strong 紫
- 滚动条暗化
2026-05-24 17:45:26 +01:00
Fam Zheng 85b55f2243 write(ui): input-bar 高度也可拖拽(横向 splitter,row-resize 光标,60-window-200 px)
deploy write / build-and-deploy (push) Successful in 1m28s
2026-05-24 17:44:04 +01:00
Fam Zheng 027921de0c write(css): splitter 放 sidebar 后而不是前;display:none 的 mobile-bar 不占 grid cell 之前算错了顺序
deploy write / build-and-deploy (push) Successful in 1m35s
2026-05-24 17:24:13 +01:00
Fam Zheng b2d70b2491 write(css): editor-pane 加 grid-template-rows: auto 1fr,title-row 不再占 50%
deploy write / build-and-deploy (push) Successful in 1m52s
2026-05-24 17:20:36 +01:00
Fam Zheng 7b868852d2 write(ui): 三栏宽度可拖拽 + localStorage 持久化
deploy articulate / build-and-deploy (push) Successful in 1m15s
deploy cube / build-and-deploy (push) Successful in 1m40s
deploy karaoke / build-and-deploy (push) Successful in 1m6s
deploy llm-proxy / build-and-deploy (push) Successful in 2m3s
deploy music / build-and-deploy (push) Successful in 2m16s
deploy notes / build-and-deploy (push) Successful in 2m2s
deploy simpleasm / build-and-deploy (push) Successful in 1m30s
deploy werewolf / build-and-deploy (push) Successful in 1m12s
deploy write / build-and-deploy (push) Successful in 1m54s
- sidebar | workspace 拖拽条:调整侧栏宽(180-500px)
- editor | preview 拖拽条:调整源码/预览比例(15%-85%)
- CSS var --sidebar-w / --editor-fr / --preview-fr 驱动 grid-template-columns
- 鼠标 down 开始 drag,move 实时算 px/dx,up 落盘 localStorage
- 移动端(<768px)自动隐藏拖拽条,回到 100% 切 tab 模式
2026-05-24 17:18:37 +01:00
Fam Zheng 9328c01c1b write: 进 cube 仓库 + 接 gitea CI 自动部署
deploy write / build-and-deploy (push) Failing after 4s
- 整 apps/write/ 进 git(含 frontend 源码 + Makefile + systemd unit + k8s service/ingress)
- .gitea/workflows/deploy-write.yml: act_runner fam 用户跑 host shell
    cargo build → npm build → install 到 ~/.local/bin/share/config →
    systemctl --user daemon-reload + restart → kubectl apply svc/ingress
- 前端 3 处"麻薯"字样去掉(思考中 / placeholder × 2)

注意 ~/.config/write/env 已有 passphrase,CI placeholder 逻辑会跳过不覆盖。
2026-05-24 17:16:44 +01:00
Fam Zheng f8a7f31427 notes(ui): 补 actions 按钮组 CSS(之前 commit 漏了 .action-btn 样式)
deploy notes / build-and-deploy (push) Successful in 1m49s
2026-05-18 01:51:51 +01:00
Fam Zheng 3f742352e2 notes(ui): 重跑/删除挪到标题右侧 actions 组,跟元数据分开排版
deploy notes / build-and-deploy (push) Successful in 2m4s
2026-05-18 01:51:12 +01:00
Fam Zheng 3e478228dd notes: done 状态也能 ↻ 重跑;有 transcript 自动跳过 ASR 只重跑 LLM
deploy notes / build-and-deploy (push) Successful in 1m40s
- 前端 retry 按钮去掉 status==failed 限制,总显示(中间态 disabled)
- backend process_recording 启动时看 transcript 有没有,有就直接 cleaning 起步,
  省 30 分钟录音那个 2-3 分钟的 ASR 切片串行
2026-05-18 01:44:11 +01:00
Fam Zheng e072109e91 notes: 加「 清理润色」block + 转写原文默认折叠
deploy notes / build-and-deploy (push) Successful in 1m47s
- backend: schema 加 cleaned 列;process_recording 流程
  ASR → cleaning (LLM 分段+去口语+润色+**加粗高亮**) → summarizing → done
- cleanup LLM 失败不阻塞,继续 summary
- 前端三 block 顺序:纪要 → 清理润色 → 原文(details 折叠)
- 新 status 'cleaning' 也加进 statusLabel / progressText
2026-05-18 01:22:33 +01:00
Fam Zheng ca11a9bda7 notes(asr): ffprobe duration=N/A 时回退用 ffmpeg null-muxer 解码统计
deploy notes / build-and-deploy (push) Successful in 1m58s
浏览器内 MediaRecorder 录的 webm/m4a 经常 metadata 没写 duration
(录到一半浏览器关掉 tab 没正常 finalize 文件)。ffprobe format.duration
返回 N/A。回退跑 `ffmpeg -i input -f null -`,从 stderr 最后一行
"time=HH:MM:SS.MS" parse 出实际秒数。慢一点但永远能拿到。
2026-05-18 00:40:23 +01:00
Fam Zheng a8e5100380 llm-proxy(ui): 修 placeholder token 泄漏 + UI 重做 + λ favicon
deploy llm-proxy / build-and-deploy (push) Successful in 1m46s
- 修:token 输入框 placeholder 之前硬编码了真实 token (`e.g.
  famzheng-llm-2026`),等于明文泄露。改成 `your auth token`
- UI 重做 — 100dvh 锁 viewport(处理移动软键盘)+ grid 布局
  让 thread 永远占中间、footer 永远贴底
- textarea autogrow(最高 200px,超出内部滚)
- 复制按钮 / smooth scroll-to-bottom (double rAF) /
  iOS momentum scroll / safe-area padding
- 错误状态独立 row,monospace + 红底
- λ favicon(紫蓝渐变 + 绿色在线点 + glow)— SVG include_str!
  进 binary,`/favicon.svg` + `/favicon.ico` 同源响应
2026-05-18 00:34:49 +01:00
Fam Zheng a5e97adf85 notes(ui): 加紫色渐变麦克风 favicon(含红色录音圆点)
deploy llm-proxy / build-and-deploy (push) Successful in 2m26s
deploy notes / build-and-deploy (push) Successful in 2m19s
2026-05-18 00:33:03 +01:00
Fam Zheng bcc8c3f484 notes: 启动时 resume 卡在 transcribing/summarizing/pending 的录音
deploy notes / build-and-deploy (push) Successful in 3m9s
pod 重启时 spawn 的 worker 进程内存丢,db 状态停留 → 死循环看不到进度。
启动加一段:扫 status IN (pending,transcribing,summarizing),重置成 pending,
逐个 spawn process_recording 重跑(ASR + LLM idempotent)。
2026-05-18 00:30:37 +01:00
Fam Zheng 1859512976 notes(ui): 选中录音同步到 URL ?id=N(可刷新/分享/前进后退)
deploy notes / build-and-deploy (push) Successful in 3m13s
2026-05-18 00:29:55 +01:00
Fam Zheng 857c0d5481 llm-proxy(app): gemma 反向代理 + token 鉴权 + /chat web UI
deploy articulate / build-and-deploy (push) Successful in 1m29s
deploy cube / build-and-deploy (push) Successful in 1m49s
deploy karaoke / build-and-deploy (push) Successful in 1m18s
deploy llm-proxy / build-and-deploy (push) Successful in 2m41s
deploy music / build-and-deploy (push) Successful in 3m6s
deploy notes / build-and-deploy (push) Successful in 2m40s
deploy simpleasm / build-and-deploy (push) Successful in 2m5s
deploy werewolf / build-and-deploy (push) Successful in 1m41s
新 service,ns `llm-proxy`,域 `llm.famzheng.me`。
- POST /v1/chat/completions — OpenAI 兼容透传到 mochi 同款 backend
  gateway (gemma-4-31b-it);一期强制 stream=false,SSE 留二期
- 鉴权: `Authorization: token <PROXY_AUTH_TOKEN>` 或同款 Bearer;
  常时间比较防 timing;空 expected 一律拒
- GET /chat — 自带极简 HTML chat UI(token 走 localStorage,
  附 curl example details);/ 跳转到 /chat
- Secrets `llm-proxy/proxy-credentials` 已 kubectl 手工创建:
  BACKEND_TOKEN (上游) + PROXY_AUTH_TOKEN (对外)
- 13 个 cargo test 覆盖 auth 多个 scheme / 边界 + body
  改写 (stream=false 强制注入)
2026-05-18 00:21:47 +01:00
Fam Zheng 34fa47f95f notes: 加重命名 — title 旁边 ✏️ 按钮 prompt 改名 (PATCH /api/recordings/:id)
deploy notes / build-and-deploy (push) Successful in 1m53s
2026-05-17 23:28:57 +01:00
Fam Zheng 674011ddf3 notes(feishu): 完整带 ~/.local/share/lark-cli/ 加密 token 进 sidecar
deploy notes / build-and-deploy (push) Successful in 2m4s
之前只 cp config.json (365B 索引),user OAuth token 实际加密存在
~/.local/share/lark-cli/{master.key, appsecret_*.enc, cli_*_*.enc}。
secret 改成捎带全部 4 个文件;initContainer cp 到 PVC 两个子目录;
sidecar mount /root/.lark-cli + /root/.local/share/lark-cli 两路。

server.py 撤回 --as user(带上 token 后能调 docs:document:create scope)。
2026-05-17 23:19:21 +01:00
Fam Zheng e7912f3547 notes(asr): LLM 顺手出会议标题,覆盖默认时间戳 title
deploy notes / build-and-deploy (push) Successful in 1m50s
prompt 加要求:第一行 `TITLE: <主题>` + `---` + 正文;backend parse
头两行,覆盖 recordings.title;summary 字段不含 title 行。
失败 fallback 不动 title。前端 sidebar/主视图自带 5s 轮询自动刷新。
2026-05-17 23:01:28 +01:00
Fam Zheng d964b46dbe cube(chat): apps.json 成 SSOT,注进 chatbot system prompt
deploy cube / build-and-deploy (push) Successful in 1m22s
前端 apps.ts 之前是 source of truth,后端 chatbot 只能用硬编码的
"werewolf / articulate / karaoke / music / simpleasm 等" 句式糊
弄 + 靠训练知识猜。改成 apps.json 当 SSOT:
- 前端 apps.ts 改为 import data from './apps.json'
- 后端 include_str! 同一份 → 解析渲染 markdown bullet 列表
  注进 system prompt,附带 slug / status / desc / url
- prompt 显式约束:只能基于列表事实回答,不存在的 app 直说没有
- 兜底:JSON 解析失败把 raw 文本喂 LLM,不让 chatbot 因为
  ssot 坏掉 500
- 10 个 cargo test(多覆盖 render_apps_list / prompt 含 apps)
2026-05-17 22:56:31 +01:00
Fam Zheng 1ee35b4d19 notes(asr): overlap 切片 + LLM 拼接去重
deploy notes / build-and-deploy (push) Successful in 2m7s
- ffmpeg 用 -ss/-t 顺序切 65s 段,stride 55s(10s overlap);单段 ≤70s 整段不切
- 串行喂外部 ASR 后,把全部 chunk_texts 喂一次 LLM 让它去重 + 修边界字
- 单段直接返回 naive,LLM 失败也 fallback naive,不卡流程
- sidecar 注入 LLM_GATEWAY/LLM_MODEL/LLM_TOKEN env
2026-05-17 22:47:06 +01:00
Fam Zheng 688ccdc76f notes(asr): 切片串行 ASR 绕单文件大小限制
deploy notes / build-and-deploy (push) Successful in 3m40s
ASR server 直接 500 拒绝大文件 (15MB / ~15min 4.7s 即返回 500),不是
处理超时。改成:sidecar 装 ffmpeg → /transcribe endpoint 把音频切 60s
段 → 串行调外部 ASR → 拼接 transcript。notes 主容器 call_asr 改成 POST
到 sidecar /transcribe(timeout 1h 给长录音留余地)。

- feishu sidecar Dockerfile + ffmpeg + requests
- server.py 加 TranscribeReq;fallback -c copy 失败时 re-encode AAC
- main.rs 删除 asr_url/asr_token 字段(now sidecar concern)
- k8s manifest: ASR_URL/ASR_TOKEN 从主容器移到 feishu sidecar env
2026-05-17 22:38:05 +01:00
Fam Zheng e5a87cc65f notes(feishu): lark-cli config 从 secret cp 到 PVC 子目录,可读可写 + 重启保留
deploy notes / build-and-deploy (push) Successful in 1m50s
initContainer cp /secrets/lark-cli/config.json → /data/lark-cli/config.json
(已存在不覆盖,保留运行时 refresh 过的 token);feishu sidecar 主容器
subPath mount data PVC 的 lark-cli/ 到 /root/.lark-cli,lark-cli 写 cache、
refresh 都落 PVC。
2026-05-17 22:28:19 +01:00
Fam Zheng e56e2138a8 notes(feishu): Dockerfile 加 curl(lark-cli npm postinstall 依赖)
deploy notes / build-and-deploy (push) Successful in 2m40s
2026-05-17 22:23:57 +01:00
Fam Zheng 68671784f6 notes: 加一键转飞书文档 (sidecar markdown-to-feishu)
deploy notes / build-and-deploy (push) Failing after 2m2s
- backend: POST /api/recordings/:id/feishu → 拼 markdown (总结在最上 + 附件链接到转录/录音 + 转写全文) → 写 /data/feishu-tmp/<id>/ → HTTP POST 到 feishu sidecar
- 复用:已有 feishu_doc_id 时 --update 同一个 doc,前端按钮文案变「↻ 重新生成」
- schema 加 feishu_doc_id + feishu_url 两列(ALTER TABLE 兼容旧 db)
- LLM prompt 改:行动项用 markdown checkbox `- [ ] 谁·做什么·何时`
- sidecar apps/notes/feishu: node:20 + python3 + python3-markdown + @larksuite/cli + COPY 自己的 markdown-to-feishu script + FastAPI /convert
- k8s: deployment 加 feishu container 共享 PVC;lark-cli-creds Secret 挂 /root/.lark-cli/config.json
- CI: 主 image --no-cache(cube 规矩),sidecar 保留 layer cache(chromium-free,但 apt/npm 也大)
- 前端: content 头部加「📤 一键转飞书文档」按钮;已转过显示飞书链接 + 按钮变重生成
2026-05-17 22:16:13 +01:00
Fam Zheng 3a34fbdfd8 notes(ui): polling 静默 refresh + 增量更新 list/selected,不再闪动
deploy notes / build-and-deploy (push) Successful in 1m50s
2026-05-17 22:08:42 +01:00
Fam Zheng eb7cd81395 notes: 回滚讲话人猜测 prompt,保持简单纪要格式
deploy notes / build-and-deploy (push) Successful in 1m58s
2026-05-17 22:08:08 +01:00
Fam Zheng 93039457a7 notes: title 全空时用「录音 YYYY-MM-DD HH:MM」;LLM 加猜讲话人 prompt
deploy notes / build-and-deploy (push) Successful in 1m43s
2026-05-17 22:03:20 +01:00
Fam Zheng 44652eb398 notes(record): 加浏览器内直接录音(绕 iOS 录音机 App 文件不可见)
deploy notes / build-and-deploy (push) Successful in 2m11s
- 「🎙️ 直接录」按钮:navigator.mediaDevices.getUserMedia({audio:true}) → MediaRecorder
- 录音中按钮变红 + 计时器 + 脉冲;点 ⏹ 停止自动上传
- mimeType 探测:Safari 用 audio/mp4,Chrome 优先 audio/webm/opus
- 文件名 录音-YYYY-MM-DD-HH-MM-SS.{m4a|webm}
- 原 + 文件 入口保留小型,作为电脑端兜底
2026-05-17 21:55:39 +01:00
Fam Zheng c2c0c6999d notes(ui): empty 提示箭头反过来
deploy notes / build-and-deploy (push) Successful in 2m19s
2026-05-17 21:53:39 +01:00
Fam Zheng 61abd3f560 notes: 新建 notes.famzheng.me — 录音 → ASR → LLM 会议纪要
deploy articulate / build-and-deploy (push) Successful in 1m21s
deploy cube / build-and-deploy (push) Successful in 1m44s
deploy karaoke / build-and-deploy (push) Successful in 1m13s
deploy music / build-and-deploy (push) Successful in 2m23s
deploy notes / build-and-deploy (push) Successful in 2m16s
deploy simpleasm / build-and-deploy (push) Successful in 1m44s
deploy werewolf / build-and-deploy (push) Successful in 1m7s
- 后端 axum + sqlite (recordings 表):上传 multipart 流式落 PVC;spawn worker pending → transcribing (调 mochi 那边 ASR endpoint, fireredasr2 token, Whisper-style multipart) → summarizing (调 gemma-4-31b-it OpenAI 兼容接口) → done
- 鉴权 middleware:Authorization: token <PASSPHRASE>;audio 流播放 ?token= query 兜底;passphrase 走 k8s Secret 不写死
- 前端 Vue3:首次访问弹 passphrase modal;sidebar 录音列表(带状态 chip)+ content 选中显示音频 + 转写 + markdown 纪要;5s polling 进度
- k8s manifest: ns cube-notes / PVC 30Gi / Ingress notes.famzheng.me / bodylimit 600M;Secret notes-creds = {passphrase, asr_token, llm_token}
- portal apps.ts 加 notes entry
2026-05-17 21:43:44 +01:00
Fam Zheng 802d5beae9 cube(portal): 加 chatbot + create_issue tool
deploy articulate / build-and-deploy (push) Successful in 1m27s
deploy cube / build-and-deploy (push) Successful in 1m52s
deploy karaoke / build-and-deploy (push) Successful in 1m20s
deploy music / build-and-deploy (push) Successful in 2m29s
deploy simpleasm / build-and-deploy (push) Successful in 1m38s
deploy werewolf / build-and-deploy (push) Successful in 58s
入口页右下角浮动 chat — 走 mochi 同款 LLM gateway (gemma-4-31b-it),
单步 tool calling 实现 `create_issue` 调 gitea API 建 fam/cube issue。
LLM_API_TOKEN + GITEA_TOKEN 走 ns 内 secret `chat-credentials`
(kubectl 手工创建,不进 git);gateway URL / model / 仓库地址走 env。
8 个 cargo test 覆盖 prompt / tool schema / tool_call 解析 / 错误。
顺手 git rm --cached 之前漏掉的 tsbuildinfo(已 gitignore)。
2026-05-14 16:46:59 +01:00
Fam Zheng af697ea6d0 cube(portal): werewolf / articulate / karaoke 改 live
deploy cube / build-and-deploy (push) Successful in 58s
2026-05-14 16:11:11 +01:00
119 changed files with 22049 additions and 131 deletions
+52
View File
@@ -0,0 +1,52 @@
name: deploy llm-proxy
# llm.famzheng.me — gemma 反向代理。host shell runnerfam 用户)。
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
+63
View File
@@ -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-cachecube 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-freelayer 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
+63
View File
@@ -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 dirfam 已 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"
Generated
+232 -7
View File
@@ -56,6 +56,7 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum-core", "axum-core",
"base64",
"bytes", "bytes",
"futures-util", "futures-util",
"http", "http",
@@ -75,8 +76,10 @@ dependencies = [
"serde_json", "serde_json",
"serde_path_to_error", "serde_path_to_error",
"serde_urlencoded", "serde_urlencoded",
"sha1",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-tungstenite",
"tower", "tower",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
@@ -116,12 +119,27 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" 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]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.20.2" version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.11.1" version = "1.11.1"
@@ -150,12 +168,36 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" 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]] [[package]]
name = "cube" name = "cube"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"axum",
"cube-core", "cube-core",
"reqwest",
"serde",
"serde_json",
"tokio", "tokio",
"tracing",
] ]
[[package]] [[package]]
@@ -169,6 +211,22 @@ dependencies = [
"tracing-subscriber", "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]] [[package]]
name = "displaydoc" name = "displaydoc"
version = "0.2.5" version = "0.2.5"
@@ -314,6 +372,16 @@ dependencies = [
"slab", "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]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.2.17" version = "0.2.17"
@@ -644,6 +712,20 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "llm-proxy"
version = "0.1.0"
dependencies = [
"axum",
"cube-core",
"reqwest",
"serde",
"serde_json",
"tokio",
"tower-http",
"tracing",
]
[[package]] [[package]]
name = "lock_api" name = "lock_api"
version = "0.4.14" version = "0.4.14"
@@ -748,6 +830,24 @@ dependencies = [
"tracing", "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]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.50.3" version = "0.50.3"
@@ -845,7 +945,7 @@ dependencies = [
"rustc-hash", "rustc-hash",
"rustls", "rustls",
"socket2", "socket2",
"thiserror", "thiserror 2.0.18",
"tokio", "tokio",
"tracing", "tracing",
"web-time", "web-time",
@@ -860,13 +960,13 @@ dependencies = [
"bytes", "bytes",
"getrandom 0.3.4", "getrandom 0.3.4",
"lru-slab", "lru-slab",
"rand", "rand 0.9.4",
"ring", "ring",
"rustc-hash", "rustc-hash",
"rustls", "rustls",
"rustls-pki-types", "rustls-pki-types",
"slab", "slab",
"thiserror", "thiserror 2.0.18",
"tinyvec", "tinyvec",
"tracing", "tracing",
"web-time", "web-time",
@@ -901,14 +1001,35 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" 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]] [[package]]
name = "rand" name = "rand"
version = "0.9.4" version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [ dependencies = [
"rand_chacha", "rand_chacha 0.9.0",
"rand_core", "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]] [[package]]
@@ -918,7 +1039,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [ dependencies = [
"ppv-lite86", "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]] [[package]]
@@ -974,6 +1104,7 @@ dependencies = [
"hyper-util", "hyper-util",
"js-sys", "js-sys",
"log", "log",
"mime_guess",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"quinn", "quinn",
@@ -1150,6 +1281,17 @@ dependencies = [
"serde", "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]] [[package]]
name = "sharded-slab" name = "sharded-slab"
version = "0.1.7" version = "0.1.7"
@@ -1259,13 +1401,33 @@ dependencies = [
"syn", "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]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.18" version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [ 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]] [[package]]
@@ -1362,6 +1524,18 @@ dependencies = [
"tokio", "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]] [[package]]
name = "tokio-util" name = "tokio-util"
version = "0.7.18" version = "0.7.18"
@@ -1512,6 +1686,30 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 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]] [[package]]
name = "unicase" name = "unicase"
version = "2.9.0" version = "2.9.0"
@@ -1542,6 +1740,12 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]] [[package]]
name = "utf8_iter" name = "utf8_iter"
version = "1.0.4" version = "1.0.4"
@@ -1863,6 +2067,27 @@ version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" 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]] [[package]]
name = "writeable" name = "writeable"
version = "0.6.3" version = "0.6.3"
+4 -1
View File
@@ -8,6 +8,9 @@ members = [
"apps/werewolf", "apps/werewolf",
"apps/articulate", "apps/articulate",
"apps/karaoke", "apps/karaoke",
"apps/notes",
"apps/llm-proxy",
"apps/write",
] ]
[workspace.package] [workspace.package]
@@ -25,7 +28,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
rusqlite = { version = "0.32", features = ["bundled"] } 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" futures = "0.3"
tokio-stream = "0.1" tokio-stream = "0.1"
+5
View File
@@ -8,4 +8,9 @@ description = "cube.famzheng.me — cube 平台入口门户(app #0"
[dependencies] [dependencies]
cube-core = { path = "../../crates/cube-core" } cube-core = { path = "../../crates/cube-core" }
axum = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
tracing = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
reqwest = { workspace = true }
+3
View File
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import AppCard from './components/AppCard.vue' import AppCard from './components/AppCard.vue'
import Chatbot from './components/Chatbot.vue'
import { apps } from './apps' import { apps } from './apps'
</script> </script>
@@ -33,6 +34,8 @@ import { apps } from './apps'
<span>cube · monorepo at</span> <span>cube · monorepo at</span>
<a href="https://famzheng.me/gitea/fam/cube" target="_blank" rel="noopener">famzheng.me/gitea/fam/cube</a> <a href="https://famzheng.me/gitea/fam/cube" target="_blank" rel="noopener">famzheng.me/gitea/fam/cube</a>
</footer> </footer>
<Chatbot />
</main> </main>
</template> </template>
+72
View File
@@ -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 + contentpassphrase 鉴权。",
"url": "https://notes.famzheng.me",
"status": "live"
}
]
+6 -65
View File
@@ -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 type AppStatus = 'live' | 'pending' | 'tbd'
export interface App { export interface App {
@@ -8,68 +13,4 @@ export interface App {
status: AppStatus status: AppStatus
} }
export const apps: App[] = [ export const apps: App[] = data as 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',
},
]
@@ -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
View File
@@ -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"}
+15 -1
View File
@@ -30,6 +30,20 @@ spec:
ports: ports:
- containerPort: 8080 - containerPort: 8080
name: http 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: readinessProbe:
httpGet: httpGet:
path: /healthz path: /healthz
@@ -48,7 +62,7 @@ spec:
memory: 16Mi memory: 16Mi
limits: limits:
cpu: 200m cpu: 200m
memory: 64Mi memory: 128Mi
--- ---
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
+442
View File
@@ -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.meFam 的小 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());
}
}
+15 -2
View File
@@ -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] #[tokio::main]
async fn main() -> std::io::Result<()> { async fn main() -> std::io::Result<()> {
cube_core::init_tracing(); cube_core::init_tracing();
let dist = std::env::var("CUBE_DIST_DIR").unwrap_or_else(|_| "/dist".into()); 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 cube_core::serve(app, 8080).await
} }
+17
View File
@@ -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 }
+5
View File
@@ -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"]
+90
View File
@@ -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
+160
View File
@@ -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"));
}
}
+173
View File
@@ -0,0 +1,173 @@
//! `/v1/chat/completions` 透传 — 替换 Authorization 头,把请求 body 原样 forward 到
//! 上游 LLM gateway,把响应 body 原样回吐给客户端。
//!
//! 一期只支持非 streamingforce `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"),
}
}
}
+437
View File
@@ -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 &lt;your-token&gt;' \
-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>
+18
View File
@@ -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

+5429 -18
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -14,6 +14,10 @@
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.0.4", "@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"
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 792 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+39
View File
@@ -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

+279
View File
@@ -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)
}
}
+6
View File
@@ -2,8 +2,14 @@ import { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import App from './App.vue' import App from './App.vue'
import router from './router/index.js' import router from './router/index.js'
import { registerPwa } from './pwa.js'
import { initCache } from './lib/cache.js'
const app = createApp(App) const app = createApp(App)
app.use(createPinia()) app.use(createPinia())
app.use(router) app.use(router)
app.mount('#app') app.mount('#app')
registerPwa()
initCache() // 启动时按配置决定是否后台缓存
+20
View File
@@ -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')
},
})
}
+5
View File
@@ -23,6 +23,11 @@ const routes = [
component: () => import('../views/EditView.vue'), component: () => import('../views/EditView.vue'),
props: (route) => ({ id: Number(route.params.id) }), props: (route) => ({ id: Number(route.params.id) }),
}, },
{
path: '/settings',
name: 'settings',
component: () => import('../views/SettingsView.vue'),
},
] ]
export default createRouter({ export default createRouter({
+79
View File
@@ -0,0 +1,79 @@
/// <reference lib="webworker" />
// Music PWA service workerinjectManifest 模式)。
// 只 precache app shellHTML/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 })
}
})(),
)
})
+117 -6
View File
@@ -11,6 +11,7 @@
<span class="count">{{ filtered.length }} / {{ pieces.length }} </span> <span class="count">{{ filtered.length }} / {{ pieces.length }} </span>
<button class="btn-inspire" title="今天练什么?" @click="openInspire">💡</button> <button class="btn-inspire" title="今天练什么?" @click="openInspire">💡</button>
<router-link to="/upload" class="btn-add" title="新增曲目"></router-link> <router-link to="/upload" class="btn-add" title="新增曲目"></router-link>
<router-link to="/settings" class="btn-settings" title="设置"></router-link>
</header> </header>
<nav class="filterbar"> <nav class="filterbar">
@@ -43,6 +44,13 @@
:class="{ active: activeTagName === t.name }" :class="{ active: activeTagName === t.name }"
@click="setTag(t.name)" @click="setTag(t.name)"
>{{ t.name }}<span class="chip-n">{{ t.count }}</span></button> >{{ 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> </nav>
<div class="main"> <div class="main">
@@ -67,9 +75,14 @@
class="row" class="row"
:class="{ active: selectedId === p.id }" :class="{ active: selectedId === p.id }"
@click="selectPiece(p.id)" @click="selectPiece(p.id)"
@dblclick="playPiece(p.id)"
title="单击切换 / 双击切换并播放"
> >
<div class="row-main"> <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"> <div class="row-meta">
<span v-if="p.artist">{{ p.artist }}</span> <span v-if="p.artist">{{ p.artist }}</span>
<span v-if="p.category" class="cat">{{ p.category }}</span> <span v-if="p.category" class="cat">{{ p.category }}</span>
@@ -92,7 +105,15 @@
<template v-else> <template v-else>
<header class="now-playing"> <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"> <div class="np-sub">
<span v-if="selected.artist">{{ selected.artist }}</span> <span v-if="selected.artist">{{ selected.artist }}</span>
<span v-if="selected.category">· {{ selected.category }}</span> <span v-if="selected.category">· {{ selected.category }}</span>
@@ -386,6 +407,7 @@ import {
streamChat, streamChat,
streamInspire, streamInspire,
} from '../lib/api.js' } from '../lib/api.js'
import { getAudioUrl } from '../lib/cache.js'
import { parseLrc } from '../lib/lrc.js' import { parseLrc } from '../lib/lrc.js'
const route = useRoute() const route = useRoute()
@@ -404,6 +426,7 @@ const activeTagName = ref(null)
const search = ref('') const search = ref('')
const sortMode = ref(localStorage.getItem('music.sort') || 'name') const sortMode = ref(localStorage.getItem('music.sort') || 'name')
const favOnly = ref(localStorage.getItem('music.favOnly') === 'true')
const repeatOne = ref(false) const repeatOne = ref(false)
const volume = ref(parseFloat(localStorage.getItem('music.vol') || '1')) const volume = ref(parseFloat(localStorage.getItem('music.vol') || '1'))
const muted = ref(localStorage.getItem('music.muted') === '1') const muted = ref(localStorage.getItem('music.muted') === '1')
@@ -636,6 +659,9 @@ const tabs = computed(() => {
const filtered = computed(() => { const filtered = computed(() => {
const q = search.value.trim().toLowerCase() const q = search.value.trim().toLowerCase()
let arr = pieces.value let arr = pieces.value
if (favOnly.value) {
arr = arr.filter(p => p.favorite)
}
if (q) { if (q) {
arr = arr.filter(p => { arr = arr.filter(p => {
const hay = `${p.title} ${p.artist || ''} ${p.category || ''} ${(p.tags || []).join(' ')}`.toLowerCase() const hay = `${p.title} ${p.artist || ''} ${p.category || ''} ${(p.tags || []).join(' ')}`.toLowerCase()
@@ -643,6 +669,7 @@ const filtered = computed(() => {
}) })
} }
arr = [...arr] arr = [...arr]
// 名称排序时,收藏的自然置顶;其它模式按用户选的指标排,不强行打断
switch (sortMode.value) { switch (sortMode.value) {
case 'hot': case 'hot':
arr.sort((a, b) => b.play_count - a.play_count || a.title.localeCompare(b.title, 'zh')) arr.sort((a, b) => b.play_count - a.play_count || a.title.localeCompare(b.title, 'zh'))
@@ -664,11 +691,21 @@ const filtered = computed(() => {
break break
} }
default: 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 return arr
}) })
function toggleFavOnly() {
favOnly.value = !favOnly.value
localStorage.setItem('music.favOnly', favOnly.value ? 'true' : 'false')
}
function hash(id, seed) { function hash(id, seed) {
let x = (id ^ Math.floor(seed * 1e9)) >>> 0 let x = (id ^ Math.floor(seed * 1e9)) >>> 0
x = (x ^ (x << 13)) >>> 0 x = (x ^ (x << 13)) >>> 0
@@ -740,6 +777,10 @@ async function promptNewPlaylist() {
} }
async function loadPiece(id) { 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 selected.value = null
notesDraft.value = '' notesDraft.value = ''
// 切歌清 AB Looprate 保留全局) // 切歌清 AB Looprate 保留全局)
@@ -757,15 +798,22 @@ async function loadPiece(id) {
selected.value = p selected.value = p
notesDraft.value = p.notes || '' notesDraft.value = p.notes || ''
selectedId.value = p.id selectedId.value = p.id
// tab 保持:sticky 在新 piece 也存在就用它,否则用第一个
const t = tabs.value 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' activeTab.value = t[0]?.key || 'lyrics'
} }
await nextTick() await nextTick()
const first = audioAttachments.value[0] const first = audioAttachments.value[0]
if (first && audioEl.value) { if (first && audioEl.value) {
audioEl.value.src = attUrl(first.id) audioEl.value.src = getAudioUrl(first.id)
audioEl.value.play().catch(() => {}) // 续播条件:之前正在播 / 双击强制要求 / forceNextPlay flag
if (wasPlaying || forceNextPlay) {
audioEl.value.play().catch(() => {})
}
forceNextPlay = false
} else if (audioEl.value) { } else if (audioEl.value) {
audioEl.value.removeAttribute('src') audioEl.value.removeAttribute('src')
audioEl.value.load() audioEl.value.load()
@@ -794,6 +842,18 @@ function selectPiece(id) {
router.push({ name: 'piece', params: { id } }) router.push({ name: 'piece', params: { id } })
} }
let forceNextPlay = false
function playPiece(id) {
forceNextPlay = true
// 同 piece dblclickURL 不变 watch 不触发 → 直接 play
if (id === selectedId.value && audioEl.value) {
audioEl.value.play().catch(() => {})
forceNextPlay = false
return
}
selectPiece(id)
}
function attachmentUrl(id) { return attUrl(id) } function attachmentUrl(id) { return attUrl(id) }
function togglePlay() { function togglePlay() {
@@ -894,6 +954,22 @@ function setTab(k) {
activeTab.value = 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 // notes auto-save
function onNotesInput() { function onNotesInput() {
if (!selectedId.value) return if (!selectedId.value) return
@@ -1233,6 +1309,14 @@ onBeforeUnmount(() => {
text-decoration: none; text-decoration: none;
} }
.topbar .btn-add:hover { background: var(--accent); 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 { .filterbar {
display: flex; display: flex;
@@ -1319,6 +1403,17 @@ onBeforeUnmount(() => {
border-color: var(--accent-strong); border-color: var(--accent-strong);
color: var(--accent); 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; } .playlist { flex: 1; overflow-y: auto; }
.hint { padding: 40px 20px; text-align: center; color: var(--text-mute); font-size: 14px; } .hint { padding: 40px 20px; text-align: center; color: var(--text-mute); font-size: 14px; }
@@ -1410,7 +1505,23 @@ onBeforeUnmount(() => {
font-size: 22px; font-size: 22px;
color: var(--accent); color: var(--accent);
margin-bottom: 4px; 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 { .np-sub {
color: var(--text-dim); color: var(--text-dim);
font-size: 13px; 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>
+33 -4
View File
@@ -1,11 +1,40 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({ 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: { server: {
proxy: { proxy: {
'/api': 'http://localhost:8080' '/api': 'http://localhost:8080',
} },
} },
}) })
+25 -4
View File
@@ -135,6 +135,11 @@ async fn main() -> std::io::Result<()> {
CREATE INDEX IF NOT EXISTS idx_pp_piece ON playlist_pieces(piece_id);", CREATE INDEX IF NOT EXISTS idx_pp_piece ON playlist_pieces(piece_id);",
) )
.expect("init schema"); .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"); tracing::info!(%db_path, blobs = %blobs_dir.display(), "music ready");
let chord_url = let chord_url =
@@ -216,6 +221,7 @@ struct PieceSummary {
kinds: Vec<String>, kinds: Vec<String>,
tags: Vec<String>, tags: Vec<String>,
has_lyrics: bool, has_lyrics: bool,
favorite: bool,
created_at: String, created_at: String,
} }
@@ -232,6 +238,7 @@ struct PieceDetail {
created_at: String, created_at: String,
attachments: Vec<Attachment>, attachments: Vec<Attachment>,
tags: Vec<String>, tags: Vec<String>,
favorite: bool,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -271,6 +278,7 @@ struct PatchPiece {
tags: Option<Vec<String>>, tags: Option<Vec<String>>,
/// admin / import 用:直接写 play_countmvp 无认证) /// admin / import 用:直接写 play_countmvp 无认证)
play_count: Option<i64>, play_count: Option<i64>,
favorite: Option<bool>,
} }
#[derive(Deserialize, Default)] #[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, 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)) COALESCE((SELECT GROUP_CONCAT(t.name, char(9))
FROM piece_tags pt2 JOIN tags t ON t.id = pt2.tag_id 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 FROM pieces p
{filter_join} {filter_join}
{filter_where} {filter_where}
GROUP BY p.id 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 mut stmt = conn.prepare(&sql)?;
let bind_refs: Vec<&dyn rusqlite::ToSql> = bind.iter().map(|v| v as &dyn rusqlite::ToSql).collect(); 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() tags_raw.split('\t').map(|x| x.to_string()).collect()
}; };
let has_lyrics: i64 = r.get(9)?; let has_lyrics: i64 = r.get(9)?;
let fav: i64 = r.get(11)?;
Ok(PieceSummary { Ok(PieceSummary {
id: r.get(0)?, id: r.get(0)?,
title: r.get(1)?, title: r.get(1)?,
@@ -348,6 +358,7 @@ async fn list_pieces(
kinds, kinds,
tags, tags,
has_lyrics: has_lyrics != 0, has_lyrics: has_lyrics != 0,
favorite: fav != 0,
}) })
})? })?
.collect::<Result<Vec<_>, _>>()?; .collect::<Result<Vec<_>, _>>()?;
@@ -392,10 +403,12 @@ async fn get_piece(
i64, i64,
Option<String>, Option<String>,
String, String,
i64,
); );
let row: Option<PieceRow> = conn let row: Option<PieceRow> = conn
.query_row( .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", FROM pieces WHERE id = ?1",
params![id], params![id],
|r| { |r| {
@@ -408,11 +421,12 @@ async fn get_piece(
r.get(5)?, r.get(5)?,
r.get(6)?, r.get(6)?,
r.get(7)?, r.get(7)?,
r.get(8)?,
)) ))
}, },
) )
.optional()?; .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)?; row.ok_or(AppError::NotFound)?;
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
@@ -456,6 +470,7 @@ async fn get_piece(
created_at, created_at,
attachments, attachments,
tags, tags,
favorite: favorite != 0,
})) }))
} }
@@ -516,6 +531,12 @@ async fn patch_piece(
params![pc, id], 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 { if let Some(tags) = body.tags {
conn.execute( conn.execute(
"DELETE FROM piece_tags WHERE piece_id = ?1", "DELETE FROM piece_tags WHERE piece_id = ?1",
+21
View File
@@ -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 }
+5
View File
@@ -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"]
+25
View File
@@ -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"]
+970
View File
@@ -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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
def xml_escape_attr(s: str) -> str:
return xml_escape_text(s).replace('"', "&quot;")
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)
![alt](https://...) -> <img href="https://..."/> (URL is fetched
server-side by Feishu)
![alt](./local.png) -> 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:]))
+271
View File
@@ -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.durationN/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 自己拥有 dochost 上手动跑过 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),
}
+17
View File
@@ -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>
File diff suppressed because it is too large Load Diff
+18
View File
@@ -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"
}
}
+817
View File
@@ -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/mp4Chrome/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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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>
+54
View File
@@ -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())}`
}
+3
View File
@@ -0,0 +1,3 @@
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')
+11
View File
@@ -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'
}
}
})
+204
View File
@@ -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
# 默认挂全部 keysconfig.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
+925
View File
@@ -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+1cube 在伦敦
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 /transcribesidecar 用 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 同一个 docmarkdown-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(用 copysidecar 跑期间不会被改)
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()
}
}
}
}
+6 -1
View File
@@ -2,8 +2,13 @@
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <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" /> <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> <title>狼人杀发牌器</title>
</head> </head>
<body> <body>
+5386 -17
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -7,15 +7,19 @@
"dev": "vite", "dev": "vite",
"build": "vue-tsc -b && vite build", "build": "vue-tsc -b && vite build",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest" "test": "vitest",
"gen:icons": "node scripts/gen-icons.mjs",
"compress:images": "node scripts/compress-images.mjs"
}, },
"dependencies": { "dependencies": {
"vue": "^3.5.13" "vue": "^3.5.13"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.2.1",
"sharp": "^0.34.5",
"typescript": "~5.7.2", "typescript": "~5.7.2",
"vite": "^6.0.5", "vite": "^6.0.5",
"vite-plugin-pwa": "^1.3.0",
"vitest": "^2.1.8", "vitest": "^2.1.8",
"vue-tsc": "^2.2.0" "vue-tsc": "^2.2.0"
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 648 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 697 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 784 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 716 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 712 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 638 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 701 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 676 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 734 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 663 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 728 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 628 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 726 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 700 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 746 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 280 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 717 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 708 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 722 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 675 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 741 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 731 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 714 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 639 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 618 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 689 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 644 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 663 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 657 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

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')
+2
View File
@@ -1,5 +1,7 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import App from './App.vue' import App from './App.vue'
import './style.css' import './style.css'
import { setupPWA } from './pwa'
createApp(App).mount('#app') createApp(App).mount('#app')
setupPWA()
+97
View File
@@ -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)
})
})
}
+112
View File
@@ -0,0 +1,112 @@
/// <reference lib="webworker" />
// 自定义 Service WorkerinjectManifest 模式)。
// 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()
}
})(),
)
})
+2 -1
View File
@@ -18,5 +18,6 @@
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"types": ["vitest/globals"] "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"]
} }
+39 -1
View File
@@ -1,8 +1,46 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({ 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: { build: {
target: 'es2020', target: 'es2020',
}, },

Some files were not shown because too many files have changed in this diff Show More