新增平台 AI 助手抽屉与地图编辑助手对接。
统一流式问答入口,并用轻量 markdown 渲染助手回复。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user