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