新增平台 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
}
}
@@ -0,0 +1,677 @@
<template>
<!-- 悬浮唤起按钮所有界面右下角常驻 -->
<button
v-show="!open"
class="ai-fab"
type="button"
title="AI 分析助手(故障 / 日志 / 数据分析)"
@click="toggle(true)"
>
<span class="ai-fab-glyph"></span>
</button>
<!-- 右侧抽屉 -->
<section
class="ai-drawer"
:class="{ 'is-open': open }"
:style="{ width: width + 'px' }"
role="complementary"
aria-label="AI 助手"
>
<div
class="ai-resizer"
title="拖拽调整宽度"
@pointerdown="onResizeStart"
@pointermove="onResizeMove"
@pointerup="onResizeEnd"
@pointercancel="onResizeEnd"
/>
<header class="ai-head">
<div class="ai-head-title">
<span class="ai-head-glyph"></span>
<div class="ai-head-text">
<div class="ai-head-main">AI 分析助手</div>
<div class="ai-head-sub">故障 / 日志 / 数据分析 · 操作答疑</div>
</div>
</div>
<div class="ai-head-actions">
<button class="ai-iconbtn" title="新建会话" @click="onNew">
<el-icon><Plus /></el-icon>
</button>
<button class="ai-iconbtn" :class="{ active: showSessions }" title="历史会话" @click="toggleSessions">
<el-icon><ChatLineSquare /></el-icon>
</button>
<button class="ai-iconbtn" title="收起" @click="toggle(false)">
<el-icon><Close /></el-icon>
</button>
</div>
<!-- 历史会话面板 -->
<div v-if="showSessions" class="ai-sessions">
<div class="ai-sessions-head">
<span>历史会话</span>
<button class="ai-link" @click="onNew">+ 新建</button>
</div>
<div v-if="sessions.length === 0" class="ai-sessions-empty">暂无历史会话</div>
<ul v-else class="ai-sessions-list">
<li
v-for="s in sessions"
:key="s.id"
class="ai-session-item"
:class="{ active: s.id === currentSessionId }"
@click="onPickSession(s.id)"
>
<div class="ai-session-main">
<div class="ai-session-title">{{ s.title || '未命名会话' }}</div>
<div class="ai-session-meta">{{ s.turns }} · {{ formatTime(s.updated) }}</div>
</div>
<button class="ai-session-del" title="删除" @click.stop="onDeleteSession(s.id)">
<el-icon><Delete /></el-icon>
</button>
</li>
</ul>
</div>
</header>
<div ref="listRef" class="ai-body" @click="showSessions = false">
<div v-if="messages.length === 0" class="ai-empty">
<div class="ai-empty-glyph"></div>
<div class="ai-empty-title">我帮你做故障 / 日志 / 数据分析</div>
<div class="ai-empty-tip">试着这样问</div>
<button v-for="(ex, i) in examples" :key="i" class="ai-example" @click="useExample(ex)">
{{ ex }}
</button>
</div>
<div v-for="m in messages" :key="m.id" class="ai-msg" :class="`ai-msg--${m.role}`">
<div class="ai-bubble">
<div v-if="m.role === 'user'" class="ai-user-text">{{ m.content }}</div>
<template v-else>
<!-- 工具调用卡片 -->
<div v-if="m.tools.length" class="ai-tools">
<details v-for="(t, ti) in m.tools" :key="ti" class="ai-tool" :class="`is-${t.status}`">
<summary class="ai-tool-sum">
<span class="ai-tool-ic">
<el-icon v-if="t.status === 'running'" class="is-loading"><Loading /></el-icon>
<el-icon v-else-if="t.status === 'ok'"><Select /></el-icon>
<el-icon v-else><WarningFilled /></el-icon>
</span>
<span class="ai-tool-name">{{ t.name }}</span>
<span class="ai-tool-badge" :class="t.isWrite ? 'write' : 'read'">{{ t.isWrite ? '写' : '读' }}</span>
</summary>
<div class="ai-tool-body">
<div v-if="t.args !== undefined" class="ai-tool-kv">
<span class="ai-tool-k">参数</span>
<pre class="ai-tool-pre">{{ toJson(t.args) }}</pre>
</div>
<div v-if="t.result !== undefined" class="ai-tool-kv">
<span class="ai-tool-k">结果</span>
<pre class="ai-tool-pre">{{ toJson(t.result) }}</pre>
</div>
</div>
</details>
</div>
<!-- 助手正文Markdown -->
<div v-if="m.content" class="ai-md" v-html="render(m.content)" />
<span v-if="m.streaming && m.content" class="ai-caret" />
<div v-if="m.streaming && !m.content && m.tools.length === 0" class="ai-thinking">
<el-icon class="is-loading"><Loading /></el-icon><span>正在思考</span>
</div>
<!-- 错误 -->
<div v-if="m.error" class="ai-error">
<el-icon><WarningFilled /></el-icon>
<span>{{ m.error }}</span>
</div>
</template>
</div>
</div>
</div>
<footer class="ai-foot">
<el-input
v-model="draft"
type="textarea"
:rows="2"
:autosize="{ minRows: 2, maxRows: 6 }"
resize="none"
placeholder="描述你想分析的问题,例如:分析当前报警车辆的原因(Enter 发送,Shift+Enter 换行)"
@keydown="onKeydown"
/>
<div class="ai-foot-row">
<span class="ai-foot-hint">{{ isStreaming ? 'AI 正在响应…' : '故障 / 日志 / 数据分析 · 操作答疑(只读)' }}</span>
<el-button v-if="isStreaming" type="danger" plain size="small" @click="onStop">停止</el-button>
<el-button v-else type="primary" size="small" :disabled="!draft.trim()" @click="onSend">发送</el-button>
</div>
</footer>
</section>
</template>
<script setup lang="ts">
import { ref, watch, nextTick, onMounted } from 'vue'
import {
Plus,
Close,
Delete,
Loading,
Select,
WarningFilled,
ChatLineSquare
} from '@element-plus/icons-vue'
import { ElMessageBox } from 'element-plus'
import { renderMarkdown } from '@/utils/miniMarkdown'
import { useAssistantChat } from '@/composables/useAssistantChat'
const OPEN_KEY = 'assistant.drawer.open'
const WIDTH_KEY = 'assistant.drawer.width'
const MIN_W = 340
const MAX_W = 760
const { messages, sessions, currentSessionId, isStreaming, ensureInit, loadSession, newSession, removeSession, send, stop } =
useAssistantChat()
const open = ref(localStorage.getItem(OPEN_KEY) === '1')
const width = ref(clampWidth(Number(localStorage.getItem(WIDTH_KEY)) || 420))
const draft = ref('')
const showSessions = ref(false)
const listRef = ref<HTMLElement | null>(null)
const examples = [
'现在有哪些车辆在报警?帮我分析原因和处理建议',
'汇总最近的诊断日志,有哪些异常?',
'分析一下当前车队的健康状况',
'1 号车为什么停了?帮我排查'
]
function clampWidth(w: number): number {
return Math.min(MAX_W, Math.max(MIN_W, w))
}
function toggle(v: boolean): void {
open.value = v
try {
localStorage.setItem(OPEN_KEY, v ? '1' : '0')
} catch {
/* ignore */
}
if (v) void ensureInit()
}
function toggleSessions(): void {
showSessions.value = !showSessions.value
}
function onNew(): void {
newSession()
showSessions.value = false
draft.value = ''
}
async function onPickSession(id: string): Promise<void> {
showSessions.value = false
if (id === currentSessionId.value) return
await loadSession(id)
void scrollToBottom()
}
async function onDeleteSession(id: string): Promise<void> {
try {
await ElMessageBox.confirm('确定删除该会话?', '删除会话', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消'
})
} catch {
return
}
await removeSession(id)
}
function useExample(ex: string): void {
draft.value = ex
}
function onKeydown(e: Event | KeyboardEvent): void {
if (!(e instanceof KeyboardEvent)) return
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
e.preventDefault()
void onSend()
}
}
async function onSend(): Promise<void> {
const text = draft.value.trim()
if (!text || isStreaming.value) return
draft.value = ''
void scrollToBottom()
await send(text)
}
function onStop(): void {
stop()
}
function toJson(v: unknown): string {
try {
const s = JSON.stringify(v, null, 2)
return s.length > 4000 ? s.slice(0, 4000) + '\n… (已截断)' : s
} catch {
return String(v)
}
}
function render(s: string): string {
return renderMarkdown(s)
}
function formatTime(iso: string): string {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return ''
const pad = (n: number) => String(n).padStart(2, '0')
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
async function scrollToBottom(): Promise<void> {
await nextTick()
const el = listRef.value
if (el) el.scrollTop = el.scrollHeight
}
// 流式更新 / 新消息时自动滚到底。
watch(
messages,
() => {
void scrollToBottom()
},
{ deep: true }
)
// ── 拖拽调整宽度(左沿手柄向左拖变宽)──
let resizing = false
let startX = 0
let startW = 0
function onResizeStart(e: PointerEvent): void {
resizing = true
startX = e.clientX
startW = width.value
;(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId)
e.preventDefault()
}
function onResizeMove(e: PointerEvent): void {
if (!resizing) return
width.value = clampWidth(startW + (startX - e.clientX))
}
function onResizeEnd(e: PointerEvent): void {
if (!resizing) return
resizing = false
;(e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId)
try {
localStorage.setItem(WIDTH_KEY, String(width.value))
} catch {
/* ignore */
}
}
onMounted(() => {
if (open.value) void ensureInit()
})
</script>
<style scoped>
/* ─────────────── FAB ─────────────── */
.ai-fab {
position: fixed;
right: 22px;
bottom: 28px;
z-index: 1800;
width: 52px;
height: 52px;
border-radius: 50%;
border: 1px solid rgba(var(--mg-accent-rgb, 196, 181, 253), 0.5);
background: linear-gradient(135deg, var(--mg-primary, #7c3aed) 0%, var(--mg-primary-active, #5b21b6) 100%);
color: #fff;
cursor: pointer;
box-shadow: 0 10px 28px rgba(91, 33, 182, 0.5), 0 0 0 0 rgba(124, 58, 237, 0.5);
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.2s ease, box-shadow 0.2s ease;
animation: ai-fab-pulse 3.6s ease-in-out infinite;
}
.ai-fab:hover {
transform: translateY(-2px) scale(1.06);
box-shadow: 0 14px 36px rgba(124, 58, 237, 0.6);
}
.ai-fab-glyph {
font-size: 24px;
text-shadow: 0 0 12px rgba(255, 255, 255, 0.6);
}
@keyframes ai-fab-pulse {
0%, 100% { box-shadow: 0 10px 28px rgba(91, 33, 182, 0.5), 0 0 0 0 rgba(124, 58, 237, 0.45); }
50% { box-shadow: 0 10px 28px rgba(91, 33, 182, 0.5), 0 0 0 12px rgba(124, 58, 237, 0); }
}
/* ─────────────── Drawer ─────────────── */
.ai-drawer {
position: fixed;
top: 0;
right: 0;
bottom: 0;
z-index: 1900;
display: flex;
flex-direction: column;
max-width: 96vw;
background: linear-gradient(180deg, rgba(28, 14, 56, 0.99) 0%, rgba(16, 7, 34, 0.99) 100%);
border-left: 1px solid rgba(var(--mg-accent-rgb, 196, 181, 253), 0.28);
box-shadow: -14px 0 40px rgba(8, 2, 16, 0.6);
color: rgba(236, 224, 250, 0.95);
transform: translateX(100%);
opacity: 0;
visibility: hidden;
transition: transform 0.28s cubic-bezier(0.25, 0.8, 0.25, 1), opacity 0.28s ease, visibility 0.28s;
}
.ai-drawer.is-open {
transform: translateX(0);
opacity: 1;
visibility: visible;
}
.ai-resizer {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 6px;
cursor: ew-resize;
z-index: 5;
touch-action: none;
transition: background 0.15s ease;
}
.ai-resizer:hover {
background: rgba(var(--mg-accent-rgb, 196, 181, 253), 0.45);
}
/* ─────────────── Header ─────────────── */
.ai-head {
position: relative;
flex: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px 14px;
background: linear-gradient(135deg, rgba(120, 70, 220, 0.55) 0%, rgba(168, 85, 247, 0.4) 100%);
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
}
.ai-head-title { display: flex; align-items: center; gap: 10px; min-width: 0; }
.ai-head-glyph { font-size: 19px; color: #fff; text-shadow: 0 0 10px rgba(255, 200, 250, 0.7); flex: none; }
.ai-head-text { min-width: 0; }
.ai-head-main { font-size: 15px; font-weight: 700; color: #fff; line-height: 1.2; }
.ai-head-sub { font-size: 11px; color: rgba(240, 222, 255, 0.78); margin-top: 2px; white-space: nowrap; }
.ai-head-actions { display: flex; align-items: center; gap: 6px; flex: none; }
.ai-iconbtn {
appearance: none;
border: 0;
background: rgba(255, 255, 255, 0.12);
color: #fff;
width: 28px;
height: 28px;
border-radius: 7px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 14px;
transition: background 0.15s ease;
}
.ai-iconbtn:hover, .ai-iconbtn.active { background: rgba(255, 255, 255, 0.26); }
/* Sessions popover */
.ai-sessions {
position: absolute;
top: 56px;
right: 12px;
width: 280px;
max-height: 360px;
overflow: auto;
background: rgba(22, 11, 44, 0.99);
border: 1px solid rgba(var(--mg-accent-rgb, 196, 181, 253), 0.3);
border-radius: 12px;
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.5);
z-index: 20;
padding: 8px;
}
.ai-sessions-head {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
color: rgba(210, 188, 240, 0.8);
padding: 4px 6px 8px;
}
.ai-link { appearance: none; border: 0; background: transparent; color: var(--mg-accent, #c4b5fd); cursor: pointer; font-size: 12px; }
.ai-sessions-empty { padding: 16px; text-align: center; color: rgba(210, 188, 240, 0.5); font-size: 12px; }
.ai-sessions-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
.ai-session-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 8px;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s ease;
}
.ai-session-item:hover { background: rgba(255, 255, 255, 0.06); }
.ai-session-item.active { background: rgba(124, 58, 237, 0.28); }
.ai-session-main { min-width: 0; flex: 1; }
.ai-session-title { font-size: 13px; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ai-session-meta { font-size: 11px; color: rgba(210, 188, 240, 0.6); margin-top: 2px; }
.ai-session-del {
appearance: none; border: 0; background: transparent; color: rgba(255, 160, 180, 0.7);
cursor: pointer; width: 24px; height: 24px; border-radius: 6px; flex: none;
display: inline-flex; align-items: center; justify-content: center;
}
.ai-session-del:hover { background: rgba(255, 90, 110, 0.2); color: #ff8aa0; }
/* ─────────────── Body ─────────────── */
.ai-body {
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
padding: 14px;
display: flex;
flex-direction: column;
gap: 12px;
scrollbar-width: thin;
scrollbar-color: rgba(var(--mg-accent-rgb, 196, 181, 253), 0.3) transparent;
}
.ai-body::-webkit-scrollbar { width: 6px; }
.ai-body::-webkit-scrollbar-thumb { background: rgba(var(--mg-accent-rgb, 196, 181, 253), 0.28); border-radius: 3px; }
.ai-empty { text-align: center; padding: 28px 12px; display: flex; flex-direction: column; align-items: center; gap: 8px; }
.ai-empty-glyph { font-size: 34px; color: var(--mg-accent, #c4b5fd); text-shadow: 0 0 20px rgba(196, 181, 253, 0.5); }
.ai-empty-title { font-size: 15px; font-weight: 600; color: #fff; }
.ai-empty-tip { font-size: 12px; color: rgba(210, 188, 240, 0.65); margin-top: 4px; }
.ai-example {
appearance: none;
width: 100%;
text-align: left;
border: 1px dashed rgba(var(--mg-accent-rgb, 196, 181, 253), 0.4);
background: rgba(255, 255, 255, 0.04);
color: rgba(232, 215, 245, 0.92);
border-radius: 9px;
padding: 9px 11px;
font-size: 12.5px;
cursor: pointer;
transition: all 0.15s ease;
}
.ai-example:hover { background: rgba(150, 90, 230, 0.22); border-color: rgba(var(--mg-accent-rgb, 196, 181, 253), 0.7); color: #fff; }
.ai-msg { display: flex; }
.ai-msg--user { justify-content: flex-end; }
.ai-msg--assistant { justify-content: flex-start; }
.ai-bubble {
max-width: 90%;
padding: 10px 12px;
border-radius: 13px;
font-size: 13.5px;
line-height: 1.6;
}
.ai-msg--user .ai-bubble {
background: linear-gradient(135deg, rgba(150, 90, 240, 0.96) 0%, rgba(120, 70, 220, 0.96) 100%);
color: #fff;
border-bottom-right-radius: 4px;
}
.ai-msg--assistant .ai-bubble {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
color: rgba(236, 224, 250, 0.96);
border-bottom-left-radius: 4px;
}
.ai-user-text { white-space: pre-wrap; word-break: break-word; }
.ai-thinking { display: inline-flex; align-items: center; gap: 8px; color: rgba(210, 188, 240, 0.85); font-size: 13px; }
.ai-caret {
display: inline-block;
width: 7px;
height: 15px;
margin-left: 2px;
vertical-align: text-bottom;
background: var(--mg-accent, #c4b5fd);
animation: ai-blink 1s steps(2) infinite;
}
@keyframes ai-blink { 0%, 50% { opacity: 1; } 50.01%, 100% { opacity: 0; } }
.ai-error {
display: flex;
align-items: flex-start;
gap: 6px;
margin-top: 8px;
padding: 8px 10px;
border-radius: 8px;
background: rgba(255, 90, 110, 0.14);
border: 1px solid rgba(255, 90, 110, 0.35);
color: #ffb3c0;
font-size: 12.5px;
}
/* ─────────────── Tool cards ─────────────── */
.ai-tools { display: flex; flex-direction: column; gap: 6px; margin-bottom: 8px; }
.ai-tool {
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 9px;
background: rgba(0, 0, 0, 0.22);
overflow: hidden;
}
.ai-tool.is-running { border-color: rgba(196, 181, 253, 0.5); }
.ai-tool.is-ok { border-color: rgba(80, 220, 160, 0.4); }
.ai-tool.is-error { border-color: rgba(255, 120, 120, 0.5); }
.ai-tool-sum {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
cursor: pointer;
list-style: none;
font-size: 12.5px;
}
.ai-tool-sum::-webkit-details-marker { display: none; }
.ai-tool-ic { display: inline-flex; align-items: center; color: rgba(220, 205, 250, 0.9); }
.ai-tool.is-ok .ai-tool-ic { color: #4fe0a0; }
.ai-tool.is-error .ai-tool-ic { color: #ff8a8a; }
.ai-tool-name { font-family: var(--mg-font-mono, monospace); color: #fff; font-weight: 600; flex: 1; min-width: 0; }
.ai-tool-badge {
flex: none;
font-size: 10px;
padding: 1px 7px;
border-radius: 999px;
font-weight: 600;
}
.ai-tool-badge.read { background: rgba(96, 165, 250, 0.2); color: #93c5fd; border: 1px solid rgba(96, 165, 250, 0.4); }
.ai-tool-badge.write { background: rgba(251, 146, 60, 0.2); color: #fdba74; border: 1px solid rgba(251, 146, 60, 0.45); }
.ai-tool-body { padding: 0 10px 8px; }
.ai-tool-kv { margin-top: 6px; }
.ai-tool-k { font-size: 11px; color: rgba(210, 188, 240, 0.65); }
.ai-tool-pre {
margin: 3px 0 0;
padding: 7px 9px;
background: rgba(0, 0, 0, 0.35);
border-radius: 7px;
font-size: 11.5px;
font-family: var(--mg-font-mono, monospace);
color: rgba(220, 230, 245, 0.92);
white-space: pre-wrap;
word-break: break-word;
max-height: 220px;
overflow: auto;
}
/* ─────────────── Markdown ─────────────── */
.ai-md :deep(p) { margin: 0 0 8px; }
.ai-md :deep(p:last-child) { margin-bottom: 0; }
.ai-md :deep(.mmd-h) { margin: 10px 0 6px; font-weight: 700; color: #fff; line-height: 1.3; }
.ai-md :deep(h3.mmd-h) { font-size: 15px; }
.ai-md :deep(h4.mmd-h) { font-size: 14px; }
.ai-md :deep(h5.mmd-h) { font-size: 13px; }
.ai-md :deep(.mmd-ul), .ai-md :deep(.mmd-ol) { margin: 4px 0 8px; padding-left: 20px; }
.ai-md :deep(li) { margin: 2px 0; }
.ai-md :deep(.mmd-code) {
font-family: var(--mg-font-mono, monospace);
font-size: 12px;
background: rgba(0, 0, 0, 0.4);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 5px;
padding: 1px 5px;
}
.ai-md :deep(.mmd-pre) {
margin: 6px 0;
padding: 10px 12px;
background: rgba(0, 0, 0, 0.42);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 9px;
overflow: auto;
max-height: 320px;
}
.ai-md :deep(.mmd-pre code) {
font-family: var(--mg-font-mono, monospace);
font-size: 12px;
color: rgba(220, 230, 245, 0.95);
white-space: pre;
}
.ai-md :deep(.mmd-link) { color: var(--mg-accent, #c4b5fd); text-decoration: underline; }
.ai-md :deep(.mmd-quote) {
margin: 6px 0;
padding: 4px 12px;
border-left: 3px solid rgba(var(--mg-accent-rgb, 196, 181, 253), 0.6);
color: rgba(220, 205, 250, 0.85);
}
.ai-md :deep(strong) { color: #fff; }
/* ─────────────── Footer ─────────────── */
.ai-foot {
flex: none;
padding: 10px 12px 12px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
flex-direction: column;
gap: 8px;
}
.ai-foot :deep(.el-textarea__inner) {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(var(--mg-accent-rgb, 196, 181, 253), 0.25);
color: #fff;
box-shadow: none;
}
.ai-foot :deep(.el-textarea__inner:focus) {
border-color: rgba(var(--mg-accent-rgb, 196, 181, 253), 0.6);
}
.ai-foot-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.ai-foot-hint { font-size: 11px; color: rgba(210, 188, 240, 0.6); }
@media (max-width: 560px) {
.ai-drawer { width: 100vw !important; }
}
</style>
@@ -51,9 +51,11 @@
:class="`aap-msg--${m.role}`"
>
<div class="aap-bubble">
<div class="aap-bubble-text">{{ m.text }}</div>
<div v-if="m.role === 'user'" class="aap-bubble-text">{{ m.text }}</div>
<div v-else class="aap-md" v-html="renderMd(m.text)"></div>
<div v-if="m.role === 'assistant' && m.meta" class="aap-meta">
落地对象 <b>{{ m.meta.created }}</b> · 工具调用 <b>{{ m.meta.usedTools }}</b>
<el-icon class="aap-meta-ic"><CircleCheck /></el-icon>
已落地 <b>{{ m.meta.created }}</b> 个对象 · 工具调用 <b>{{ m.meta.usedTools }}</b>
</div>
</div>
</div>
@@ -99,10 +101,11 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { Loading } from '@element-plus/icons-vue'
import { Loading, CircleCheck } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { mapEditApi, type AiMapGenerateRequest, type AiMapGenerateResult } from '@/api/mapEdit'
import { renderMarkdown } from '@/utils/miniMarkdown'
const props = defineProps<{
/** 面板是否展开(停靠在右侧)。 */
@@ -198,6 +201,10 @@ function useExample(ex: string) {
draft.value = ex
}
function renderMd(s: string): string {
return renderMarkdown(s)
}
function onKeydown(e: Event | KeyboardEvent) {
// el-input 的 keydown 事件签名是 Event | KeyboardEvent,这里收窄到键盘事件。
// Enter 发送、Shift+Enter 换行;中文输入法组合期间(isComposing)不触发发送。
@@ -387,6 +394,48 @@ async function send() {
color: rgba(210, 188, 240, 0.8);
}
.aap-meta b { color: var(--mg-accent, #c4a4ff); }
.aap-meta-ic { color: #6ee7b7; margin-right: 4px; vertical-align: -2px; }
/* assistant 消息 Markdown 渲染 */
.aap-md { font-size: 13px; line-height: 1.6; word-break: break-word; color: rgba(236, 224, 250, 0.95); }
.aap-md :deep(p) { margin: 0 0 7px; }
.aap-md :deep(p:last-child) { margin-bottom: 0; }
.aap-md :deep(.mmd-h) { margin: 8px 0 5px; font-weight: 700; color: #fff; }
.aap-md :deep(h3.mmd-h) { font-size: 14px; }
.aap-md :deep(h4.mmd-h) { font-size: 13px; }
.aap-md :deep(.mmd-ul), .aap-md :deep(.mmd-ol) { margin: 4px 0 7px; padding-left: 18px; }
.aap-md :deep(li) { margin: 2px 0; }
.aap-md :deep(.mmd-code) {
font-family: var(--mg-font-mono, monospace);
font-size: 12px;
background: rgba(0, 0, 0, 0.4);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 5px;
padding: 1px 5px;
}
.aap-md :deep(.mmd-pre) {
margin: 6px 0;
padding: 9px 11px;
background: rgba(0, 0, 0, 0.42);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
overflow: auto;
max-height: 280px;
}
.aap-md :deep(.mmd-pre code) {
font-family: var(--mg-font-mono, monospace);
font-size: 12px;
color: rgba(220, 230, 245, 0.95);
white-space: pre;
}
.aap-md :deep(.mmd-link) { color: var(--mg-accent, #c4a4ff); text-decoration: underline; }
.aap-md :deep(.mmd-quote) {
margin: 6px 0;
padding: 3px 10px;
border-left: 3px solid rgba(190, 140, 240, 0.6);
color: rgba(220, 205, 250, 0.85);
}
.aap-md :deep(strong) { color: #fff; }
.aap-bubble--loading {
display: inline-flex;
align-items: center;
@@ -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
}
}
@@ -0,0 +1,136 @@
/**
* 极简、零依赖、先转义后渲染的 Markdown→HTML。仅覆盖聊天场景常用语法:
* 标题 / 粗斜体 / 行内代码 / 围栏代码块 / 有序无序列表 / 引用 / 链接 / 段落与换行。
* 安全策略:所有文本先 HTML 转义,仅注入我们自己生成的标签;链接仅允许 http(s)/相对/锚点。
* 如需完整 CommonMark,可后续替换为 markdown-it(届时记得保留 XSS 防护)。
*/
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function safeUrl(url: string): string | null {
const u = url.trim()
if (/^https?:\/\//i.test(u) || u.startsWith('/') || u.startsWith('#')) return u
return null
}
/** 行内格式:行内代码先占位保护,再处理链接/粗体/斜体,最后还原代码。入参须已 HTML 转义。 */
function inline(escaped: string): string {
const codes: string[] = []
let s = escaped.replace(/`([^`]+)`/g, (_m, c: string) => {
codes.push(`<code class="mmd-code">${c}</code>`)
return `\u0001${codes.length - 1}\u0001`
})
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, text: string, url: string) => {
const safe = safeUrl(url)
if (!safe) return `[${text}](${url})`
return `<a class="mmd-link" href="${safe}" target="_blank" rel="noopener noreferrer">${text}</a>`
})
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
s = s.replace(/__([^_]+)__/g, '<strong>$1</strong>')
s = s.replace(/(^|[^*])\*([^*\s][^*]*)\*/g, '$1<em>$2</em>')
s = s.replace(/(^|[^_])_([^_\s][^_]*)_/g, '$1<em>$2</em>')
s = s.replace(/\u0001(\d+)\u0001/g, (_m, i: string) => codes[Number(i)] ?? '')
return s
}
export function renderMarkdown(src: string): string {
if (!src) return ''
const text = src.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
const blocks: string[] = []
const withTokens = text.replace(/```([^\n]*)\n([\s\S]*?)```/g, (_m, lang: string, code: string) => {
const cls = (lang || '').trim()
const body = escapeHtml(code.replace(/\n$/, ''))
const clsAttr = cls ? ` class="language-${escapeHtml(cls)}"` : ''
blocks.push(`<pre class="mmd-pre"><code${clsAttr}>${body}</code></pre>`)
return `\u0000${blocks.length - 1}\u0000`
})
const lines = withTokens.split('\n')
const out: string[] = []
let para: string[] = []
let i = 0
const flushPara = (): void => {
if (para.length) {
out.push(`<p>${inline(escapeHtml(para.join(' ')))}</p>`)
para = []
}
}
while (i < lines.length) {
const line = lines[i]
const codeToken = line.match(/^\u0000(\d+)\u0000\s*$/)
if (codeToken) {
flushPara()
out.push(blocks[Number(codeToken[1])] ?? '')
i++
continue
}
if (/^\s*$/.test(line)) {
flushPara()
i++
continue
}
const h = line.match(/^(#{1,6})\s+(.*)$/)
if (h) {
flushPara()
const lvl = h[1].length
const tag = lvl <= 2 ? 'h3' : lvl === 3 ? 'h4' : 'h5'
out.push(`<${tag} class="mmd-h">${inline(escapeHtml(h[2]))}</${tag}>`)
i++
continue
}
if (/^\s*>\s?/.test(line)) {
flushPara()
const items: string[] = []
while (i < lines.length && /^\s*>\s?/.test(lines[i])) {
items.push(lines[i].replace(/^\s*>\s?/, ''))
i++
}
out.push(`<blockquote class="mmd-quote">${inline(escapeHtml(items.join(' ')))}</blockquote>`)
continue
}
if (/^\s*[-*+]\s+/.test(line)) {
flushPara()
const items: string[] = []
while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i])) {
items.push(lines[i].replace(/^\s*[-*+]\s+/, ''))
i++
}
out.push(`<ul class="mmd-ul">${items.map((it) => `<li>${inline(escapeHtml(it))}</li>`).join('')}</ul>`)
continue
}
if (/^\s*\d+\.\s+/.test(line)) {
flushPara()
const items: string[] = []
while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
items.push(lines[i].replace(/^\s*\d+\.\s+/, ''))
i++
}
out.push(`<ol class="mmd-ol">${items.map((it) => `<li>${inline(escapeHtml(it))}</li>`).join('')}</ol>`)
continue
}
para.push(line)
i++
}
flushPara()
return out.join('\n')
}