新增平台 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,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<AssistantSessionMeta[]> {
const { data } = await http.get<AssistantSessionMeta[]>(`${REST}/sessions`)
return data ?? []
}
export async function getHistory(sessionId: string): Promise<AssistantHistory> {
const { data } = await http.get<AssistantHistory>(`${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<void> {
await http.delete(`${REST}/sessions/${encodeURIComponent(id)}`)
}
export async function listTools(): Promise<AssistantToolMeta[]> {
const { data } = await http.get<AssistantToolMeta[]>(`${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 流式消费。通过 <paramref name="signal"/> 支持中断(停止生成)。
* 事件协议见后端 §6.2session / token / tool_call / tool_result / error / done。
*/
export async function streamChat(
payload: { message: string; sessionId?: string | null; profile?: string },
handlers: AssistantStreamHandlers,
signal: AbortSignal
): Promise<void> {
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<string, unknown> = {}
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
}
}