write: 进 cube 仓库 + 接 gitea CI 自动部署
deploy write / build-and-deploy (push) Failing after 4s

- 整 apps/write/ 进 git(含 frontend 源码 + Makefile + systemd unit + k8s service/ingress)
- .gitea/workflows/deploy-write.yml: act_runner fam 用户跑 host shell
    cargo build → npm build → install 到 ~/.local/bin/share/config →
    systemctl --user daemon-reload + restart → kubectl apply svc/ingress
- 前端 3 处"麻薯"字样去掉(思考中 / placeholder × 2)

注意 ~/.config/write/env 已有 passphrase,CI placeholder 逻辑会跳过不覆盖。
This commit is contained in:
Fam Zheng
2026-05-24 17:16:44 +01:00
parent f8a7f31427
commit 9328c01c1b
20 changed files with 3206 additions and 0 deletions
+69
View File
@@ -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)}`
}
+118
View File
@@ -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)
}
}