新增平台 AI 助手抽屉与地图编辑助手对接。

统一流式问答入口,并用轻量 markdown 渲染助手回复。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zhaowei.huang
2026-07-26 11:32:29 +08:00
co-authored by Cursor
parent ef63dda228
commit b1dd4f9005
5 changed files with 1300 additions and 3 deletions
@@ -0,0 +1,227 @@
import { ref, computed } from 'vue'
import {
streamChat,
listSessions,
getHistory,
deleteSession,
listTools,
type AssistantSessionMeta
} from '@/api/assistant'
export interface ToolInvocation {
name: string
isWrite: boolean
args?: unknown
status: 'running' | 'ok' | 'error'
result?: unknown
}
export interface ChatMessage {
id: string
role: 'user' | 'assistant'
content: string
tools: ToolInvocation[]
streaming?: boolean
error?: string | null
}
const CURRENT_KEY = 'assistant.currentSessionId'
function uid(): string {
return (globalThis.crypto?.randomUUID?.() ?? `id-${Date.now()}-${Math.random().toString(36).slice(2)}`)
}
/**
* AI 助手对话状态机:维护消息列表 + 会话列表,封装发送/中断/历史加载/会话切换。
* 单例式(模块级状态),让抽屉在路由切换后仍保留当前对话。
*/
const messages = ref<ChatMessage[]>([])
const sessions = ref<AssistantSessionMeta[]>([])
const currentSessionId = ref<string | null>(localStorage.getItem(CURRENT_KEY))
const status = ref<'idle' | 'streaming'>('idle')
const toolIsWrite = ref<Record<string, boolean>>({})
let initialized = false
let abort: AbortController | null = null
const isStreaming = computed(() => status.value === 'streaming')
const canSend = computed(() => status.value === 'idle')
function persistCurrent(): void {
try {
if (currentSessionId.value) localStorage.setItem(CURRENT_KEY, currentSessionId.value)
else localStorage.removeItem(CURRENT_KEY)
} catch {
/* ignore */
}
}
async function refreshSessions(): Promise<void> {
try {
sessions.value = await listSessions()
} catch {
/* 列表失败不阻塞对话 */
}
}
async function ensureInit(): Promise<void> {
if (initialized) return
initialized = true
try {
const tools = await listTools()
const map: Record<string, boolean> = {}
for (const t of tools) map[t.name] = t.isWrite
toolIsWrite.value = map
} catch {
/* 工具清单失败:tool 卡片仍可展示,仅缺少读写标识 */
}
await refreshSessions()
if (currentSessionId.value) await loadSession(currentSessionId.value)
}
async function loadSession(id: string): Promise<void> {
if (status.value === 'streaming') return
try {
const h = await getHistory(id)
const list: ChatMessage[] = []
const turns = h.turns ?? []
for (let i = 0; i < turns.length; i++) {
const t = turns[i]
if (t.role === 'user') {
list.push({ id: uid(), role: 'user', content: t.content ?? '', tools: [] })
} else if (t.role === 'assistant') {
const tools: ToolInvocation[] = []
const calls = t.toolCalls ?? []
for (const c of calls) {
// 历史中工具结果是紧随其后的 tool 轮,按顺序取回。
let result: unknown
const next = turns[i + 1]
if (next && next.role === 'tool') {
try {
result = JSON.parse(next.content ?? 'null')
} catch {
result = next.content
}
i++
}
let args: unknown
try {
args = JSON.parse(c.arguments || '{}')
} catch {
args = c.arguments
}
tools.push({ name: c.name, isWrite: toolIsWrite.value[c.name] ?? false, args, status: 'ok', result })
}
list.push({ id: uid(), role: 'assistant', content: t.content ?? '', tools })
}
// 落单的 tool 轮(理论上已被上面消费)跳过。
}
messages.value = list
currentSessionId.value = id
persistCurrent()
} catch {
/* ignore */
}
}
function newSession(): void {
if (status.value === 'streaming') stop()
messages.value = []
currentSessionId.value = null
persistCurrent()
}
async function removeSession(id: string): Promise<void> {
try {
await deleteSession(id)
} catch {
/* ignore */
}
await refreshSessions()
if (currentSessionId.value === id) newSession()
}
async function send(text: string): Promise<void> {
const msg = text.trim()
if (!msg || status.value === 'streaming') return
messages.value.push({ id: uid(), role: 'user', content: msg, tools: [] })
const assistant: ChatMessage = { id: uid(), role: 'assistant', content: '', tools: [], streaming: true, error: null }
messages.value.push(assistant)
status.value = 'streaming'
abort = new AbortController()
await streamChat(
{ message: msg, sessionId: currentSessionId.value, profile: 'analysis' },
{
onSession: (sid) => {
if (sid) {
currentSessionId.value = sid
persistCurrent()
}
},
onToken: (delta) => {
assistant.content += delta
},
onToolCall: (name, args) => {
assistant.tools.push({ name, isWrite: toolIsWrite.value[name] ?? false, args, status: 'running' })
},
onToolResult: (name, ok, result) => {
const card = [...assistant.tools].reverse().find((t) => t.name === name && t.status === 'running')
if (card) {
card.status = ok ? 'ok' : 'error'
card.result = result
} else {
assistant.tools.push({ name, isWrite: toolIsWrite.value[name] ?? false, status: ok ? 'ok' : 'error', result })
}
},
onError: (message) => {
assistant.error = message
},
onDone: () => {
assistant.streaming = false
status.value = 'idle'
abort = null
void refreshSessions()
}
},
abort.signal
)
// 兜底:若流意外结束未触发 done。
if (assistant.streaming) {
assistant.streaming = false
status.value = 'idle'
abort = null
}
}
function stop(): void {
if (abort) {
abort.abort()
abort = null
}
const last = messages.value[messages.value.length - 1]
if (last && last.role === 'assistant' && last.streaming) {
last.streaming = false
if (!last.content && last.tools.length === 0) last.content = '(已停止)'
}
status.value = 'idle'
}
export function useAssistantChat() {
return {
messages,
sessions,
currentSessionId,
status,
isStreaming,
canSend,
ensureInit,
refreshSessions,
loadSession,
newSession,
removeSession,
send,
stop
}
}