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
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
This commit is contained in:
@@ -0,0 +1,55 @@
|
|||||||
|
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
|
||||||
|
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 image
|
||||||
|
env:
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
echo "$REGISTRY_TOKEN" | docker login registry.famzheng.me -u mochi --password-stdin
|
||||||
|
# --no-cache 必须 —— 见 memory/feedback_cube_docker_cache.md
|
||||||
|
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/notes/k8s/all.yaml
|
||||||
|
|
||||||
|
- name: Roll out to k3s
|
||||||
|
run: |
|
||||||
|
kubectl -n "$NS" set image "deploy/$APP" "$APP=$IMAGE:${{ steps.tag.outputs.sha }}"
|
||||||
|
kubectl -n "$NS" rollout status "deploy/$APP" --timeout=120s
|
||||||
Generated
+19
@@ -753,6 +753,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"
|
||||||
@@ -979,6 +997,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",
|
||||||
|
|||||||
+2
-1
@@ -8,6 +8,7 @@ members = [
|
|||||||
"apps/werewolf",
|
"apps/werewolf",
|
||||||
"apps/articulate",
|
"apps/articulate",
|
||||||
"apps/karaoke",
|
"apps/karaoke",
|
||||||
|
"apps/notes",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
@@ -25,7 +26,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"
|
||||||
|
|
||||||
|
|||||||
@@ -72,4 +72,11 @@ export const apps: App[] = [
|
|||||||
url: 'https://karaoke.famzheng.me',
|
url: 'https://karaoke.famzheng.me',
|
||||||
status: 'live',
|
status: 'live',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
slug: 'notes',
|
||||||
|
name: 'notes',
|
||||||
|
description: '录音 → ASR 转写 → LLM 生成会议纪要。Sidebar + content;passphrase 鉴权。',
|
||||||
|
url: 'https://notes.famzheng.me',
|
||||||
|
status: 'live',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
[package]
|
||||||
|
name = "notes"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
description = "notes.famzheng.me — 录音上传 → ASR 转写 → LLM 生成会议纪要"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
cube-core = { path = "../../crates/cube-core" }
|
||||||
|
axum = { workspace = true, features = ["multipart"] }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
tower = { workspace = true }
|
||||||
|
tower-http = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
rusqlite = { workspace = true }
|
||||||
|
reqwest = { workspace = true }
|
||||||
|
futures = { workspace = true }
|
||||||
|
tokio-stream = { workspace = true }
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
FROM scratch
|
||||||
|
COPY target/x86_64-unknown-linux-musl/release/notes /notes
|
||||||
|
COPY apps/notes/frontend/dist /dist
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["/notes"]
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<!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="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>
|
||||||
Generated
+1229
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "notes",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.4.21",
|
||||||
|
"vue-router": "^4.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.0.4",
|
||||||
|
"vite": "^5.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,502 @@
|
|||||||
|
<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">
|
||||||
|
<label class="upload-pick">
|
||||||
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
type="file"
|
||||||
|
accept="audio/*,video/*"
|
||||||
|
@change="onFile"
|
||||||
|
/>
|
||||||
|
<span class="upload-btn" :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">
|
||||||
|
<h2>{{ selected.title }}</h2>
|
||||||
|
<div class="head-meta">
|
||||||
|
<span>{{ statusLabel(selected.status) }}</span>
|
||||||
|
<span>· {{ fmtSize(selected.size_bytes) }}</span>
|
||||||
|
<span>· {{ selected.created_at }}</span>
|
||||||
|
<button v-if="selected.status === 'failed'" class="retry-btn" @click="retry">↻ 重试</button>
|
||||||
|
<button class="danger-btn" @click="remove">删除</button>
|
||||||
|
</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.transcript" class="muted">{{ selected.status === 'failed' ? '转写失败' : '尚未生成' }}</p>
|
||||||
|
<pre v-else class="transcript">{{ selected.transcript }}</pre>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
import {
|
||||||
|
listRecordings,
|
||||||
|
getRecording,
|
||||||
|
uploadRecording,
|
||||||
|
deleteRecording,
|
||||||
|
retryRecording,
|
||||||
|
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('')
|
||||||
|
let pollTimer = null
|
||||||
|
|
||||||
|
async function submitPass() {
|
||||||
|
setPass(passDraft.value.trim())
|
||||||
|
try {
|
||||||
|
await listRecordings()
|
||||||
|
needPass.value = false
|
||||||
|
authError.value = ''
|
||||||
|
refresh()
|
||||||
|
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
|
||||||
|
stopPoll()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
loading.value = true
|
||||||
|
try { list.value = await listRecordings() }
|
||||||
|
catch (e) {
|
||||||
|
if (e.unauthorized) { logout(); return }
|
||||||
|
}
|
||||||
|
finally { loading.value = false }
|
||||||
|
// 同步当前选中
|
||||||
|
if (selectedId.value) {
|
||||||
|
try { selected.value = await getRecording(selectedId.value) } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function select(id) {
|
||||||
|
selectedId.value = id
|
||||||
|
try { selected.value = await getRecording(id) }
|
||||||
|
catch (e) {
|
||||||
|
if (e.unauthorized) { logout(); return }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
await refresh()
|
||||||
|
} catch (e) { alert(e.message) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retry() {
|
||||||
|
try {
|
||||||
|
await retryRecording(selectedId.value)
|
||||||
|
await refresh()
|
||||||
|
} catch (e) { alert(e.message) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function audioUrl(id) { return audioUrlFn(id) }
|
||||||
|
|
||||||
|
function statusLabel(s) {
|
||||||
|
return ({
|
||||||
|
pending: '⏳ 排队',
|
||||||
|
transcribing: '🎙️ 转写中',
|
||||||
|
summarizing: '✏️ 总结中',
|
||||||
|
done: '✓ 完成',
|
||||||
|
failed: '✗ 失败',
|
||||||
|
})[s] || s
|
||||||
|
}
|
||||||
|
function progressText(s) {
|
||||||
|
return ({
|
||||||
|
pending: '等候处理',
|
||||||
|
transcribing: '语音转写中(视音频长度可能要几分钟)',
|
||||||
|
summarizing: 'LLM 生成纪要中',
|
||||||
|
})[s] || s
|
||||||
|
}
|
||||||
|
function fmtSize(b) {
|
||||||
|
if (!b) return '?'
|
||||||
|
if (b < 1024) return b + 'B'
|
||||||
|
if (b < 1024 * 1024) return (b / 1024).toFixed(1) + 'KB'
|
||||||
|
if (b < 1024 * 1024 * 1024) return (b / 1024 / 1024).toFixed(1) + 'MB'
|
||||||
|
return (b / 1024 / 1024 / 1024).toFixed(2) + 'GB'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 极简 markdown
|
||||||
|
function mdLite(s) {
|
||||||
|
if (!s) return ''
|
||||||
|
let h = s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
h = h.replace(/^### (.+)$/gm, '<h4>$1</h4>')
|
||||||
|
h = h.replace(/^## (.+)$/gm, '<h3>$1</h3>')
|
||||||
|
h = h.replace(/^# (.+)$/gm, '<h2>$1</h2>')
|
||||||
|
h = h.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>')
|
||||||
|
h = h.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||||
|
h = h.replace(/(^|\n)\s*[-*]\s+/g, '$1• ')
|
||||||
|
return h.split(/\n{2,}/).map(p => `<p>${p.replace(/\n/g, '<br>')}</p>`).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPoll() {
|
||||||
|
stopPoll()
|
||||||
|
pollTimer = setInterval(refresh, 5000)
|
||||||
|
}
|
||||||
|
function stopPoll() {
|
||||||
|
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!needPass.value) { refresh(); startPoll() }
|
||||||
|
})
|
||||||
|
onBeforeUnmount(stopPoll)
|
||||||
|
</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);
|
||||||
|
}
|
||||||
|
.upload-pick { position: relative; display: block; cursor: pointer; }
|
||||||
|
.upload-pick input[type=file] { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
|
||||||
|
.upload-btn {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
background: var(--accent-strong);
|
||||||
|
color: #fff;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.upload-btn:hover { background: var(--accent); }
|
||||||
|
.upload-btn.uploading { background: var(--bg-card); color: var(--text-dim); }
|
||||||
|
.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 {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.root { flex-direction: column; }
|
||||||
|
.sidebar { width: 100%; height: 45vh; border-right: none; border-bottom: 1px solid var(--border-soft); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// 鉴权:每个请求加 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 retryRecording(id) { return jreq('/api/recordings/' + id + '/retry', { method: 'POST' }) }
|
||||||
|
|
||||||
|
export function uploadRecording(title, file) {
|
||||||
|
const fd = new FormData()
|
||||||
|
if (title) fd.append('title', title)
|
||||||
|
fd.append('audio', file, file.name)
|
||||||
|
return jreq('/api/recordings', { method: 'POST', body: fd })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function audioUrl(id) {
|
||||||
|
return `/api/recordings/${id}/audio?token=${encodeURIComponent(getPass())}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
createApp(App).mount('#app')
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:8080'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
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
|
||||||
|
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: ASR_URL
|
||||||
|
value: http://18.159.112.195:8848/v1/audio/transcriptions
|
||||||
|
- 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: ASR_TOKEN
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: notes-creds
|
||||||
|
key: asr_token
|
||||||
|
- name: LLM_TOKEN
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: notes-creds
|
||||||
|
key: llm_token
|
||||||
|
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
|
||||||
|
volumes:
|
||||||
|
- name: data
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: notes-data
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: notes
|
||||||
|
namespace: cube-notes
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: notes
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 80
|
||||||
|
targetPort: 8080
|
||||||
|
---
|
||||||
|
apiVersion: traefik.io/v1alpha1
|
||||||
|
kind: Middleware
|
||||||
|
metadata:
|
||||||
|
name: bodylimit
|
||||||
|
namespace: cube-notes
|
||||||
|
spec:
|
||||||
|
buffering:
|
||||||
|
maxRequestBodyBytes: 629145600
|
||||||
|
---
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: notes
|
||||||
|
namespace: cube-notes
|
||||||
|
annotations:
|
||||||
|
traefik.ingress.kubernetes.io/router.middlewares: cube-notes-bodylimit@kubernetescrd
|
||||||
|
spec:
|
||||||
|
ingressClassName: traefik
|
||||||
|
rules:
|
||||||
|
- host: notes.famzheng.me
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: notes
|
||||||
|
port:
|
||||||
|
number: 80
|
||||||
@@ -0,0 +1,619 @@
|
|||||||
|
//! 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,
|
||||||
|
asr_url: String,
|
||||||
|
asr_token: String,
|
||||||
|
llm_gateway: String,
|
||||||
|
llm_token: String,
|
||||||
|
llm_model: 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");
|
||||||
|
}
|
||||||
|
let asr_url = std::env::var("ASR_URL")
|
||||||
|
.unwrap_or_else(|_| "http://18.159.112.195:8848/v1/audio/transcriptions".into());
|
||||||
|
let asr_token = std::env::var("ASR_TOKEN").unwrap_or_default();
|
||||||
|
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());
|
||||||
|
|
||||||
|
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");
|
||||||
|
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,
|
||||||
|
asr_url,
|
||||||
|
asr_token,
|
||||||
|
llm_gateway,
|
||||||
|
llm_token,
|
||||||
|
llm_model,
|
||||||
|
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).delete(delete_recording))
|
||||||
|
.route("/recordings/:id/audio", get(stream_audio))
|
||||||
|
.route("/recordings/:id/retry", post(retry_recording))
|
||||||
|
.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);
|
||||||
|
|
||||||
|
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>,
|
||||||
|
summary: Option<String>,
|
||||||
|
error: Option<String>,
|
||||||
|
created_at: 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>, String,
|
||||||
|
);
|
||||||
|
let row: Option<Row> = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT title, filename, mime, size_bytes, status,
|
||||||
|
transcript, summary, error, created_at
|
||||||
|
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)?,
|
||||||
|
))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
let (title, filename, mime, size_bytes, status, transcript, summary, error, created_at) =
|
||||||
|
row.ok_or(AppError::NotFound)?;
|
||||||
|
Ok(JsonResp(RecordingDetail {
|
||||||
|
id, title, filename, mime, size_bytes, status,
|
||||||
|
transcript, summary, error, created_at,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
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"))?;
|
||||||
|
let title = title
|
||||||
|
.filter(|x| !x.is_empty())
|
||||||
|
.unwrap_or_else(|| filename.clone());
|
||||||
|
|
||||||
|
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) {
|
||||||
|
set_status(&s, id, "transcribing", None, None);
|
||||||
|
let path = s.blobs_dir.join(id.to_string());
|
||||||
|
let filename: String = {
|
||||||
|
let conn = s.db.lock().unwrap();
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT filename FROM recordings WHERE id = ?1",
|
||||||
|
params![id],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap_or_else(|_| "audio".to_string())
|
||||||
|
};
|
||||||
|
|
||||||
|
// ASR:multipart POST,OpenAI Whisper 风格
|
||||||
|
let transcript = match call_asr(&s, &path, &filename).await {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(%id, error = %e, "ASR failed");
|
||||||
|
set_status(&s, id, "failed", None, Some(&format!("ASR: {e}")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// 写 transcript 但还没 summary
|
||||||
|
{
|
||||||
|
let conn = s.db.lock().unwrap();
|
||||||
|
let _ = conn.execute(
|
||||||
|
"UPDATE recordings SET transcript = ?1, status = 'summarizing' WHERE id = ?2",
|
||||||
|
params![&transcript, id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// LLM:生成会议纪要
|
||||||
|
let summary = 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 conn = s.db.lock().unwrap();
|
||||||
|
let _ = conn.execute(
|
||||||
|
"UPDATE recordings SET summary = ?1, status = 'done', error = NULL WHERE id = ?2",
|
||||||
|
params![&summary, id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
tracing::info!(%id, "done");
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
||||||
|
let bytes = tokio::fs::read(path).await.map_err(|e| e.to_string())?;
|
||||||
|
let part = reqwest::multipart::Part::bytes(bytes)
|
||||||
|
.file_name(filename.to_string())
|
||||||
|
.mime_str("audio/mpeg")
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let form = reqwest::multipart::Form::new()
|
||||||
|
.text("model", "qwen3-asr")
|
||||||
|
.text("response_format", "json")
|
||||||
|
.part("file", part);
|
||||||
|
|
||||||
|
let resp = s
|
||||||
|
.http
|
||||||
|
.post(&s.asr_url)
|
||||||
|
.bearer_auth(&s.asr_token)
|
||||||
|
.multipart(form)
|
||||||
|
.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!("ASR {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!("ASR response no 'text': {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":
|
||||||
|
"你是一个会议纪要助手。根据语音转写整理一份结构化纪要(markdown):\n\
|
||||||
|
1. **概要**:1-2 句话总结\n\
|
||||||
|
2. **关键讨论点**:bullet 列出\n\
|
||||||
|
3. **决定 / 结论**\n\
|
||||||
|
4. **行动项 (action items)**:谁、做什么、何时\n\
|
||||||
|
5. **待跟进 / 未决问题**\n\
|
||||||
|
转写可能有 ASR 错字,结合上下文合理修正;遇到模糊处标 [?]。" },
|
||||||
|
{ "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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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" })))
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user