新增平台 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`"
|
:class="`aap-msg--${m.role}`"
|
||||||
>
|
>
|
||||||
<div class="aap-bubble">
|
<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">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -99,10 +101,11 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
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 { ElMessage } from 'element-plus'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { mapEditApi, type AiMapGenerateRequest, type AiMapGenerateResult } from '@/api/mapEdit'
|
import { mapEditApi, type AiMapGenerateRequest, type AiMapGenerateResult } from '@/api/mapEdit'
|
||||||
|
import { renderMarkdown } from '@/utils/miniMarkdown'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
/** 面板是否展开(停靠在右侧)。 */
|
/** 面板是否展开(停靠在右侧)。 */
|
||||||
@@ -198,6 +201,10 @@ function useExample(ex: string) {
|
|||||||
draft.value = ex
|
draft.value = ex
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderMd(s: string): string {
|
||||||
|
return renderMarkdown(s)
|
||||||
|
}
|
||||||
|
|
||||||
function onKeydown(e: Event | KeyboardEvent) {
|
function onKeydown(e: Event | KeyboardEvent) {
|
||||||
// el-input 的 keydown 事件签名是 Event | KeyboardEvent,这里收窄到键盘事件。
|
// el-input 的 keydown 事件签名是 Event | KeyboardEvent,这里收窄到键盘事件。
|
||||||
// Enter 发送、Shift+Enter 换行;中文输入法组合期间(isComposing)不触发发送。
|
// Enter 发送、Shift+Enter 换行;中文输入法组合期间(isComposing)不触发发送。
|
||||||
@@ -387,6 +394,48 @@ async function send() {
|
|||||||
color: rgba(210, 188, 240, 0.8);
|
color: rgba(210, 188, 240, 0.8);
|
||||||
}
|
}
|
||||||
.aap-meta b { color: var(--mg-accent, #c4a4ff); }
|
.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 {
|
.aap-bubble--loading {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
}
|
||||||
|
|
||||||
|
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')
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user