- 整 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,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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user