- 整 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 逻辑会跳过不覆盖。
This commit is contained in:
@@ -0,0 +1,63 @@
|
|||||||
|
name: deploy write
|
||||||
|
# write.famzheng.me — host systemd service(不是 k8s pod),act_runner fam 用户直接 cp 本地
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
paths:
|
||||||
|
- 'apps/write/**'
|
||||||
|
- 'crates/cube-core/**'
|
||||||
|
- 'Cargo.toml'
|
||||||
|
- 'Cargo.lock'
|
||||||
|
- '.gitea/workflows/deploy-write.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
APP: write
|
||||||
|
# systemctl --user 需要 runtime dir;fam 已 enable linger
|
||||||
|
XDG_RUNTIME_DIR: /run/user/1001
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Build backend
|
||||||
|
run: |
|
||||||
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
|
cargo build --release -p "$APP"
|
||||||
|
|
||||||
|
- name: Build frontend
|
||||||
|
run: |
|
||||||
|
cd "apps/$APP/frontend"
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
- name: Install binary + dist + systemd unit
|
||||||
|
run: |
|
||||||
|
mkdir -p \
|
||||||
|
"$HOME/.local/bin" \
|
||||||
|
"$HOME/.local/share/$APP/dist" \
|
||||||
|
"$HOME/.local/state/$APP" \
|
||||||
|
"$HOME/.config/$APP" \
|
||||||
|
"$HOME/.config/systemd/user"
|
||||||
|
install -m 755 "target/release/$APP" "$HOME/.local/bin/$APP"
|
||||||
|
rsync -a --delete "apps/$APP/frontend/dist/" "$HOME/.local/share/$APP/dist/"
|
||||||
|
install -m 644 "apps/$APP/systemd/$APP.service" "$HOME/.config/systemd/user/$APP.service"
|
||||||
|
# 首次部署占位 env(已有则不动,避免覆盖 passphrase)
|
||||||
|
if [ ! -f "$HOME/.config/$APP/env" ]; then
|
||||||
|
echo "WRITE_PASSPHRASE=CHANGE-ME" > "$HOME/.config/$APP/env"
|
||||||
|
chmod 600 "$HOME/.config/$APP/env"
|
||||||
|
echo "⚠ created placeholder ~/.config/$APP/env, edit + restart"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Reload + restart write.service
|
||||||
|
run: |
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user enable "$APP.service"
|
||||||
|
systemctl --user restart "$APP.service"
|
||||||
|
sleep 1
|
||||||
|
systemctl --user --no-pager status "$APP.service" | head -15
|
||||||
|
|
||||||
|
- name: Apply k8s service/ingress
|
||||||
|
run: kubectl apply -f "apps/$APP/k8s/all.yaml"
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
[package]
|
||||||
|
name = "write"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
description = "write.famzheng.me — voice/text → claude → markdown doc"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
cube-core = { path = "../../crates/cube-core" }
|
||||||
|
axum = { workspace = true, features = ["ws"] }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
tower = { workspace = true }
|
||||||
|
tower-http = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
rusqlite = { workspace = true }
|
||||||
|
reqwest = { workspace = true }
|
||||||
|
futures = { workspace = true }
|
||||||
|
tokio-stream = { workspace = true }
|
||||||
|
tokio-tungstenite = "0.24"
|
||||||
|
futures-util = "0.3"
|
||||||
|
url = "2"
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
SHELL := /bin/bash
|
||||||
|
HOST := famzheng.com
|
||||||
|
HOST_USER := fam
|
||||||
|
APP := write
|
||||||
|
|
||||||
|
REMOTE_BIN := /home/$(HOST_USER)/.local/bin/$(APP)
|
||||||
|
REMOTE_DIST := /home/$(HOST_USER)/.local/share/$(APP)/dist
|
||||||
|
REMOTE_SYSTEMD := /home/$(HOST_USER)/.config/systemd/user/$(APP).service
|
||||||
|
REMOTE_ENV := /home/$(HOST_USER)/.config/$(APP)/env
|
||||||
|
REMOTE_STATE := /home/$(HOST_USER)/.local/state/$(APP)
|
||||||
|
|
||||||
|
.PHONY: build build-fe build-be deploy install enable start stop restart status logs k8s-apply k8s-delete dev dev-fe local-test
|
||||||
|
|
||||||
|
build: build-be build-fe
|
||||||
|
|
||||||
|
build-be:
|
||||||
|
@cd ../.. && cargo build --release -p $(APP)
|
||||||
|
|
||||||
|
build-fe:
|
||||||
|
@cd frontend && (test -d node_modules || npm ci) && npm run build
|
||||||
|
|
||||||
|
deploy: build
|
||||||
|
@echo "==> ensure remote dirs"
|
||||||
|
@ssh $(HOST) 'mkdir -p $(dir $(REMOTE_BIN)) $(REMOTE_DIST) $(dir $(REMOTE_SYSTEMD)) $(dir $(REMOTE_ENV)) $(REMOTE_STATE)'
|
||||||
|
@echo "==> ship binary"
|
||||||
|
@scp ../../target/release/$(APP) $(HOST):$(REMOTE_BIN)
|
||||||
|
@echo "==> ship frontend dist"
|
||||||
|
@rsync -a --delete frontend/dist/ $(HOST):$(REMOTE_DIST)/
|
||||||
|
@echo "==> ship systemd unit"
|
||||||
|
@scp systemd/$(APP).service $(HOST):$(REMOTE_SYSTEMD)
|
||||||
|
@ssh $(HOST) 'systemctl --user daemon-reload'
|
||||||
|
@echo "==> ensure env file (touch if absent)"
|
||||||
|
@ssh $(HOST) '[ -f $(REMOTE_ENV) ] || { echo "WRITE_PASSPHRASE=CHANGE-ME" > $(REMOTE_ENV) && chmod 600 $(REMOTE_ENV) && echo "⚠ created placeholder $(REMOTE_ENV) — edit it then run make restart"; }'
|
||||||
|
@$(MAKE) enable start status
|
||||||
|
|
||||||
|
install:
|
||||||
|
@$(MAKE) deploy
|
||||||
|
|
||||||
|
enable:
|
||||||
|
@ssh $(HOST) 'systemctl --user enable $(APP).service'
|
||||||
|
|
||||||
|
start:
|
||||||
|
@ssh $(HOST) 'systemctl --user start $(APP).service'
|
||||||
|
|
||||||
|
stop:
|
||||||
|
@ssh $(HOST) 'systemctl --user stop $(APP).service'
|
||||||
|
|
||||||
|
restart:
|
||||||
|
@ssh $(HOST) 'systemctl --user restart $(APP).service'
|
||||||
|
|
||||||
|
status:
|
||||||
|
@ssh $(HOST) 'systemctl --user --no-pager status $(APP).service | head -20'
|
||||||
|
|
||||||
|
logs:
|
||||||
|
@ssh $(HOST) 'tail -n 200 -f $(REMOTE_STATE)/$(APP).log'
|
||||||
|
|
||||||
|
k8s-apply:
|
||||||
|
@kubectl apply -f k8s/all.yaml
|
||||||
|
|
||||||
|
k8s-delete:
|
||||||
|
@kubectl delete -f k8s/all.yaml
|
||||||
|
|
||||||
|
# Local dev (runs both backend and frontend with auto-reload).
|
||||||
|
# Make a config first: ~/.config/write/env (locally) with WRITE_PASSPHRASE=...
|
||||||
|
dev:
|
||||||
|
@echo "==> backend on :31391"
|
||||||
|
@( cd ../.. && cargo run --release -p $(APP) ) & \
|
||||||
|
echo "==> frontend on :5173 (vite)"; \
|
||||||
|
cd frontend && npm run dev
|
||||||
|
|
||||||
|
dev-fe:
|
||||||
|
@cd frontend && npm run dev
|
||||||
|
|
||||||
|
# Local end-to-end test (uses ~/.local/share/write paths locally)
|
||||||
|
local-test:
|
||||||
|
@mkdir -p ~/.local/share/write/docs ~/.local/state/write
|
||||||
|
@( cd ../.. && \
|
||||||
|
WRITE_PASSPHRASE=test \
|
||||||
|
WRITE_DIST_DIR=$$PWD/apps/write/frontend/dist \
|
||||||
|
PORT=31391 \
|
||||||
|
cargo run --release -p $(APP) ) &
|
||||||
|
@echo "==> backend running; ctrl-c to stop"
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1.0,viewport-fit=cover" />
|
||||||
|
<title>write</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ctext y='26' font-size='28'%3E✍%3C/text%3E%3C/svg%3E" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1396
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "write",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"marked": "^14.0.0",
|
||||||
|
"vue": "^3.4.21"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.0.4",
|
||||||
|
"typescript": "^5.4.0",
|
||||||
|
"vite": "^5.4.0",
|
||||||
|
"vue-tsc": "^2.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// AudioWorkletProcessor: downsample mic input to 16 kHz mono **Int16 LE** PCM.
|
||||||
|
// Qwen3-ASR streaming server (mochi-asr on i7:9000) expects Int16 LE.
|
||||||
|
//
|
||||||
|
// Browsers run AudioContext at 44.1k or 48k. We linear-resample to 16k.
|
||||||
|
|
||||||
|
const TARGET_RATE = 16000;
|
||||||
|
const CHUNK_SAMPLES = 1600; // 100 ms at 16 kHz
|
||||||
|
|
||||||
|
class AsrWorklet extends AudioWorkletProcessor {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this._ratio = sampleRate / TARGET_RATE;
|
||||||
|
this._tail = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
process(inputs) {
|
||||||
|
const input = inputs[0];
|
||||||
|
if (!input || input.length === 0 || !input[0]) return true;
|
||||||
|
const channel = input[0];
|
||||||
|
|
||||||
|
const outLen = Math.floor(channel.length / this._ratio);
|
||||||
|
for (let i = 0; i < outLen; i++) {
|
||||||
|
const srcIdx = i * this._ratio;
|
||||||
|
const i0 = srcIdx | 0;
|
||||||
|
const i1 = i0 + 1 < channel.length ? i0 + 1 : channel.length - 1;
|
||||||
|
const t = srcIdx - i0;
|
||||||
|
this._tail.push(channel[i0] * (1 - t) + channel[i1] * t);
|
||||||
|
}
|
||||||
|
|
||||||
|
while (this._tail.length >= CHUNK_SAMPLES) {
|
||||||
|
const samples = this._tail.splice(0, CHUNK_SAMPLES);
|
||||||
|
const i16 = new Int16Array(CHUNK_SAMPLES);
|
||||||
|
for (let i = 0; i < CHUNK_SAMPLES; i++) {
|
||||||
|
let s = samples[i];
|
||||||
|
if (s < -1) s = -1;
|
||||||
|
else if (s > 1) s = 1;
|
||||||
|
i16[i] = s < 0 ? Math.round(s * 32768) : Math.round(s * 32767);
|
||||||
|
}
|
||||||
|
this.port.postMessage(i16.buffer, [i16.buffer]);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerProcessor('asr-worklet', AsrWorklet);
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import { marked } from 'marked'
|
||||||
|
import { api, getToken, setToken, type DocMeta } from './lib/api'
|
||||||
|
import { AsrSession } from './lib/asr'
|
||||||
|
|
||||||
|
const docs = ref<DocMeta[]>([])
|
||||||
|
const activeId = ref<number | null>(null)
|
||||||
|
const title = ref('')
|
||||||
|
const content = ref('')
|
||||||
|
const inputText = ref('')
|
||||||
|
const status = ref('')
|
||||||
|
const statusErr = ref(false)
|
||||||
|
const busy = ref(false)
|
||||||
|
const recording = ref(false)
|
||||||
|
|
||||||
|
const needToken = ref(!getToken())
|
||||||
|
const tokenInput = ref('')
|
||||||
|
|
||||||
|
// mobile UI state
|
||||||
|
const drawerOpen = ref(false)
|
||||||
|
const mobilePane = ref<'editor' | 'preview'>('preview')
|
||||||
|
|
||||||
|
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let asr: AsrSession | null = null
|
||||||
|
let voiceBase = '' // committed transcript so partials don't clobber it
|
||||||
|
|
||||||
|
function setStatus(msg: string, err = false): void {
|
||||||
|
status.value = msg
|
||||||
|
statusErr.value = err
|
||||||
|
if (msg && !err) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (status.value === msg) status.value = ''
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDocs(): Promise<void> {
|
||||||
|
try {
|
||||||
|
docs.value = await api.listDocs()
|
||||||
|
if (docs.value.length && activeId.value === null) {
|
||||||
|
await selectDoc(docs.value[0].id)
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
if (String(e).includes('401')) {
|
||||||
|
needToken.value = true
|
||||||
|
} else {
|
||||||
|
setStatus(String(e), true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectDoc(id: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
const d = await api.getDoc(id)
|
||||||
|
activeId.value = d.id
|
||||||
|
title.value = d.title
|
||||||
|
content.value = d.content
|
||||||
|
drawerOpen.value = false // close drawer on mobile after pick
|
||||||
|
} catch (e: any) {
|
||||||
|
setStatus(String(e), true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function newDoc(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const d = await api.createDoc('未命名文档')
|
||||||
|
await loadDocs()
|
||||||
|
await selectDoc(d.id)
|
||||||
|
} catch (e: any) {
|
||||||
|
setStatus(String(e), true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteDoc(id: number): Promise<void> {
|
||||||
|
if (!confirm('删除这个文档?')) return
|
||||||
|
await api.deleteDoc(id)
|
||||||
|
if (activeId.value === id) activeId.value = null
|
||||||
|
await loadDocs()
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSave(): void {
|
||||||
|
if (saveTimer) clearTimeout(saveTimer)
|
||||||
|
saveTimer = setTimeout(saveNow, 800)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveNow(): Promise<void> {
|
||||||
|
if (activeId.value === null) return
|
||||||
|
try {
|
||||||
|
await api.saveContent(activeId.value, content.value, title.value)
|
||||||
|
// refresh doc list ordering
|
||||||
|
docs.value = await api.listDocs()
|
||||||
|
} catch (e: any) {
|
||||||
|
setStatus(String(e), true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(content, scheduleSave)
|
||||||
|
watch(title, scheduleSave)
|
||||||
|
|
||||||
|
async function sendMessage(): Promise<void> {
|
||||||
|
if (!inputText.value.trim() || busy.value || activeId.value === null) return
|
||||||
|
const text = inputText.value.trim()
|
||||||
|
busy.value = true
|
||||||
|
setStatus('思考中…')
|
||||||
|
inputText.value = ''
|
||||||
|
try {
|
||||||
|
// Ensure latest edits are saved before claude touches the file
|
||||||
|
if (saveTimer) { clearTimeout(saveTimer); saveTimer = null }
|
||||||
|
await api.saveContent(activeId.value, content.value, title.value)
|
||||||
|
const r = await api.chat(activeId.value, text)
|
||||||
|
content.value = r.content
|
||||||
|
const cost = r.cost_usd?.toFixed(4) ?? '?'
|
||||||
|
const turns = r.turns ?? '?'
|
||||||
|
setStatus(`${r.reply || '完成'} • cost=$${cost} turns=${turns}`)
|
||||||
|
} catch (e: any) {
|
||||||
|
setStatus(String(e), true)
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
docs.value = await api.listDocs()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInputKeydown(e: KeyboardEvent): void {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
||||||
|
e.preventDefault()
|
||||||
|
sendMessage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleMic(): Promise<void> {
|
||||||
|
if (recording.value) {
|
||||||
|
// stop
|
||||||
|
recording.value = false
|
||||||
|
await asr?.stop()
|
||||||
|
asr = null
|
||||||
|
} else {
|
||||||
|
voiceBase = inputText.value
|
||||||
|
if (voiceBase && !voiceBase.endsWith(' ')) voiceBase += ' '
|
||||||
|
asr = new AsrSession((ev) => {
|
||||||
|
if (ev.kind === 'error') {
|
||||||
|
setStatus(`ASR: ${ev.message}`, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The Qwen3-ASR server returns cumulative text per partial.
|
||||||
|
inputText.value = voiceBase + ev.text
|
||||||
|
if (ev.kind === 'final') {
|
||||||
|
voiceBase = inputText.value
|
||||||
|
if (voiceBase && !voiceBase.endsWith(' ')) voiceBase += ' '
|
||||||
|
}
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
await asr.start()
|
||||||
|
recording.value = true
|
||||||
|
setStatus('🎙 录音中…再点一次停止')
|
||||||
|
} catch (e: any) {
|
||||||
|
recording.value = false
|
||||||
|
asr = null
|
||||||
|
setStatus(String(e), true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveToken(): void {
|
||||||
|
if (!tokenInput.value.trim()) return
|
||||||
|
setToken(tokenInput.value.trim())
|
||||||
|
needToken.value = false
|
||||||
|
tokenInput.value = ''
|
||||||
|
loadDocs()
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderedPreview = computed(() => {
|
||||||
|
return marked.parse(content.value || '*(空文档)*', { breaks: true, gfm: true }) as string
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!needToken.value) loadDocs()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="needToken" class="modal-backdrop">
|
||||||
|
<div class="modal">
|
||||||
|
<h2>请输入 passphrase</h2>
|
||||||
|
<input
|
||||||
|
v-model="tokenInput"
|
||||||
|
type="password"
|
||||||
|
placeholder="WRITE_PASSPHRASE"
|
||||||
|
@keydown.enter="saveToken"
|
||||||
|
/>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn primary" @click="saveToken">进入</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="app"
|
||||||
|
:class="{ 'drawer-open': drawerOpen, 'pane-editor': mobilePane === 'editor', 'pane-preview': mobilePane === 'preview' }"
|
||||||
|
v-show="!needToken"
|
||||||
|
>
|
||||||
|
<div class="mobile-bar">
|
||||||
|
<button class="hamburger" @click="drawerOpen = true" aria-label="open sidebar">☰</button>
|
||||||
|
<div class="doc-title">
|
||||||
|
<input
|
||||||
|
v-if="activeId !== null"
|
||||||
|
v-model="title"
|
||||||
|
placeholder="文档标题"
|
||||||
|
style="border:none;outline:none;background:transparent;font:inherit;font-weight:600;width:100%;"
|
||||||
|
/>
|
||||||
|
<span v-else style="color:#888;">没选中文档</span>
|
||||||
|
</div>
|
||||||
|
<div class="pane-tabs">
|
||||||
|
<button :class="{ active: mobilePane === 'editor' }" @click="mobilePane = 'editor'" title="源码">✏</button>
|
||||||
|
<button :class="{ active: mobilePane === 'preview' }" @click="mobilePane = 'preview'" title="预览">👁</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-backdrop" @click="drawerOpen = false"></div>
|
||||||
|
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<h1>✍ write</h1>
|
||||||
|
<button class="new-btn" @click="newDoc">+ 新建</button>
|
||||||
|
</div>
|
||||||
|
<div class="doc-list">
|
||||||
|
<div
|
||||||
|
v-for="d in docs"
|
||||||
|
:key="d.id"
|
||||||
|
class="doc-item"
|
||||||
|
:class="{ active: d.id === activeId }"
|
||||||
|
@click="selectDoc(d.id)"
|
||||||
|
>
|
||||||
|
<div class="title">{{ d.title || '未命名' }}</div>
|
||||||
|
<button class="del" @click.stop="deleteDoc(d.id)">删</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="!docs.length" style="padding: 12px; color: #888; font-size: 12px;">
|
||||||
|
还没有文档,点 + 新建一个。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="workspace">
|
||||||
|
<div class="editor-pane">
|
||||||
|
<div class="title-row">
|
||||||
|
<input
|
||||||
|
v-model="title"
|
||||||
|
placeholder="文档标题"
|
||||||
|
:disabled="activeId === null"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="editor">
|
||||||
|
<textarea
|
||||||
|
v-model="content"
|
||||||
|
placeholder="markdown 源码(此处可手动编辑,也可以让 AI 改)"
|
||||||
|
:disabled="activeId === null"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="preview" v-html="renderedPreview"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-bar">
|
||||||
|
<div class="input-row">
|
||||||
|
<textarea
|
||||||
|
v-model="inputText"
|
||||||
|
placeholder="对话框(Enter 发送 / Shift+Enter 换行)"
|
||||||
|
:disabled="busy || activeId === null"
|
||||||
|
@keydown="onInputKeydown"
|
||||||
|
></textarea>
|
||||||
|
<button
|
||||||
|
class="btn mic"
|
||||||
|
:class="{ on: recording }"
|
||||||
|
:disabled="busy || activeId === null"
|
||||||
|
@click="toggleMic"
|
||||||
|
:title="recording ? '停止录音' : '开始语音输入'"
|
||||||
|
>{{ recording ? '◼' : '🎙' }}</button>
|
||||||
|
<button
|
||||||
|
class="btn primary"
|
||||||
|
:disabled="busy || !inputText.trim() || activeId === null"
|
||||||
|
@click="sendMessage"
|
||||||
|
>发送</button>
|
||||||
|
</div>
|
||||||
|
<div class="status" :class="{ err: statusErr }">{{ status }}</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// Tiny API client. Token is read once from localStorage and attached to every
|
||||||
|
// request. WS endpoints use ?token= query (browsers can't set ws headers).
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'write.token'
|
||||||
|
|
||||||
|
export function getToken(): string {
|
||||||
|
return localStorage.getItem(TOKEN_KEY) || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setToken(t: string): void {
|
||||||
|
localStorage.setItem(TOKEN_KEY, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
function authHeaders(): Record<string, string> {
|
||||||
|
const t = getToken()
|
||||||
|
return t ? { Authorization: `token ${t}` } : {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function req<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
|
const headers: Record<string, string> = { ...authHeaders() }
|
||||||
|
if (body !== undefined) headers['Content-Type'] = 'application/json'
|
||||||
|
const r = await fetch(path, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
})
|
||||||
|
if (!r.ok) {
|
||||||
|
const text = await r.text().catch(() => '')
|
||||||
|
throw new Error(`${method} ${path} → ${r.status}: ${text}`)
|
||||||
|
}
|
||||||
|
if (r.status === 204) return undefined as T
|
||||||
|
return r.json() as Promise<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocMeta {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocFull extends DocMeta {
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatResponse {
|
||||||
|
content: string
|
||||||
|
reply: string
|
||||||
|
cost_usd?: number
|
||||||
|
turns?: number
|
||||||
|
duration_ms?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
listDocs: () => req<DocMeta[]>('GET', '/api/docs'),
|
||||||
|
createDoc: (title?: string) => req<DocMeta>('POST', '/api/docs', { title }),
|
||||||
|
getDoc: (id: number) => req<DocFull>('GET', `/api/docs/${id}`),
|
||||||
|
deleteDoc: (id: number) => req<void>('DELETE', `/api/docs/${id}`),
|
||||||
|
saveContent: (id: number, content: string, title?: string) =>
|
||||||
|
req<DocMeta>('PUT', `/api/docs/${id}/content`, { content, title }),
|
||||||
|
chat: (id: number, text: string) =>
|
||||||
|
req<ChatResponse>('POST', `/api/docs/${id}/chat`, { text }),
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asrWsUrl(): string {
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
const t = getToken()
|
||||||
|
return `${proto}//${location.host}/asr?token=${encodeURIComponent(t)}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// Browser-side ASR client. Connects to /asr (proxied to mochi-asr on i7:9000),
|
||||||
|
// captures the mic, downsamples in an AudioWorklet, emits transcript events.
|
||||||
|
|
||||||
|
import { asrWsUrl } from './api'
|
||||||
|
|
||||||
|
export type AsrEvent =
|
||||||
|
| { kind: 'partial'; text: string }
|
||||||
|
| { kind: 'final'; text: string }
|
||||||
|
| { kind: 'error'; message: string }
|
||||||
|
|
||||||
|
export type AsrEventHandler = (e: AsrEvent) => void
|
||||||
|
|
||||||
|
export class AsrSession {
|
||||||
|
private ws: WebSocket | null = null
|
||||||
|
private audioCtx: AudioContext | null = null
|
||||||
|
private node: AudioWorkletNode | null = null
|
||||||
|
private mediaStream: MediaStream | null = null
|
||||||
|
private source: MediaStreamAudioSourceNode | null = null
|
||||||
|
private handler: AsrEventHandler
|
||||||
|
private active = false
|
||||||
|
|
||||||
|
constructor(handler: AsrEventHandler) {
|
||||||
|
this.handler = handler
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
if (this.active) return
|
||||||
|
this.active = true
|
||||||
|
|
||||||
|
this.mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
this.audioCtx = new AudioContext()
|
||||||
|
await this.audioCtx.audioWorklet.addModule('/asr-worklet.js')
|
||||||
|
this.node = new AudioWorkletNode(this.audioCtx, 'asr-worklet', {
|
||||||
|
numberOfInputs: 1,
|
||||||
|
numberOfOutputs: 0,
|
||||||
|
channelCount: 1,
|
||||||
|
})
|
||||||
|
this.source = this.audioCtx.createMediaStreamSource(this.mediaStream)
|
||||||
|
this.source.connect(this.node)
|
||||||
|
|
||||||
|
this.ws = new WebSocket(asrWsUrl())
|
||||||
|
this.ws.binaryType = 'arraybuffer'
|
||||||
|
|
||||||
|
const flushQueue: ArrayBuffer[] = []
|
||||||
|
let wsReady = false
|
||||||
|
|
||||||
|
this.node.port.onmessage = (ev: MessageEvent<ArrayBuffer>) => {
|
||||||
|
if (!this.ws) return
|
||||||
|
if (wsReady && this.ws.readyState === WebSocket.OPEN) {
|
||||||
|
this.ws.send(ev.data)
|
||||||
|
} else {
|
||||||
|
flushQueue.push(ev.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ws.addEventListener('open', () => {
|
||||||
|
wsReady = true
|
||||||
|
while (flushQueue.length) {
|
||||||
|
const buf = flushQueue.shift()!
|
||||||
|
this.ws?.send(buf)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this.ws.addEventListener('message', (ev) => {
|
||||||
|
if (typeof ev.data !== 'string') return
|
||||||
|
let data: { text?: string; final?: boolean; error?: string }
|
||||||
|
try {
|
||||||
|
data = JSON.parse(ev.data)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (data.error) {
|
||||||
|
this.handler({ kind: 'error', message: data.error })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (typeof data.text === 'string') {
|
||||||
|
this.handler(
|
||||||
|
data.final
|
||||||
|
? { kind: 'final', text: data.text }
|
||||||
|
: { kind: 'partial', text: data.text },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this.ws.addEventListener('error', () => {
|
||||||
|
this.handler({ kind: 'error', message: 'asr ws error' })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
if (!this.active) return
|
||||||
|
this.active = false
|
||||||
|
|
||||||
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||||
|
this.ws.send(JSON.stringify({ type: 'flush' }))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.source) this.source.disconnect()
|
||||||
|
if (this.node) this.node.disconnect()
|
||||||
|
if (this.mediaStream) {
|
||||||
|
for (const track of this.mediaStream.getTracks()) track.stop()
|
||||||
|
}
|
||||||
|
if (this.audioCtx) await this.audioCtx.close()
|
||||||
|
|
||||||
|
this.source = null
|
||||||
|
this.node = null
|
||||||
|
this.mediaStream = null
|
||||||
|
this.audioCtx = null
|
||||||
|
|
||||||
|
// Give upstream a moment to send the {"final": true} response.
|
||||||
|
const ws = this.ws
|
||||||
|
this.ws = null
|
||||||
|
setTimeout(() => ws?.close(), 1500)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import './styles.css'
|
||||||
|
|
||||||
|
createApp(App).mount('#app')
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body, #app { height: 100%; margin: 0; width: 100%; overflow-x: hidden; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
|
||||||
|
'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||||
|
background: #fafafa;
|
||||||
|
color: #222;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.app { max-width: 100vw; overflow-x: hidden; }
|
||||||
|
|
||||||
|
button { font: inherit; cursor: pointer; }
|
||||||
|
textarea, input { font: inherit; }
|
||||||
|
|
||||||
|
/* ====== layout ====== */
|
||||||
|
|
||||||
|
.app {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 260px 1fr;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
border-right: 1px solid #e5e5e5;
|
||||||
|
background: #fff;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.sidebar-header h1 { margin: 0; font-size: 16px; font-weight: 600; }
|
||||||
|
.sidebar-header .new-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
background: #fff;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.sidebar-header .new-btn:hover { background: #f5f5f5; }
|
||||||
|
|
||||||
|
.doc-list { flex: 1; overflow-y: auto; padding: 6px; }
|
||||||
|
.doc-item {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
font-size: 13px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.doc-item:hover { background: #f3f3f3; }
|
||||||
|
.doc-item.active { background: #e8efff; }
|
||||||
|
.doc-item .title {
|
||||||
|
flex: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.doc-item .meta { font-size: 11px; color: #888; }
|
||||||
|
.doc-item .del {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #c44;
|
||||||
|
font-size: 12px;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
.doc-item:hover .del { visibility: visible; }
|
||||||
|
|
||||||
|
/* ====== workspace ====== */
|
||||||
|
|
||||||
|
.workspace {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 1fr auto;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
background: #fafafa;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-pane > * { min-width: 0; min-height: 0; }
|
||||||
|
.workspace > * { min-width: 0; }
|
||||||
|
.input-bar { min-width: 0; box-sizing: border-box; overflow: hidden; }
|
||||||
|
|
||||||
|
.editor-pane {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
min-height: 0;
|
||||||
|
border-bottom: 1px solid #e5e5e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-row {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.title-row input {
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor, .preview {
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.editor {
|
||||||
|
border-right: 1px solid #e5e5e5;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.editor { overflow-x: hidden; }
|
||||||
|
.editor textarea {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
resize: none;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
background: transparent;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.preview {
|
||||||
|
background: #fff;
|
||||||
|
line-height: 1.6;
|
||||||
|
overflow-x: hidden;
|
||||||
|
word-wrap: break-word;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.preview h1, .preview h2, .preview h3 { margin: 18px 0 8px; }
|
||||||
|
.preview p { margin: 8px 0; }
|
||||||
|
.preview pre {
|
||||||
|
background: #f5f5f5; padding: 10px; border-radius: 6px;
|
||||||
|
font-size: 12px; overflow-x: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.preview code { background: #f5f5f5; padding: 1px 4px; border-radius: 3px; font-size: 12px; }
|
||||||
|
.preview pre code { background: transparent; padding: 0; word-wrap: normal; overflow-wrap: normal; }
|
||||||
|
.preview blockquote { border-left: 3px solid #ddd; margin: 8px 0; padding: 4px 12px; color: #555; }
|
||||||
|
.preview img { max-width: 100%; height: auto; }
|
||||||
|
.preview table { display: block; max-width: 100%; overflow-x: auto; }
|
||||||
|
|
||||||
|
/* ====== input bar ====== */
|
||||||
|
|
||||||
|
.input-bar {
|
||||||
|
background: #fff;
|
||||||
|
border-top: 1px solid #e5e5e5;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
.input-row { display: flex; gap: 8px; align-items: flex-end; min-width: 0; }
|
||||||
|
.input-row textarea {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 40px;
|
||||||
|
max-height: 160px;
|
||||||
|
resize: none;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
outline: none;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.input-row textarea:focus { border-color: #6b7cff; }
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.btn:hover { background: #f5f5f5; }
|
||||||
|
.btn.primary { background: #5566ee; border-color: #4455dd; color: #fff; }
|
||||||
|
.btn.primary:hover { background: #4455dd; }
|
||||||
|
.btn.primary:disabled { background: #aaa; border-color: #aaa; cursor: not-allowed; }
|
||||||
|
.btn.mic { width: 44px; padding: 0; }
|
||||||
|
.btn.mic.on { background: #ee5566; border-color: #dd4455; color: #fff; }
|
||||||
|
|
||||||
|
.status {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
padding: 4px 4px 0;
|
||||||
|
min-height: 18px;
|
||||||
|
}
|
||||||
|
.status.err { color: #c44; }
|
||||||
|
|
||||||
|
/* ====== mobile-only chrome ====== */
|
||||||
|
|
||||||
|
.mobile-bar {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #e5e5e5;
|
||||||
|
padding-top: max(8px, env(safe-area-inset-top));
|
||||||
|
}
|
||||||
|
.mobile-bar .hamburger,
|
||||||
|
.mobile-bar .pane-tabs button {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
.mobile-bar .pane-tabs {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.mobile-bar .pane-tabs button.active {
|
||||||
|
background: #5566ee;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #4455dd;
|
||||||
|
}
|
||||||
|
.mobile-bar .doc-title {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.mobile-bar .doc-title input { min-width: 0; }
|
||||||
|
|
||||||
|
.sidebar-backdrop {
|
||||||
|
display: none;
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(0,0,0,0.35);
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====== mobile layout ====== */
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.app {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-bar { display: flex; }
|
||||||
|
|
||||||
|
/* sidebar becomes a slide-in drawer */
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; bottom: 0;
|
||||||
|
width: 78vw;
|
||||||
|
max-width: 320px;
|
||||||
|
border-right: 1px solid #e5e5e5;
|
||||||
|
border-bottom: none;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
z-index: 60;
|
||||||
|
box-shadow: 2px 0 8px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.app.drawer-open .sidebar { transform: translateX(0); }
|
||||||
|
.app.drawer-open .sidebar-backdrop { display: block; }
|
||||||
|
|
||||||
|
/* workspace fills the screen; editor + preview overlay each other,
|
||||||
|
pane-tabs picks which one shows */
|
||||||
|
.editor-pane {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.title-row { display: none; } /* title shows in the mobile-bar instead */
|
||||||
|
|
||||||
|
.editor, .preview {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: none;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.editor { display: none; }
|
||||||
|
.preview { display: none; }
|
||||||
|
.app.pane-editor .editor { display: block; }
|
||||||
|
.app.pane-preview .preview { display: block; }
|
||||||
|
|
||||||
|
/* input bar honors safe area at bottom */
|
||||||
|
.input-bar {
|
||||||
|
padding-bottom: max(10px, env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
.input-row textarea {
|
||||||
|
font-size: 16px; /* iOS won't zoom in if >= 16px */
|
||||||
|
}
|
||||||
|
.btn { height: 44px; } /* easier thumb target */
|
||||||
|
.btn.mic { width: 48px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====== modal ====== */
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(0,0,0,0.4);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
background: #fff; padding: 20px; border-radius: 10px;
|
||||||
|
width: 360px; max-width: 90vw;
|
||||||
|
}
|
||||||
|
.modal h2 { margin: 0 0 12px; font-size: 16px; }
|
||||||
|
.modal input {
|
||||||
|
width: 100%; padding: 8px 10px; border: 1px solid #ddd; border-radius: 6px;
|
||||||
|
margin-bottom: 12px; outline: none;
|
||||||
|
}
|
||||||
|
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; }
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": false,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:31391',
|
||||||
|
'/asr': { target: 'ws://localhost:31391', ws: true },
|
||||||
|
'/healthz': 'http://localhost:31391',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: cube-write
|
||||||
|
---
|
||||||
|
# write runs as a systemd --user fam service on the host (it spawns `claude`
|
||||||
|
# which uses Fam's OAuth credentials in ~/.claude). To expose it via traefik
|
||||||
|
# we point a manual Endpoints object at the node-side of cni0 (10.42.0.1).
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: write
|
||||||
|
namespace: cube-write
|
||||||
|
spec:
|
||||||
|
ports:
|
||||||
|
- port: 80
|
||||||
|
targetPort: 31391
|
||||||
|
protocol: TCP
|
||||||
|
name: http
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Endpoints
|
||||||
|
metadata:
|
||||||
|
name: write
|
||||||
|
namespace: cube-write
|
||||||
|
subsets:
|
||||||
|
- addresses:
|
||||||
|
- ip: 10.42.0.1
|
||||||
|
ports:
|
||||||
|
- port: 31391
|
||||||
|
protocol: TCP
|
||||||
|
name: http
|
||||||
|
---
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: write
|
||||||
|
namespace: cube-write
|
||||||
|
spec:
|
||||||
|
ingressClassName: traefik
|
||||||
|
rules:
|
||||||
|
- host: write.famzheng.me
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: write
|
||||||
|
port:
|
||||||
|
number: 80
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
//! `/asr` — browser-facing WS that proxies to the upstream ASR server.
|
||||||
|
//!
|
||||||
|
//! Upstream protocol (Qwen3-ASR streaming server on i7:9000):
|
||||||
|
//! client → server: text JSON `{"type":"init","language":"...","chunk_size_sec":N}`,
|
||||||
|
//! binary Int16 LE PCM, 16 kHz mono,
|
||||||
|
//! text JSON `{"type":"flush"}`
|
||||||
|
//! server → client: text JSON `{"text":"..."}`, `{"text":"...","final":true}`
|
||||||
|
//!
|
||||||
|
//! Browser only pushes PCM binary + optional flush. The proxy injects the
|
||||||
|
//! upstream `init` from server-side config.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{
|
||||||
|
ws::{Message, WebSocket},
|
||||||
|
State, WebSocketUpgrade,
|
||||||
|
},
|
||||||
|
response::Response,
|
||||||
|
};
|
||||||
|
use futures_util::{SinkExt, StreamExt};
|
||||||
|
use tokio_tungstenite::{
|
||||||
|
connect_async,
|
||||||
|
tungstenite::{client::IntoClientRequest, Message as TgMessage},
|
||||||
|
};
|
||||||
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
pub async fn ws_handler(State(state): State<AppState>, ws: WebSocketUpgrade) -> Response {
|
||||||
|
ws.on_upgrade(move |socket| handle_socket(socket, state))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_socket(client_ws: WebSocket, state: AppState) {
|
||||||
|
info!(upstream = %state.asr_upstream, "ASR proxy: dialing upstream");
|
||||||
|
let request = match state.asr_upstream.as_str().into_client_request() {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
error!(error = %e, "failed to build upstream request");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (upstream_ws, _resp) = match connect_async(request).await {
|
||||||
|
Ok(pair) => pair,
|
||||||
|
Err(e) => {
|
||||||
|
error!(error = %e, "ASR upstream connection failed");
|
||||||
|
let _ = close_with_msg(client_ws, "ASR upstream unreachable").await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (mut up_tx, mut up_rx) = upstream_ws.split();
|
||||||
|
let (mut cl_tx, mut cl_rx) = client_ws.split();
|
||||||
|
|
||||||
|
let init = serde_json::json!({
|
||||||
|
"type": "init",
|
||||||
|
"language": state.asr_language,
|
||||||
|
"chunk_size_sec": state.asr_chunk_size_sec,
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
if let Err(e) = up_tx.send(TgMessage::Text(init)).await {
|
||||||
|
warn!(error = %e, "ASR upstream init send failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let to_upstream = async move {
|
||||||
|
while let Some(msg) = cl_rx.next().await {
|
||||||
|
match msg {
|
||||||
|
Ok(Message::Binary(b)) => {
|
||||||
|
if up_tx.send(TgMessage::Binary(b.to_vec())).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Message::Text(t)) => {
|
||||||
|
if up_tx.send(TgMessage::Text(t.to_string())).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Message::Close(_)) => {
|
||||||
|
let _ = up_tx.send(TgMessage::Close(None)).await;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Ok(Message::Ping(p)) => {
|
||||||
|
let _ = up_tx.send(TgMessage::Ping(p)).await;
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => {
|
||||||
|
debug!(error = %e, "client ws recv error");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let to_client = async move {
|
||||||
|
while let Some(msg) = up_rx.next().await {
|
||||||
|
match msg {
|
||||||
|
Ok(TgMessage::Text(t)) => {
|
||||||
|
if cl_tx
|
||||||
|
.send(Message::Text(t.as_str().to_string().into()))
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(TgMessage::Binary(b)) => {
|
||||||
|
if cl_tx
|
||||||
|
.send(Message::Binary(b.to_vec().into()))
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(TgMessage::Close(_)) => {
|
||||||
|
let _ = cl_tx.send(Message::Close(None)).await;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => {
|
||||||
|
debug!(error = %e, "upstream ws recv error");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
() = to_upstream => warn!("ASR proxy: client side closed"),
|
||||||
|
() = to_client => warn!("ASR proxy: upstream side closed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn close_with_msg(mut ws: WebSocket, reason: &str) -> Result<(), axum::Error> {
|
||||||
|
let _ = ws
|
||||||
|
.send(Message::Text(
|
||||||
|
format!("{{\"error\":\"{reason}\"}}").into(),
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
ws.send(Message::Close(None)).await
|
||||||
|
}
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
//! Doc CRUD + the chat endpoint that hands off to claude.
|
||||||
|
//!
|
||||||
|
//! Layout on disk:
|
||||||
|
//! ${DOCS_DIR}/<id>/doc.md the editable markdown
|
||||||
|
//! Metadata (id/title/timestamps) in sqlite.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Stdio;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Json as JsonResp},
|
||||||
|
routing::{get, post, put},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use rusqlite::{params, OptionalExtension};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use tokio::process::Command;
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct DocMeta {
|
||||||
|
pub id: i64,
|
||||||
|
pub title: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn router() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/docs", get(list_docs).post(create_doc))
|
||||||
|
.route("/docs/:id", get(get_doc).delete(delete_doc))
|
||||||
|
.route("/docs/:id/content", put(save_doc_content))
|
||||||
|
.route("/docs/:id/chat", post(chat))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn doc_dir(state: &AppState, id: i64) -> PathBuf {
|
||||||
|
state.docs_dir.join(id.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn doc_path(state: &AppState, id: i64) -> PathBuf {
|
||||||
|
doc_dir(state, id).join("doc.md")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_docs(State(state): State<AppState>) -> impl IntoResponse {
|
||||||
|
let db = state.db.lock().expect("db lock");
|
||||||
|
let mut stmt = match db.prepare(
|
||||||
|
"SELECT id, title, created_at, updated_at FROM docs ORDER BY updated_at DESC",
|
||||||
|
) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => return Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string())),
|
||||||
|
};
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |row| {
|
||||||
|
Ok(DocMeta {
|
||||||
|
id: row.get(0)?,
|
||||||
|
title: row.get(1)?,
|
||||||
|
created_at: row.get(2)?,
|
||||||
|
updated_at: row.get(3)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
let out: Vec<DocMeta> = rows
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
.collect();
|
||||||
|
Ok(JsonResp(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct CreateDocReq {
|
||||||
|
title: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_doc(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
JsonResp(req): JsonResp<CreateDocReq>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let title = req.title.unwrap_or_else(|| "未命名文档".to_string());
|
||||||
|
let id: i64 = {
|
||||||
|
let db = state.db.lock().expect("db lock");
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO docs (title) VALUES (?1)",
|
||||||
|
params![title],
|
||||||
|
)
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
db.last_insert_rowid()
|
||||||
|
};
|
||||||
|
let dir = doc_dir(&state, id);
|
||||||
|
if let Err(e) = std::fs::create_dir_all(&dir) {
|
||||||
|
error!(error = %e, dir = %dir.display(), "create dir failed");
|
||||||
|
return Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string()));
|
||||||
|
}
|
||||||
|
let path = doc_path(&state, id);
|
||||||
|
let _ = std::fs::write(&path, "");
|
||||||
|
let meta = read_meta(&state, id)
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
|
||||||
|
Ok(JsonResp(meta))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_meta(state: &AppState, id: i64) -> Result<DocMeta, String> {
|
||||||
|
let db = state.db.lock().expect("db lock");
|
||||||
|
db.query_row(
|
||||||
|
"SELECT id, title, created_at, updated_at FROM docs WHERE id = ?1",
|
||||||
|
params![id],
|
||||||
|
|row| {
|
||||||
|
Ok(DocMeta {
|
||||||
|
id: row.get(0)?,
|
||||||
|
title: row.get(1)?,
|
||||||
|
created_at: row.get(2)?,
|
||||||
|
updated_at: row.get(3)?,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.ok_or_else(|| "doc not found".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct DocResp {
|
||||||
|
#[serde(flatten)]
|
||||||
|
meta: DocMeta,
|
||||||
|
content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_doc(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let meta = read_meta(&state, id)
|
||||||
|
.map_err(|e| (StatusCode::NOT_FOUND, e))?;
|
||||||
|
let content = std::fs::read_to_string(doc_path(&state, id))
|
||||||
|
.unwrap_or_default();
|
||||||
|
Ok::<_, (StatusCode, String)>(JsonResp(DocResp { meta, content }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_doc(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
{
|
||||||
|
let db = state.db.lock().expect("db lock");
|
||||||
|
let _ = db.execute("DELETE FROM docs WHERE id = ?1", params![id]);
|
||||||
|
}
|
||||||
|
let _ = std::fs::remove_dir_all(doc_dir(&state, id));
|
||||||
|
StatusCode::NO_CONTENT
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct SaveContentReq {
|
||||||
|
content: String,
|
||||||
|
title: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_doc_content(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
JsonResp(req): JsonResp<SaveContentReq>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let dir = doc_dir(&state, id);
|
||||||
|
std::fs::create_dir_all(&dir).ok();
|
||||||
|
std::fs::write(doc_path(&state, id), &req.content)
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
{
|
||||||
|
let db = state.db.lock().expect("db lock");
|
||||||
|
if let Some(t) = &req.title {
|
||||||
|
let _ = db.execute(
|
||||||
|
"UPDATE docs SET title = ?1, updated_at = CURRENT_TIMESTAMP WHERE id = ?2",
|
||||||
|
params![t, id],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
let _ = db.execute(
|
||||||
|
"UPDATE docs SET updated_at = CURRENT_TIMESTAMP WHERE id = ?1",
|
||||||
|
params![id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let meta = read_meta(&state, id)
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
|
||||||
|
Ok::<_, (StatusCode, String)>(JsonResp(meta))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ChatReq {
|
||||||
|
text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ChatResp {
|
||||||
|
content: String,
|
||||||
|
reply: String,
|
||||||
|
cost_usd: Option<f64>,
|
||||||
|
turns: Option<i64>,
|
||||||
|
duration_ms: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn chat(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
JsonResp(req): JsonResp<ChatReq>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
// Verify doc exists
|
||||||
|
let _meta = match read_meta(&state, id) {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => return Err((StatusCode::NOT_FOUND, e)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let dir = doc_dir(&state, id);
|
||||||
|
std::fs::create_dir_all(&dir).ok();
|
||||||
|
let path = doc_path(&state, id);
|
||||||
|
if !path.exists() {
|
||||||
|
let _ = std::fs::write(&path, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(id, "spawning claude in {}", dir.display());
|
||||||
|
|
||||||
|
// Build the prompt for claude. claude runs in the doc's directory with full
|
||||||
|
// read/write access via Read/Edit/Write tools — it edits `doc.md` directly.
|
||||||
|
let prompt = format!(
|
||||||
|
r#"你是 write.famzheng.me 的 AI 协作者。Fam 在跟你协作编辑一个 markdown 文档。
|
||||||
|
|
||||||
|
工作目录里只有一个文件: `doc.md`。这就是当前正在编辑的文档。
|
||||||
|
|
||||||
|
Fam 刚说:
|
||||||
|
---
|
||||||
|
{}
|
||||||
|
---
|
||||||
|
|
||||||
|
根据 Fam 说的话,决定动作:
|
||||||
|
- 如果他要求改文档(例如 "把第二段重写"、"加一个章节" 等),用 Read / Edit / Write 工具修改 `doc.md`。
|
||||||
|
- 如果他在问问题,**也直接把答案写进 `doc.md`**(追加,或合适地融入),因为这个 app 没有独立对话框——所有对话都体现在文档上。
|
||||||
|
- 如果他说的是闲聊或不相关,简短回应一下别动文档。
|
||||||
|
|
||||||
|
**重要**: 最终输出一句话简短反馈(不超过 30 字),告诉 Fam 你做了什么。比如 "已扩写第二段"、"已加 TODO 章节"、"没看懂你想改啥"。**不要在输出里贴文档全文**。
|
||||||
|
|
||||||
|
完成后退出。
|
||||||
|
"#,
|
||||||
|
req.text
|
||||||
|
);
|
||||||
|
|
||||||
|
// Spawn claude
|
||||||
|
let mut cmd = Command::new(&state.claude_bin);
|
||||||
|
cmd.current_dir(&dir)
|
||||||
|
.arg("-p")
|
||||||
|
.arg(&prompt)
|
||||||
|
.arg("--dangerously-skip-permissions")
|
||||||
|
.arg("--model")
|
||||||
|
.arg(&state.claude_model)
|
||||||
|
.arg("--output-format")
|
||||||
|
.arg("json")
|
||||||
|
.arg("--max-budget-usd")
|
||||||
|
.arg(state.claude_max_budget_usd.to_string())
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped());
|
||||||
|
|
||||||
|
let child = match cmd.spawn() {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
error!(error = %e, "spawn claude failed");
|
||||||
|
return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("spawn: {e}")));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Read stdout fully (JSON result format gives a single JSON object)
|
||||||
|
let output = match child.wait_with_output().await {
|
||||||
|
Ok(o) => o,
|
||||||
|
Err(e) => {
|
||||||
|
error!(error = %e, "claude wait failed");
|
||||||
|
return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("wait: {e}")));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
warn!(rc = ?output.status.code(), "claude exited non-zero");
|
||||||
|
return Err((
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("claude exit={:?}: {}", output.status.code(), &stderr[..stderr.len().min(2000)]),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let stdout_text = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let parsed: Value = serde_json::from_str(&stdout_text)
|
||||||
|
.unwrap_or_else(|_| json!({"result": stdout_text}));
|
||||||
|
|
||||||
|
let reply = parsed.get("result")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
let cost = parsed.get("total_cost_usd").and_then(Value::as_f64);
|
||||||
|
let turns = parsed.get("num_turns").and_then(Value::as_i64);
|
||||||
|
let duration = parsed.get("duration_ms").and_then(Value::as_i64);
|
||||||
|
|
||||||
|
// Re-read doc.md
|
||||||
|
let content = std::fs::read_to_string(&path).unwrap_or_default();
|
||||||
|
|
||||||
|
{
|
||||||
|
let db = state.db.lock().expect("db lock");
|
||||||
|
let _ = db.execute(
|
||||||
|
"UPDATE docs SET updated_at = CURRENT_TIMESTAMP WHERE id = ?1",
|
||||||
|
params![id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(id, ?cost, ?turns, ?duration, "claude chat done");
|
||||||
|
|
||||||
|
Ok::<_, (StatusCode, String)>(JsonResp(ChatResp {
|
||||||
|
content,
|
||||||
|
reply,
|
||||||
|
cost_usd: cost,
|
||||||
|
turns,
|
||||||
|
duration_ms: duration,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
//! write.famzheng.me — voice/text → claude → markdown doc.
|
||||||
|
//!
|
||||||
|
//! Auth: `Authorization: token <PASSPHRASE>` header. WS endpoints also accept
|
||||||
|
//! `?token=...` query (since browser ws can't set headers).
|
||||||
|
//! Config: env vars (see write.service for the full list).
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Request, State},
|
||||||
|
http::{header, StatusCode},
|
||||||
|
middleware::{from_fn_with_state, Next},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
routing::get,
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use rusqlite::Connection;
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
mod asr;
|
||||||
|
mod docs;
|
||||||
|
mod state;
|
||||||
|
use state::AppState;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> std::io::Result<()> {
|
||||||
|
cube_core::init_tracing();
|
||||||
|
|
||||||
|
let docs_dir = PathBuf::from(
|
||||||
|
std::env::var("WRITE_DOCS_DIR")
|
||||||
|
.unwrap_or_else(|_| "/home/fam/.local/share/write/docs".into()),
|
||||||
|
);
|
||||||
|
let db_path = std::env::var("WRITE_DB_PATH")
|
||||||
|
.unwrap_or_else(|_| "/home/fam/.local/share/write/app.db".into());
|
||||||
|
let dist = std::env::var("WRITE_DIST_DIR")
|
||||||
|
.unwrap_or_else(|_| "/home/fam/src/cube/apps/write/frontend/dist".into());
|
||||||
|
|
||||||
|
let passphrase = std::env::var("WRITE_PASSPHRASE").unwrap_or_default();
|
||||||
|
if passphrase.is_empty() {
|
||||||
|
warn!("WRITE_PASSPHRASE not set — all /api/* and /asr will return 401");
|
||||||
|
}
|
||||||
|
|
||||||
|
let asr_upstream = std::env::var("WRITE_ASR_UPSTREAM")
|
||||||
|
.unwrap_or_else(|_| "ws://cpc-i7:9000".into());
|
||||||
|
let asr_language =
|
||||||
|
std::env::var("WRITE_ASR_LANGUAGE").unwrap_or_else(|_| "Chinese".into());
|
||||||
|
let asr_chunk_size_sec: f64 = std::env::var("WRITE_ASR_CHUNK_SIZE_SEC")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(1.0);
|
||||||
|
|
||||||
|
let claude_bin = std::env::var("WRITE_CLAUDE_BIN")
|
||||||
|
.unwrap_or_else(|_| "/home/fam/.local/bin/claude".into());
|
||||||
|
let claude_model =
|
||||||
|
std::env::var("WRITE_CLAUDE_MODEL").unwrap_or_else(|_| "sonnet".into());
|
||||||
|
let claude_max_budget_usd: f64 = std::env::var("WRITE_CLAUDE_MAX_BUDGET_USD")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(2.0);
|
||||||
|
|
||||||
|
std::fs::create_dir_all(&docs_dir).expect("mkdir docs_dir");
|
||||||
|
if let Some(parent) = PathBuf::from(&db_path).parent() {
|
||||||
|
std::fs::create_dir_all(parent).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
let conn = Connection::open(&db_path).expect("open sqlite");
|
||||||
|
conn.execute_batch(
|
||||||
|
"PRAGMA journal_mode=WAL;
|
||||||
|
CREATE TABLE IF NOT EXISTS docs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL DEFAULT '未命名文档',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);",
|
||||||
|
)
|
||||||
|
.expect("init schema");
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
%db_path,
|
||||||
|
docs = %docs_dir.display(),
|
||||||
|
%asr_upstream,
|
||||||
|
%claude_model,
|
||||||
|
"write ready"
|
||||||
|
);
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
db: Arc::new(Mutex::new(conn)),
|
||||||
|
docs_dir,
|
||||||
|
passphrase,
|
||||||
|
asr_upstream,
|
||||||
|
asr_language,
|
||||||
|
asr_chunk_size_sec,
|
||||||
|
claude_bin,
|
||||||
|
claude_model,
|
||||||
|
claude_max_budget_usd,
|
||||||
|
};
|
||||||
|
|
||||||
|
let api = docs::router().route_layer(from_fn_with_state(state.clone(), auth_header));
|
||||||
|
let asr_router = Router::new()
|
||||||
|
.route("/asr", get(asr::ws_handler))
|
||||||
|
.route_layer(from_fn_with_state(state.clone(), auth_query_or_header));
|
||||||
|
|
||||||
|
let app: Router = Router::new()
|
||||||
|
.nest("/api", api)
|
||||||
|
.merge(asr_router)
|
||||||
|
.with_state(state)
|
||||||
|
.merge(cube_core::base(&dist));
|
||||||
|
|
||||||
|
let port: u16 = std::env::var("PORT")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(31391);
|
||||||
|
cube_core::serve(app, port).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Standard `Authorization: token <PASSPHRASE>` middleware.
|
||||||
|
async fn auth_header(State(state): State<AppState>, req: Request, next: Next) -> Response {
|
||||||
|
if state.passphrase.is_empty() {
|
||||||
|
return (StatusCode::UNAUTHORIZED, "auth not configured").into_response();
|
||||||
|
}
|
||||||
|
let want = format!("token {}", state.passphrase);
|
||||||
|
let got = req
|
||||||
|
.headers()
|
||||||
|
.get(header::AUTHORIZATION)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("");
|
||||||
|
if got != want {
|
||||||
|
return (StatusCode::UNAUTHORIZED, "invalid token").into_response();
|
||||||
|
}
|
||||||
|
next.run(req).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same as `auth_header` but also accepts `?token=<PASSPHRASE>` query — needed
|
||||||
|
/// for WebSocket endpoints since the browser can't attach custom headers to ws.
|
||||||
|
async fn auth_query_or_header(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
req: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Response {
|
||||||
|
if state.passphrase.is_empty() {
|
||||||
|
return (StatusCode::UNAUTHORIZED, "auth not configured").into_response();
|
||||||
|
}
|
||||||
|
let want_header = format!("token {}", state.passphrase);
|
||||||
|
let header_match = req
|
||||||
|
.headers()
|
||||||
|
.get(header::AUTHORIZATION)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(|got| got == want_header)
|
||||||
|
.unwrap_or(false);
|
||||||
|
let query_match = req.uri().query().map(|q| {
|
||||||
|
q.split('&').any(|kv| {
|
||||||
|
let mut parts = kv.splitn(2, '=');
|
||||||
|
matches!(
|
||||||
|
(parts.next(), parts.next()),
|
||||||
|
(Some("token"), Some(v)) if v == state.passphrase
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}).unwrap_or(false);
|
||||||
|
|
||||||
|
if !header_match && !query_match {
|
||||||
|
return (StatusCode::UNAUTHORIZED, "invalid token").into_response();
|
||||||
|
}
|
||||||
|
next.run(req).await
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use rusqlite::Connection;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub db: Arc<Mutex<Connection>>,
|
||||||
|
pub docs_dir: PathBuf,
|
||||||
|
pub passphrase: String,
|
||||||
|
pub asr_upstream: String,
|
||||||
|
pub asr_language: String,
|
||||||
|
pub asr_chunk_size_sec: f64,
|
||||||
|
pub claude_bin: String,
|
||||||
|
pub claude_model: String,
|
||||||
|
pub claude_max_budget_usd: f64,
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=write.famzheng.me — voice/text → claude → markdown doc
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/home/fam/.local/bin/write
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
# Env: required + optional
|
||||||
|
EnvironmentFile=/home/fam/.config/write/env
|
||||||
|
Environment=PORT=31391
|
||||||
|
Environment=WRITE_DOCS_DIR=/home/fam/.local/share/write/docs
|
||||||
|
Environment=WRITE_DB_PATH=/home/fam/.local/share/write/app.db
|
||||||
|
Environment=WRITE_DIST_DIR=/home/fam/.local/share/write/dist
|
||||||
|
Environment=WRITE_ASR_UPSTREAM=ws://cpc-i7:9000
|
||||||
|
Environment=WRITE_ASR_LANGUAGE=Chinese
|
||||||
|
Environment=WRITE_ASR_CHUNK_SIZE_SEC=1.0
|
||||||
|
Environment=WRITE_CLAUDE_BIN=/home/fam/.local/bin/claude
|
||||||
|
Environment=WRITE_CLAUDE_MODEL=sonnet
|
||||||
|
Environment=WRITE_CLAUDE_MAX_BUDGET_USD=2
|
||||||
|
Environment=RUST_LOG=info
|
||||||
|
|
||||||
|
StandardOutput=append:/home/fam/.local/state/write/write.log
|
||||||
|
StandardError=append:/home/fam/.local/state/write/write.log
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
Reference in New Issue
Block a user