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