diff --git a/frontends/apps/simple-platform-vue/src/api/assistant.ts b/frontends/apps/simple-platform-vue/src/api/assistant.ts new file mode 100644 index 0000000..10648c1 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/api/assistant.ts @@ -0,0 +1,208 @@ +import http from './http' + +/** + * AI 助手 API:会话/工具走 axios(带鉴权拦截器);对话走原生 fetch 流式(SSE), + * 因为 EventSource 只能 GET、且无法设置 Authorization header。fetch 这里手动对齐 + * axios 的双轨鉴权(Cookie + Bearer + X-Scope)。后端见 SimpleLite `Web/Assistant/AssistantApi.cs`。 + */ + +const API_BASE = (import.meta.env.VITE_API_BASE as string | undefined) ?? '/api' +const REST = '/sl/projection/assistant' + +export interface AssistantSessionMeta { + id: string + title: string + created: string + updated: string + turns: number +} + +export interface AssistantToolMeta { + name: string + description: string + isWrite: boolean +} + +export interface AssistantHistoryTurn { + role: 'user' | 'assistant' | 'tool' + content?: string | null + toolCalls?: { name: string; arguments: string }[] + time: string +} + +export interface AssistantHistory { + id: string + title: string + created: string + updated: string + turns: AssistantHistoryTurn[] +} + +export async function listSessions(): Promise { + const { data } = await http.get(`${REST}/sessions`) + return data ?? [] +} + +export async function getHistory(sessionId: string): Promise { + const { data } = await http.get(`${REST}/history`, { params: { sessionId } }) + return data +} + +export async function createSession(title?: string): Promise<{ id: string; title: string }> { + const { data } = await http.post<{ id: string; title: string }>( + `${REST}/sessions`, + null, + title ? { params: { title } } : undefined + ) + return data +} + +export async function deleteSession(id: string): Promise { + await http.delete(`${REST}/sessions/${encodeURIComponent(id)}`) +} + +export async function listTools(): Promise { + const { data } = await http.get(`${REST}/tools`) + return data ?? [] +} + +export interface AssistantStreamHandlers { + onSession?: (sessionId: string) => void + onToken?: (delta: string) => void + onToolCall?: (name: string, args: unknown) => void + onToolResult?: (name: string, ok: boolean, result: unknown) => void + onError?: (message: string) => void + onDone?: (finishReason: string, usedTools: number) => void +} + +/** + * 发起一轮对话并以 SSE 流式消费。通过 支持中断(停止生成)。 + * 事件协议见后端 §6.2:session / token / tool_call / tool_result / error / done。 + */ +export async function streamChat( + payload: { message: string; sessionId?: string | null; profile?: string }, + handlers: AssistantStreamHandlers, + signal: AbortSignal +): Promise { + const token = localStorage.getItem('simple.auth.token') + const scope = localStorage.getItem('simple.auth.scope') + + let resp: Response + try { + resp = await fetch(`${API_BASE}${REST}/chat`, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + Accept: 'text/event-stream', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(scope ? { 'X-Scope': scope } : {}) + }, + body: JSON.stringify({ + message: payload.message, + sessionId: payload.sessionId ?? undefined, + profile: payload.profile ?? 'analysis' + }), + signal + }) + } catch (e) { + if ((e as Error).name === 'AbortError') return + handlers.onError?.('网络错误:无法连接 AI 助手服务。') + handlers.onDone?.('error', 0) + return + } + + if (resp.status === 401) { + handlers.onError?.('登录已失效,请重新登录。') + handlers.onDone?.('error', 0) + return + } + if (!resp.ok || !resp.body) { + let msg = `请求失败(HTTP ${resp.status})` + if (resp.status === 403) msg = '无权使用 AI 助手(需要 Platform 权限)。' + else if (resp.status === 502 || resp.status === 504) msg = 'SimpleLite 未连接:请先启动后端(端口 8222)。' + else { + try { + const t = await resp.text() + if (t) msg = t.slice(0, 500) + } catch { + /* ignore */ + } + } + handlers.onError?.(msg) + handlers.onDone?.('error', 0) + return + } + + const reader = resp.body.getReader() + const decoder = new TextDecoder() + let buf = '' + try { + for (;;) { + const { value, done } = await reader.read() + if (done) break + buf += decoder.decode(value, { stream: true }) + let sep: number + // 帧之间以空行(\n\n)分隔。 + while ((sep = indexOfFrameBoundary(buf)) >= 0) { + const frame = buf.slice(0, sep) + buf = buf.slice(sep).replace(/^(\r?\n){2}/, '') + dispatchFrame(frame, handlers) + } + } + if (buf.trim()) dispatchFrame(buf, handlers) + } catch (e) { + if ((e as Error).name !== 'AbortError') { + handlers.onError?.((e as Error).message || '读取流失败') + handlers.onDone?.('error', 0) + } + } +} + +function indexOfFrameBoundary(s: string): number { + const a = s.indexOf('\n\n') + const b = s.indexOf('\r\n\r\n') + if (a < 0) return b + if (b < 0) return a + return Math.min(a, b) +} + +function dispatchFrame(raw: string, h: AssistantStreamHandlers): void { + let event = 'message' + const dataLines: string[] = [] + for (const lineRaw of raw.split('\n')) { + const line = lineRaw.replace(/\r$/, '') + if (line.startsWith('event:')) event = line.slice(6).trim() + else if (line.startsWith('data:')) dataLines.push(line.slice(5).replace(/^ /, '')) + } + const dataStr = dataLines.join('\n') + let data: Record = {} + try { + data = dataStr ? JSON.parse(dataStr) : {} + } catch { + data = { raw: dataStr } + } + + switch (event) { + case 'session': + h.onSession?.(String(data.sessionId ?? '')) + break + case 'token': + h.onToken?.(String(data.delta ?? '')) + break + case 'tool_call': + h.onToolCall?.(String(data.name ?? ''), data.args) + break + case 'tool_result': + h.onToolResult?.(String(data.name ?? ''), Boolean(data.ok), data.result) + break + case 'error': + h.onError?.(String(data.message ?? '未知错误')) + break + case 'done': + h.onDone?.(String(data.finishReason ?? 'stop'), Number(data.usedTools ?? 0)) + break + default: + break + } +} diff --git a/frontends/apps/simple-platform-vue/src/components/assistant/AiAssistantDrawer.vue b/frontends/apps/simple-platform-vue/src/components/assistant/AiAssistantDrawer.vue new file mode 100644 index 0000000..eef4e1b --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/components/assistant/AiAssistantDrawer.vue @@ -0,0 +1,677 @@ + + + + + diff --git a/frontends/apps/simple-platform-vue/src/components/map-editor/AiAssistantPanel.vue b/frontends/apps/simple-platform-vue/src/components/map-editor/AiAssistantPanel.vue index 423465d..38bce1c 100644 --- a/frontends/apps/simple-platform-vue/src/components/map-editor/AiAssistantPanel.vue +++ b/frontends/apps/simple-platform-vue/src/components/map-editor/AiAssistantPanel.vue @@ -51,9 +51,11 @@ :class="`aap-msg--${m.role}`" >
-
{{ m.text }}
+
{{ m.text }}
+
- 落地对象 {{ m.meta.created }} · 工具调用 {{ m.meta.usedTools }} + + 已落地 {{ m.meta.created }} 个对象 · 工具调用 {{ m.meta.usedTools }}
@@ -99,10 +101,11 @@