feat(v3): Web 应用化基线(异步任务/SSE/llama-server 管理/Vue SPA 四页 + 设置页整页滚动修复)

This commit is contained in:
tzt
2026-09-01 08:31:47 +08:00
parent 8d36eeec59
commit 3bbdcb7cc7
60 changed files with 7236 additions and 1160 deletions
+338
View File
@@ -0,0 +1,338 @@
<template>
<div class="chat-view">
<!-- 历史会话侧边栏 -->
<aside class="sidebar">
<h3>会话记录</h3>
<ul class="session-list">
<li
v-for="s in chatStore.sessions"
:key="s.requestId"
:class="['session-item', { active: s.requestId === chatStore.currentId }]"
@click="chatStore.setCurrent(s.requestId)"
>
<span class="s-query">{{ s.query.slice(0, 28) }}{{ s.query.length > 28 ? '…' : '' }}</span>
<span class="s-status" :class="s.status?.status ?? 'pending'">
{{ s.status?.status ?? 'pending' }}
</span>
</li>
</ul>
</aside>
<!-- 主聊天区 -->
<main class="main">
<div v-if="!chatStore.current()" class="empty">
<p>输入问题开启端云协同协作之旅</p>
<p class="hint">结果通过 SSE 实时推送可切换协作页面查看交流文本可视化</p>
</div>
<template v-else>
<!-- 用户问题 -->
<div class="user-msg">
<span class="role-label"></span>
<p>{{ chatStore.current()!.query }}</p>
</div>
<!-- 状态指示器 -->
<div class="status-bar">
<span v-if="runStatus === 'pending'" class="badge pending"> 排队中</span>
<span v-else-if="runStatus === 'running'" class="badge running">
🔄 协作中 ({{ ws?.meta.round ?? 0 }}/{{ ws?.meta.rounds_cap ?? 6 }})
</span>
<span v-else-if="runStatus === 'done'" class="badge done"> 完成</span>
<span v-else-if="runStatus === 'failed'" class="badge failed"> 失败</span>
<span v-if="ws" class="route-path">
路由{{ chatStore.current()!.status?.route?.join(' ') ?? '' }}
</span>
</div>
<!-- 协作元信息 -->
<div v-if="ws" class="ws-meta">
<span>tokens: {{ ws.meta.api_input_tokens }} in / {{ ws.meta.api_output_tokens }} out</span>
<span>延迟: {{ chatStore.current()!.status?.latency_ms?.toFixed(0) }} ms</span>
<span>模型: {{ chatStore.current()!.status?.model_used ?? '—' }}</span>
</div>
<!-- 响应正文 -->
<div v-if="response" class="assistant-msg">
<span class="role-label">系统</span>
<pre>{{ response }}</pre>
</div>
<!-- 交流文本预览紧凑折叠 -->
<details v-if="ws" class="ws-preview">
<summary>📄 交流文本预览</summary>
<div v-if="ws.brief" class="brief-block">
<strong>Brief:</strong> {{ ws.brief.goal }}
<span class="tags">{{ ws.brief.tags.join(', ') }}</span>
</div>
<ul v-if="ws.plan?.length" class="plan-list">
<li v-for="p in ws.plan" :key="p.id" :class="p.status">
<span class="step-id">{{ p.id }}</span>
<span>{{ p.task }}</span>
<span class="deps" v-if="p.deps.length">{{ p.deps.join(',') }}</span>
</li>
</ul>
<ul v-if="ws.progress?.length" class="progress-list">
<li v-for="pg in ws.progress" :key="pg.step" :class="pg.status">
{{ pg.step }}: {{ pg.status }}
</li>
</ul>
</details>
<!-- 错误 -->
<div v-if="chatStore.current()!.error" class="error-msg">
{{ chatStore.current()!.error }}
</div>
</template>
<!-- 输入框 -->
<form class="input-bar" @submit.prevent="handleSend">
<input
v-model="input"
placeholder="输入你的问题…"
:disabled="sending"
autofocus
/>
<button type="submit" :disabled="sending || !input.trim()">
{{ sending ? '发送中…' : '发送' }}
</button>
</form>
</main>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useChatStore } from '@/stores/chat'
import { watchRun } from '@/api'
import type { Workspace } from '@/types'
const chatStore = useChatStore()
const input = ref('')
const sending = ref(false)
const current = computed(() => chatStore.current())
const runStatus = computed(() => current.value?.status?.status ?? 'pending')
const ws = computed(() => current.value?.workspace)
const response = computed(() => current.value?.status?.response ?? null)
// SSE 订阅
let sseHandle: ReturnType<typeof watchRun> | null = null
function subscribeSSE(requestId: string) {
sseHandle?.close()
sseHandle = watchRun(requestId)
sseHandle.subscribe({
onWorkspace(workspace: Workspace) {
chatStore.updateWorkspace(requestId, workspace)
},
onStatus(_state: string) {
chatStore.pollStatus(requestId)
},
onError(detail: string) {
const s = chatStore.sessions.find((x) => x.requestId === requestId)
if (s) s.error = detail
},
onDone() {
chatStore.pollStatus(requestId)
},
})
}
watch(
() => chatStore.currentId,
(id) => {
if (id) {
subscribeSSE(id)
// 若已完成立即拉一次状态
if (current.value?.status?.status === 'done') {
chatStore.pollStatus(id)
}
}
},
{ immediate: true },
)
async function handleSend() {
if (!input.value.trim() || sending.value) return
const query = input.value.trim()
input.value = ''
sending.value = true
try {
const session = await chatStore.sendQuery(query)
subscribeSSE(session.requestId)
} finally {
sending.value = false
}
}
</script>
<style scoped>
.chat-view {
display: flex;
height: 100%;
gap: 0;
}
.sidebar {
width: 240px;
border-right: 1px solid #e5e7eb;
background: #f9fafb;
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar h3 {
padding: 12px 16px;
margin: 0;
font-size: 14px;
color: #6b7280;
border-bottom: 1px solid #e5e7eb;
}
.session-list {
list-style: none;
margin: 0;
padding: 8px;
overflow-y: auto;
flex: 1;
}
.session-item {
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
margin-bottom: 4px;
display: flex;
flex-direction: column;
gap: 2px;
font-size: 13px;
}
.session-item:hover { background: #e5e7eb; }
.session-item.active { background: #dbeafe; }
.s-query { color: #111; }
.s-status { font-size: 11px; color: #9ca3af; }
.s-status.done { color: #16a34a; }
.s-status.failed { color: #dc2626; }
.s-status.running { color: #2563eb; }
.main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 20px 24px;
gap: 12px;
}
.empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #9ca3af;
gap: 8px;
}
.hint { font-size: 13px; }
.user-msg, .assistant-msg {
background: #f3f4f6;
border-radius: 8px;
padding: 12px 16px;
display: flex;
gap: 12px;
font-size: 14px;
line-height: 1.6;
}
.assistant-msg { background: #eff6ff; }
.role-label {
font-weight: 700;
font-size: 12px;
color: #6b7280;
min-width: 32px;
}
.user-msg pre, .assistant-msg pre {
margin: 0;
white-space: pre-wrap;
font-family: inherit;
}
.status-bar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.badge {
padding: 3px 10px;
border-radius: 99px;
font-size: 12px;
font-weight: 600;
}
.badge.pending { background: #f3f4f6; color: #6b7280; }
.badge.running { background: #dbeafe; color: #2563eb; }
.badge.done { background: #dcfce7; color: #16a34a; }
.badge.failed { background: #fee2e2; color: #dc2626; }
.route-path { font-size: 12px; color: #9ca3af; }
.ws-meta {
display: flex;
gap: 16px;
font-size: 12px;
color: #9ca3af;
}
.ws-preview {
background: #fafafa;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 12px;
font-size: 13px;
}
.brief-block { margin-bottom: 8px; }
.tags { margin-left: 8px; color: #6b7280; font-size: 12px; }
.plan-list, .progress-list {
list-style: none;
padding: 0;
margin: 4px 0 0;
}
.plan-list li, .progress-list li {
padding: 2px 0;
display: flex;
gap: 6px;
}
.step-id { font-family: monospace; color: #6b7280; min-width: 48px; }
.deps { color: #9ca3af; font-size: 12px; }
.done { color: #16a34a; }
.pending { color: #9ca3af; }
.running { color: #2563eb; }
.error-msg { color: #dc2626; font-size: 13px; background: #fee2e2; padding: 8px 12px; border-radius: 6px; }
.input-bar {
display: flex;
gap: 8px;
padding-top: 12px;
border-top: 1px solid #e5e7eb;
margin-top: auto;
}
.input-bar input {
flex: 1;
padding: 10px 14px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 14px;
outline: none;
}
.input-bar input:focus { border-color: #2563eb; }
.input-bar button {
padding: 10px 20px;
background: #2563eb;
color: #fff;
border: none;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
}
.input-bar button:disabled { background: #9ca3af; cursor: not-allowed; }
</style>