You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1530 lines
55 KiB
1530 lines
55 KiB
<template>
|
|
<div class="sdk-test">
|
|
<!-- ==================== 左侧配置面板 ==================== -->
|
|
<aside class="sdk-panel">
|
|
<div class="sdk-panel-header">
|
|
<div class="sdk-panel-links">
|
|
<a href="/index.html">← 返回管理后台</a>
|
|
<a href="/doc.html" target="_blank">API 文档 ↗</a>
|
|
</div>
|
|
<h1>🧪 ChatbotSDK 测试面板</h1>
|
|
<p>P0 核心 · P1 体验增强 · P2 运营完善</p>
|
|
</div>
|
|
|
|
<div class="sdk-panel-body">
|
|
<!-- 状态标签 -->
|
|
<div class="sdk-status-row">
|
|
<t-tag :theme="sdkReady ? 'success' : 'default'" variant="light">SDK {{ sdkReady ? '已加载' : '未加载' }}</t-tag>
|
|
<t-tag :theme="sdkToken ? 'success' : 'default'" variant="light">{{ sdkToken ? 'Token 已获取' : 'Token 未获取' }}</t-tag>
|
|
<t-tag :theme="backendOnline === null ? 'default' : backendOnline ? 'success' : 'danger'" variant="light">
|
|
{{ backendOnline === null ? '后端检测中…' : backendOnline ? '后端在线' : '后端离线' }}
|
|
</t-tag>
|
|
</div>
|
|
|
|
<!-- 参数映射说明 -->
|
|
<div class="mapping-note">
|
|
<b>📋 参数映射(SDK → 后端)</b><br />
|
|
integrateId → <b>roleId</b>(客服角色 ID)<br />
|
|
userId → <b>accountId</b>(外部用户ID)<br />
|
|
chatId → <b>自动管理</b>(从 /conversation/list 获取或自动生成)
|
|
</div>
|
|
|
|
<!-- SDK Token 鉴权 -->
|
|
<div class="token-box">
|
|
<div class="token-title">🔐 SDK Token 鉴权</div>
|
|
<div class="cfg-field">
|
|
<label>API Key(用于换取 Token)</label>
|
|
<div class="inline-flex">
|
|
<t-input v-model="apiKey" placeholder="sk_xxxx..." size="small" />
|
|
<t-button theme="primary" size="small" :loading="fetchingToken" @click="fetchToken">获取 Token</t-button>
|
|
</div>
|
|
<div class="hint">调用 POST /open-api/auth/token 换取 SDK Token</div>
|
|
</div>
|
|
<div class="cfg-field">
|
|
<label>SDK Token(自动填充或手动粘贴)</label>
|
|
<t-input v-model="sdkToken" placeholder="eyJhbGciOi...(获取后自动填充)" size="small" />
|
|
<div class="hint">Token 只存内存、不落盘;开启自动刷新后 SDK 会在临期前自行续期</div>
|
|
</div>
|
|
<div class="cfg-field">
|
|
<label>Token 有效期 TTL(秒,后端范围 300 ~ 86400)</label>
|
|
<t-input-number v-model="tokenTtl" :min="300" :max="86400" size="small" />
|
|
<div class="hint">设为 300 后开启自动刷新,约 4.5 分钟后可观察到 SDK 自动续期</div>
|
|
</div>
|
|
<div class="cfg-switch">
|
|
<span>启用自动刷新(向 SDK 传 getToken / tokenUrl)</span>
|
|
<t-switch v-model="autoRefresh" size="small" />
|
|
</div>
|
|
<div class="cfg-field">
|
|
<label>取 Token 时机(tokenStrategy)</label>
|
|
<t-radio-group v-model="tokenStrategy" variant="default-filled" size="small">
|
|
<t-radio-button value="expiry">expiry(临期 / 401 才取)</t-radio-button>
|
|
<t-radio-button value="always">always(每请求前都取)</t-radio-button>
|
|
</t-radio-group>
|
|
<div class="hint">
|
|
always 模式下每条消息发出前都会取一次 Token(Network 里可见),且不再依赖上方 TTL
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 参数表单 -->
|
|
<div class="cfg-field">
|
|
<label>integrateId → roleId(必传,客服角色 ID)</label>
|
|
<t-input v-model="config.integrateId" placeholder="客服角色 ID" size="small" />
|
|
<div class="hint">对应后端 AiController 的 roleId 参数</div>
|
|
</div>
|
|
<div class="cfg-field">
|
|
<label>requestDomain(必传)</label>
|
|
<t-input v-model="config.requestDomain" placeholder="http://localhost:9090" size="small" />
|
|
</div>
|
|
<div class="cfg-field">
|
|
<label>userId → accountId(可选,外部用户ID)</label>
|
|
<t-input v-model="config.userId" placeholder="外部用户ID(如 hanlin)" size="small" />
|
|
<div class="hint">对应后端 AiController 的 accountId 参数</div>
|
|
</div>
|
|
|
|
<div class="cfg-row">
|
|
<div class="cfg-field">
|
|
<label>title</label>
|
|
<t-input v-model="config.title" placeholder="弹窗标题" size="small" />
|
|
</div>
|
|
<div class="cfg-field">
|
|
<label>primaryColor</label>
|
|
<div class="color-wrap">
|
|
<input type="color" v-model="config.primaryColor" class="color-input" />
|
|
<span class="color-hex">{{ config.primaryColor }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="cfg-row">
|
|
<div class="cfg-field">
|
|
<label>position</label>
|
|
<t-select v-model="config.position" :options="positionOptions" size="small" />
|
|
</div>
|
|
<div class="cfg-field">
|
|
<label>theme 主题模式</label>
|
|
<t-select v-model="config.theme" :options="themeOptions" size="small" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="cfg-row">
|
|
<div class="cfg-field">
|
|
<label>width (px)</label>
|
|
<t-input-number v-model="config.width" :min="300" :max="1200" size="small" />
|
|
</div>
|
|
<div class="cfg-field">
|
|
<label>height (px)</label>
|
|
<t-input-number v-model="config.height" :min="300" :max="1200" size="small" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="cfg-row">
|
|
<div class="cfg-field">
|
|
<label>locale</label>
|
|
<t-select v-model="config.locale" :options="localeOptions" size="small" />
|
|
</div>
|
|
<div class="cfg-field">
|
|
<label>watermark 水印文字</label>
|
|
<t-input v-model="config.watermark" placeholder="留空则不显示" size="small" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="cfg-switch-grid">
|
|
<div class="cfg-switch"><span>resizable 可缩放</span><t-switch v-model="config.resizable" size="small" /></div>
|
|
<div class="cfg-switch"><span>streaming 流式</span><t-switch v-model="config.streaming" size="small" /></div>
|
|
<div class="cfg-switch"><span>showCategorySwitch</span><t-switch v-model="config.showCategorySwitch" size="small" /></div>
|
|
<div class="cfg-switch"><span>enableRag 知识库</span><t-switch v-model="config.enableRag" size="small" /></div>
|
|
<div class="cfg-switch"><span>debug 日志</span><t-switch v-model="config.debug" size="small" /></div>
|
|
<div class="cfg-switch"><span>showClear 清空</span><t-switch v-model="config.showClear" size="small" /></div>
|
|
</div>
|
|
|
|
<!-- SDK 操作 -->
|
|
<div class="cfg-actions">
|
|
<t-button theme="primary" block :disabled="!sdkToken || !sdkReady" @click="doInit">🚀 初始化 SDK</t-button>
|
|
</div>
|
|
<div class="cfg-actions">
|
|
<t-button variant="outline" size="small" @click="domAction('open')">👁 打开窗口</t-button>
|
|
<t-button variant="outline" size="small" @click="domAction('close')">🙈 关闭窗口</t-button>
|
|
<t-button variant="outline" size="small" @click="domAction('toggle')">🔄 切换</t-button>
|
|
</div>
|
|
<div class="cfg-actions">
|
|
<t-button variant="outline" size="small" @click="doClearHistory">🗑 清空对话</t-button>
|
|
<t-button theme="danger" variant="outline" size="small" @click="doDestroy">💥 销毁 SDK</t-button>
|
|
</div>
|
|
|
|
<div class="divider"></div>
|
|
|
|
<div class="tips">
|
|
<b>💡 操作提示</b><br />
|
|
1. integrateId 填写客服角色 ID(数字,对应后端 roleId)<br />
|
|
2. userId 填写外部用户ID(对应后端 accountId)<br />
|
|
3. chatId 自动管理:从 /conversation/list 获取或自动生成<br />
|
|
4. SDK 默认启用 RAG 检索;开启 showCategorySwitch 可选择知识库分类
|
|
</div>
|
|
|
|
<div class="divider"></div>
|
|
|
|
<!-- 接入代码生成器 -->
|
|
<div class="code-section">
|
|
<div class="code-header">
|
|
<span class="code-title">📋 接入代码</span>
|
|
<div class="code-header-actions">
|
|
<t-radio-group v-model="codeTab" variant="default-filled" size="small">
|
|
<t-radio-button value="snippet">Script 片段</t-radio-button>
|
|
<t-radio-button value="fullpage">完整页面</t-radio-button>
|
|
</t-radio-group>
|
|
<t-button size="small" variant="outline" @click="copyCode">📋 复制代码</t-button>
|
|
</div>
|
|
</div>
|
|
<pre class="code-pre" v-html="highlightedCode"></pre>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
<!-- ==================== 右侧功能区 ==================== -->
|
|
<section class="sdk-main">
|
|
<div class="sdk-main-header">
|
|
<t-radio-group v-model="activeTab" variant="default-filled" size="medium">
|
|
<t-radio-button value="chat">💬 AI 对话演示</t-radio-button>
|
|
<t-radio-button value="sdk">🧪 SDK 集成验证</t-radio-button>
|
|
</t-radio-group>
|
|
</div>
|
|
|
|
<!-- 对话演示区 -->
|
|
<div v-show="activeTab === 'chat'" class="demo-area">
|
|
<div class="demo-toolbar">
|
|
<t-select v-model="demoMode" :options="modeOptions" size="small" class="demo-mode" />
|
|
<label class="demo-toggle">
|
|
<t-switch v-model="demoRag" size="small" />
|
|
<span>RAG 检索</span>
|
|
</label>
|
|
<t-select v-if="demoRag" v-model="demoStrategy" :options="strategyOptions" size="small" class="demo-strategy" />
|
|
<div class="demo-spacer"></div>
|
|
<t-button size="small" variant="outline" @click="clearDemo">清空对话</t-button>
|
|
</div>
|
|
|
|
<div v-if="!sdkToken" class="demo-warning">
|
|
⚠️ 请先在左侧「SDK Token 鉴权」中填入 API Key 并获取 Token,对话接口(/ai/chat*)需要 Bearer Token 鉴权。
|
|
</div>
|
|
|
|
<div class="demo-list" ref="demoListRef">
|
|
<chat-list :data="demoData" layout="both" :auto-scroll="false" class="demo-chat-list">
|
|
<template #content="{ index }">
|
|
<div class="demo-content">
|
|
<div
|
|
v-if="demoMessages[index]?.role === 'assistant' && demoMessages[index]?.content"
|
|
class="markdown-body"
|
|
v-html="renderMarkdown(demoMessages[index].content)"
|
|
></div>
|
|
<div v-else-if="demoMessages[index]?.role === 'assistant'" class="demo-thinking">正在思考…</div>
|
|
<div v-else class="demo-user-text">{{ demoMessages[index]?.content }}</div>
|
|
|
|
<!-- 引用来源 -->
|
|
<div v-if="demoMessages[index]?.role === 'assistant' && demoMessages[index]?.sources?.length" class="demo-sources">
|
|
<div class="demo-sources-title">📚 引用来源({{ demoMessages[index].sources.length }})</div>
|
|
<div v-for="(s, j) in demoMessages[index].sources" :key="j" class="demo-source-item">
|
|
<div class="demo-source-head">
|
|
<span class="demo-source-doc">📄 {{ s.title || s.sourceName || '文档 ' + j }}</span>
|
|
<span v-if="s.score !== undefined && s.score !== null" class="demo-source-score">距离 {{ Number(s.score).toFixed(3) }}</span>
|
|
</div>
|
|
<div v-if="s.snippet" class="demo-source-snippet">{{ s.snippet }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template #actions="{ index }">
|
|
<chat-actionbar
|
|
v-if="demoMessages[index]?.role === 'assistant' && demoMessages[index]?.content && !demoMessages[index]?.streaming"
|
|
:action-bar="['copy']"
|
|
@operation="(val) => onDemoAction(val, index)"
|
|
/>
|
|
</template>
|
|
</chat-list>
|
|
</div>
|
|
|
|
<div class="demo-composer">
|
|
<chat-sender
|
|
v-model="demoInput"
|
|
:loading="isSending"
|
|
:send-btn-disabled="!demoInput.trim()"
|
|
placeholder="输入问题,Enter 发送,Shift + Enter 换行"
|
|
:textarea-props="{ autosize: { minRows: 1, maxRows: 4 } }"
|
|
@send="(val) => sendDemo(val)"
|
|
@stop="abortDemo"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- SDK 集成验证区 -->
|
|
<div v-show="activeTab === 'sdk'" class="sdk-verify">
|
|
<div class="sdk-verify-toolbar">
|
|
<div class="sdk-overview">
|
|
<div class="sdk-stat"><div class="sdk-stat-num num-total">{{ totalTests }}</div><div class="sdk-stat-lbl">总用例数</div></div>
|
|
<div class="sdk-stat"><div class="sdk-stat-num num-ok">{{ passCount }}</div><div class="sdk-stat-lbl">通过</div></div>
|
|
<div class="sdk-stat"><div class="sdk-stat-num num-err">{{ failCount }}</div><div class="sdk-stat-lbl">失败</div></div>
|
|
<div class="sdk-stat"><div class="sdk-stat-num num-skip">{{ skipCount }}</div><div class="sdk-stat-lbl">跳过</div></div>
|
|
<div class="sdk-stat"><div class="sdk-stat-num">{{ apiCount }}</div><div class="sdk-stat-lbl">API 调用</div></div>
|
|
<div class="sdk-stat"><div class="sdk-stat-num">{{ avgLatency }}</div><div class="sdk-stat-lbl">平均延迟</div></div>
|
|
</div>
|
|
<div class="sdk-verify-actions">
|
|
<t-radio-group v-model="sdkFilter" variant="default-filled" size="small">
|
|
<t-radio-button value="all">全部</t-radio-button>
|
|
<t-radio-button value="p0">P0</t-radio-button>
|
|
<t-radio-button value="p1">P1</t-radio-button>
|
|
<t-radio-button value="p2">P2</t-radio-button>
|
|
</t-radio-group>
|
|
<t-button theme="primary" size="small" :loading="sdkRunning" @click="runAll">▶ 运行全部</t-button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="sdk-cases">
|
|
<div v-for="tc in filteredCases" :key="tc.id" class="sdk-case">
|
|
<div class="sdk-case-header">
|
|
<span class="sdk-case-title">
|
|
{{ tc.id }}. {{ tc.name }}
|
|
<span :class="['sdk-badge', 'badge-' + tc.phase]">{{ tc.phase.toUpperCase() }}</span>
|
|
</span>
|
|
<span :class="['sdk-case-tag', 'tag-' + tc.status]">{{ statusText(tc) }}</span>
|
|
</div>
|
|
<div class="sdk-case-body">
|
|
<div class="sdk-case-desc">{{ tc.desc }}</div>
|
|
<div v-if="tc.logs.length" class="sdk-logs">
|
|
<div v-for="(l, j) in tc.logs" :key="j" :class="['sdk-log-line', 'log-' + l.cls]">{{ l.text }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, reactive, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
|
import { ChatList, ChatSender, ChatActionbar } from '@tdesign-vue-next/chat'
|
|
import '@tdesign-vue-next/chat/es/style/index.css'
|
|
import { MessagePlugin } from 'tdesign-vue-next'
|
|
import { readSSEStream } from '@/utils/sse'
|
|
import { renderMarkdown } from '@/utils/markdown'
|
|
import { toChatData, type ChatMessage } from '@/utils/chatAdapter'
|
|
|
|
// ==================== 常量 ====================
|
|
const positionOptions = [
|
|
{ label: '右下角', value: 'right-bottom' },
|
|
{ label: '左下角', value: 'left-bottom' },
|
|
]
|
|
const themeOptions = [
|
|
{ label: '白天 light', value: 'light' },
|
|
{ label: '暗夜 dark', value: 'dark' },
|
|
]
|
|
const localeOptions = [
|
|
{ label: '中文', value: 'zh-CN' },
|
|
{ label: 'English', value: 'en' },
|
|
]
|
|
const modeOptions = [
|
|
{ label: 'SSE 流式', value: 'sse' },
|
|
{ label: '同步调用', value: 'sync' },
|
|
]
|
|
const strategyOptions = [
|
|
{ label: '不重写', value: 'NONE' },
|
|
{ label: '查询重写', value: 'REWRITE' },
|
|
{ label: '翻译扩展', value: 'TRANSLATION' },
|
|
{ label: '查询压缩', value: 'COMPRESSION' },
|
|
{ label: '多路扩展', value: 'MULTI_QUERY' },
|
|
]
|
|
|
|
// SDK 可选参数的默认值(与 SDK parseConfig 对齐),用于判断是否需要在生成代码中输出
|
|
const SDK_DEFAULTS: Record<string, unknown> = {
|
|
title: 'AI 智能助手',
|
|
width: 500,
|
|
height: 520,
|
|
position: 'right-bottom',
|
|
primaryColor: '#4F46E5',
|
|
streaming: true,
|
|
locale: 'zh-CN',
|
|
showCategorySwitch: false,
|
|
enableRag: true,
|
|
resizable: true,
|
|
theme: 'light',
|
|
showClear: true,
|
|
}
|
|
|
|
// ==================== 配置状态 ====================
|
|
const config = reactive({
|
|
integrateId: '1',
|
|
requestDomain: typeof window !== 'undefined' ? window.location.origin : '',
|
|
userId: '',
|
|
title: 'AI 助手',
|
|
primaryColor: '#4F46E5',
|
|
position: 'right-bottom',
|
|
width: 380,
|
|
height: 520,
|
|
resizable: true,
|
|
streaming: true,
|
|
locale: 'zh-CN',
|
|
showCategorySwitch: false,
|
|
enableRag: true,
|
|
debug: true,
|
|
theme: 'light',
|
|
showClear: true,
|
|
watermark: '',
|
|
})
|
|
|
|
const apiKey = ref('')
|
|
const sdkToken = ref('')
|
|
const sdkRoles = ref<any[] | null>(null)
|
|
const fetchingToken = ref(false)
|
|
/** 换取 Token 时请求的 TTL(秒),调小便于观察 SDK 自动刷新 */
|
|
const tokenTtl = ref(7200)
|
|
/** 当前 sdkToken 的有效期(秒),来自换取接口响应 */
|
|
const sdkTokenExpiresIn = ref<number | null>(null)
|
|
/** 是否向 SDK 传 getToken 回调(开启后 SDK 自动续期 + 401 自动重试) */
|
|
const autoRefresh = ref(false)
|
|
/** 取 Token 时机:expiry(临期/401 才取)| always(每个受保护请求前都取) */
|
|
const tokenStrategy = ref<'expiry' | 'always'>('expiry')
|
|
const sdkReady = ref(false)
|
|
const backendOnline = ref<boolean | null>(null)
|
|
|
|
// 接入代码生成器
|
|
const codeTab = ref<'snippet' | 'fullpage'>('snippet')
|
|
|
|
// ==================== 工具函数 ====================
|
|
function normalizeDomain(d: string): string {
|
|
const v = (d || '').trim()
|
|
if (!v) return typeof window !== 'undefined' ? window.location.origin : ''
|
|
return v.replace(/\/+$/, '')
|
|
}
|
|
const demoDomain = computed(() => normalizeDomain(config.requestDomain))
|
|
|
|
function getSdk(): any {
|
|
return typeof window !== 'undefined' ? (window as any).ChatbotSDK : undefined
|
|
}
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((r) => setTimeout(r, ms))
|
|
}
|
|
function assert(c: unknown, m: string): asserts c {
|
|
if (!c) throw new Error(m)
|
|
}
|
|
function qid(id: string): HTMLElement | null {
|
|
return document.getElementById(id)
|
|
}
|
|
function q(sel: string): HTMLElement | null {
|
|
return document.querySelector(sel)
|
|
}
|
|
function sdkAuthHeaders(): Record<string, string> {
|
|
const t = sdkToken.value.trim()
|
|
return t ? { Authorization: 'Bearer ' + t } : {}
|
|
}
|
|
|
|
// ==================== 构建 SDK init 配置 ====================
|
|
function buildSdkInitConfig(): Record<string, any> {
|
|
const token = sdkToken.value.trim() || undefined
|
|
const cfg: Record<string, any> = {
|
|
integrateId: config.integrateId,
|
|
requestDomain: normalizeDomain(config.requestDomain),
|
|
userId: config.userId.trim() || undefined,
|
|
title: config.title,
|
|
primaryColor: config.primaryColor,
|
|
position: config.position,
|
|
width: config.width,
|
|
height: config.height,
|
|
resizable: config.resizable,
|
|
streaming: config.streaming,
|
|
locale: config.locale,
|
|
showCategorySwitch: config.showCategorySwitch,
|
|
enableRag: config.enableRag,
|
|
debug: config.debug,
|
|
theme: config.theme,
|
|
showClear: config.showClear,
|
|
watermark: config.watermark.trim() || undefined,
|
|
}
|
|
if (token) {
|
|
cfg.token = token
|
|
if (sdkRoles.value) cfg.roles = sdkRoles.value
|
|
if (sdkTokenExpiresIn.value) cfg.expiresIn = sdkTokenExpiresIn.value
|
|
// 开启自动刷新后把取 Token 逻辑本身交给 SDK:临期/每请求前主动取 + 401 兜底重试
|
|
if (autoRefresh.value) {
|
|
if (tokenStrategy.value === 'always') cfg.tokenStrategy = 'always'
|
|
cfg.getToken = () => exchangeToken()
|
|
}
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
// ==================== SDK 脚本加载 ====================
|
|
function loadSdkScript(): void {
|
|
if (getSdk()) {
|
|
sdkReady.value = true
|
|
return
|
|
}
|
|
const existing = document.querySelector('script[data-csk-sdk-test]')
|
|
if (existing) return
|
|
const s = document.createElement('script')
|
|
s.src = '/sdk/chatbot-sdk.min.js'
|
|
s.async = true
|
|
s.setAttribute('data-csk-sdk-test', '1')
|
|
s.onload = () => { sdkReady.value = true }
|
|
s.onerror = () => {
|
|
sdkReady.value = false
|
|
s.remove() // 移除失败的 script 节点,允许用户再次点击重新加载
|
|
}
|
|
document.head.appendChild(s)
|
|
}
|
|
|
|
// ==================== Token 鉴权 ====================
|
|
/**
|
|
* 调后端换取 Token。同时被「获取 Token」按钮和传给 SDK 的 getToken 回调复用,
|
|
* 因此不做 MessagePlugin 提示,失败直接抛错(SDK 会通过 onError 上报 auth_refresh_failed)。
|
|
*/
|
|
async function exchangeToken(): Promise<{ token: string; expiresIn?: number }> {
|
|
const key = apiKey.value.trim()
|
|
assert(key, '请先输入 API Key')
|
|
const res = await fetch(normalizeDomain(config.requestDomain) + '/open-api/auth/token', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-API-Key': key },
|
|
body: JSON.stringify({ ttl: tokenTtl.value }),
|
|
})
|
|
const data = await res.json()
|
|
if (!data.success || !data.token) throw new Error(data.message || '未知错误')
|
|
sdkRoles.value = data.roles || null
|
|
if (data.roles && data.roles.length > 0) {
|
|
config.integrateId = String(data.roles[0].id)
|
|
}
|
|
return { token: data.token, expiresIn: data.expiresIn }
|
|
}
|
|
|
|
async function fetchToken(): Promise<void> {
|
|
if (!apiKey.value.trim()) {
|
|
MessagePlugin.warning('请先输入 API Key')
|
|
return
|
|
}
|
|
fetchingToken.value = true
|
|
try {
|
|
const { token, expiresIn } = await exchangeToken()
|
|
sdkToken.value = token
|
|
sdkTokenExpiresIn.value = expiresIn ?? null
|
|
MessagePlugin.success('Token 获取成功' + (expiresIn ? `(有效期 ${expiresIn} 秒)` : ''))
|
|
} catch (e: any) {
|
|
MessagePlugin.error('换取失败: ' + (e.message || e))
|
|
} finally {
|
|
fetchingToken.value = false
|
|
}
|
|
}
|
|
|
|
// ==================== 接入代码生成 ====================
|
|
function buildCodeSnippet(): string {
|
|
const domain = normalizeDomain(config.requestDomain)
|
|
const iid = String(config.integrateId).trim()
|
|
const iidLit = /^\d+$/.test(iid) ? iid : JSON.stringify(iid)
|
|
const params: string[] = []
|
|
params.push(' integrateId: ' + iidLit + ', // 必传:客服角色 ID(对应后端 roleId)')
|
|
params.push(' requestDomain: ' + JSON.stringify(domain) + ', // 必传:后端 API 域名')
|
|
|
|
const token = sdkToken.value.trim()
|
|
if (token) {
|
|
params.push(' token: ' + JSON.stringify(token) + ', // SDK JWT Token(首次从后端换取)')
|
|
if (sdkTokenExpiresIn.value) {
|
|
params.push(' expiresIn: ' + sdkTokenExpiresIn.value + ', // 有效期(秒),SDK 据此在临期前主动刷新')
|
|
}
|
|
if (autoRefresh.value) {
|
|
if (tokenStrategy.value === 'always') {
|
|
params.push(" tokenStrategy: 'always', // 每个受保护请求前都向宿主取一次 Token(不依赖 expiresIn)")
|
|
}
|
|
params.push(' getToken: async () => { // SDK 在临期 / 每请求前(always)/ 收到 401 时调用它')
|
|
params.push(" const d = await (await fetch('/后端换取Token接口')).json();")
|
|
params.push(' return { token: d.token, expiresIn: d.expiresIn };')
|
|
params.push(' },')
|
|
}
|
|
if (sdkRoles.value) {
|
|
params.push(' roles: ' + JSON.stringify(sdkRoles.value) + ', // 可用客服角色列表')
|
|
}
|
|
} else {
|
|
params.push(" token: '<从后端获取的 SDK Token>', // 推荐:SDK JWT Token(POST /open-api/auth/token 换取)")
|
|
}
|
|
|
|
const uid = config.userId.trim()
|
|
if (uid) params.push(' userId: ' + JSON.stringify(uid) + ', // 可选:外部用户ID(对应后端 accountId)')
|
|
if (config.title !== SDK_DEFAULTS.title) params.push(' title: ' + JSON.stringify(config.title) + ', // 弹窗标题')
|
|
if (config.primaryColor !== SDK_DEFAULTS.primaryColor) params.push(' primaryColor: ' + JSON.stringify(config.primaryColor) + ', // 主色调')
|
|
if (config.position !== SDK_DEFAULTS.position) params.push(' position: ' + JSON.stringify(config.position) + ', // 悬浮按钮位置')
|
|
if (config.width !== SDK_DEFAULTS.width) params.push(' width: ' + config.width + ', // 弹窗宽度(px)')
|
|
if (config.height !== SDK_DEFAULTS.height) params.push(' height: ' + config.height + ', // 弹窗高度(px),最小 300')
|
|
if (config.resizable !== SDK_DEFAULTS.resizable) params.push(' resizable: ' + config.resizable + ', // 允许拖拽缩放窗口')
|
|
if (config.theme !== SDK_DEFAULTS.theme) params.push(' theme: ' + JSON.stringify(config.theme) + ', // 主题模式')
|
|
if (config.streaming !== SDK_DEFAULTS.streaming) params.push(' streaming: ' + config.streaming + ', // 是否启用流式输出')
|
|
if (config.locale !== SDK_DEFAULTS.locale) params.push(' locale: ' + JSON.stringify(config.locale) + ', // 界面语言')
|
|
if (config.showCategorySwitch !== SDK_DEFAULTS.showCategorySwitch) params.push(' showCategorySwitch: ' + config.showCategorySwitch + ', // 是否显示知识库切换')
|
|
if (config.enableRag !== SDK_DEFAULTS.enableRag) params.push(' enableRag: ' + config.enableRag + ', // 启用 RAG 知识库检索')
|
|
if (config.showClear !== SDK_DEFAULTS.showClear) params.push(' showClear: ' + config.showClear + ', // 显示清空按钮')
|
|
const watermark = config.watermark.trim()
|
|
if (watermark) params.push(' watermark: ' + JSON.stringify(watermark) + ', // 聊天窗口水印文字')
|
|
params.push(' debug: false // 调试日志,生产环境建议关闭')
|
|
|
|
const initBlock = 'ChatbotSDK.init({\n' + params.join('\n') + '\n});'
|
|
return (
|
|
'<!-- ChatbotSDK 智能客服 -->\n' +
|
|
'<script src="' + domain + '/sdk/chatbot-sdk.min.js"><\/script>\n' +
|
|
'<script>\n' + initBlock + '\n<\/script>'
|
|
)
|
|
}
|
|
|
|
function buildFullPage(): string {
|
|
const indented = buildCodeSnippet()
|
|
.split('\n')
|
|
.map((l) => ' ' + l)
|
|
.join('\n')
|
|
return (
|
|
'<!DOCTYPE html>\n<html lang="zh-CN">\n<head>\n <meta charset="UTF-8">\n <meta name="viewport" content="width=device-width, initial-scale=1.0">\n <title>AI 智能助手</title>\n</head>\n<body>\n' +
|
|
indented +
|
|
'\n</body>\n</html>'
|
|
)
|
|
}
|
|
|
|
const currentCode = computed(() => (codeTab.value === 'fullpage' ? buildFullPage() : buildCodeSnippet()))
|
|
|
|
function highlightCode(code: string): string {
|
|
const esc = code.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
const re =
|
|
/(<!--[\s\S]*?-->)|('[^']*'|"[^"]*")|(\/\/[^\n]*)|\b(\d+)\b|\b(integrateId|requestDomain|userId|categoryId|title|primaryColor|position|width|streaming|locale|showCategorySwitch|enableRag|rewriteStrategy|debug|showClear|watermark|showAdminPanel|launcherIcon|token|tokenStrategy|tokenUrl|expiresIn|getToken|roles)(?=\s*:)/g
|
|
return esc.replace(re, (m, htmlCmt, str, cmt, num, key) => {
|
|
if (htmlCmt) return '<span class="cm">' + htmlCmt + '</span>'
|
|
if (str) return '<span class="st">' + str + '</span>'
|
|
if (cmt) return '<span class="cm">' + cmt + '</span>'
|
|
if (num) return '<span class="nu">' + num + '</span>'
|
|
if (key) return '<span class="kw">' + key + '</span>'
|
|
return m
|
|
})
|
|
}
|
|
const highlightedCode = computed(() => highlightCode(currentCode.value))
|
|
|
|
function fallbackCopy(text: string, done: () => void): void {
|
|
const ta = document.createElement('textarea')
|
|
ta.value = text
|
|
ta.style.position = 'fixed'
|
|
ta.style.opacity = '0'
|
|
document.body.appendChild(ta)
|
|
ta.select()
|
|
try {
|
|
document.execCommand('copy')
|
|
done()
|
|
} catch {
|
|
/* 忽略 */
|
|
}
|
|
document.body.removeChild(ta)
|
|
}
|
|
function copyCode(): void {
|
|
const code = currentCode.value
|
|
const done = () => MessagePlugin.success('代码已复制')
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
navigator.clipboard.writeText(code).then(done).catch(() => fallbackCopy(code, done))
|
|
} else {
|
|
fallbackCopy(code, done)
|
|
}
|
|
}
|
|
|
|
// ==================== SDK 操作 ====================
|
|
function doInit(): void {
|
|
if (!sdkReady.value) {
|
|
MessagePlugin.warning('SDK 脚本尚未加载,请稍候')
|
|
return
|
|
}
|
|
if (!sdkToken.value.trim()) {
|
|
MessagePlugin.warning('请先点击「获取 Token」按钮,用 API Key 换取 SDK Token 后再初始化')
|
|
return
|
|
}
|
|
const sdk = getSdk()
|
|
sdk.destroy()
|
|
sdk.init(buildSdkInitConfig())
|
|
if (qid('csk-launcher')) {
|
|
MessagePlugin.success('SDK 初始化成功')
|
|
} else {
|
|
MessagePlugin.error('SDK 初始化失败:配置校验未通过,请检查必填参数')
|
|
}
|
|
}
|
|
function doDestroy(): void {
|
|
getSdk()?.destroy()
|
|
}
|
|
function doClearHistory(): void {
|
|
getSdk()?.clearHistory()
|
|
}
|
|
function domAction(act: 'open' | 'close' | 'toggle'): void {
|
|
const sdk = getSdk()
|
|
if (!sdk) return
|
|
if (!qid('csk-launcher')) {
|
|
sdk.init(buildSdkInitConfig())
|
|
}
|
|
sdk[act]()
|
|
}
|
|
|
|
// ==================== 对话演示区 ====================
|
|
const activeTab = ref<'chat' | 'sdk'>('chat')
|
|
const demoMode = ref<'sse' | 'sync'>('sse')
|
|
const demoRag = ref(true)
|
|
const demoStrategy = ref('MULTI_QUERY')
|
|
const demoInput = ref('')
|
|
const demoChatId = ref('')
|
|
const isSending = ref(false)
|
|
const demoListRef = ref<HTMLElement | null>(null)
|
|
let abortController: AbortController | null = null
|
|
|
|
function genId(): string {
|
|
return 'msg_' + Date.now() + '_' + Math.random().toString(36).substring(2, 8)
|
|
}
|
|
function fmtTime(): string {
|
|
return new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
|
}
|
|
function welcomeMessage(): ChatMessage {
|
|
return {
|
|
id: genId(),
|
|
role: 'assistant',
|
|
content: '您好,这里是新接口(GET /ai/chat、/ai/chat/stream、/ai/chat/sources)对话演示区。请先在左侧获取 SDK Token,然后输入问题开始对话。',
|
|
streaming: false,
|
|
time: fmtTime(),
|
|
}
|
|
}
|
|
|
|
const demoMessages = ref<ChatMessage[]>([welcomeMessage()])
|
|
const demoData = computed(() => toChatData(demoMessages.value))
|
|
|
|
function buildDemoUrl(
|
|
path: 'chat' | 'chat/stream' | 'chat/sources',
|
|
message: string,
|
|
chatId: string,
|
|
opts: { enableRag: boolean; rewriteStrategy?: string },
|
|
): string {
|
|
const p = new URLSearchParams()
|
|
p.set('message', message)
|
|
p.set('chatId', chatId)
|
|
const rid = String(config.integrateId || '').trim()
|
|
if (rid) p.set('roleId', rid)
|
|
const uid = config.userId.trim()
|
|
if (uid) p.set('accountId', uid)
|
|
if (path !== 'chat/sources' && opts.enableRag) p.set('enableRag', 'true')
|
|
if (opts.enableRag && opts.rewriteStrategy) p.set('rewriteStrategy', opts.rewriteStrategy)
|
|
return demoDomain.value + '/ai/' + path + '?' + p.toString()
|
|
}
|
|
|
|
async function scrollDemoBottom(): Promise<void> {
|
|
await nextTick()
|
|
if (demoListRef.value) demoListRef.value.scrollTop = demoListRef.value.scrollHeight
|
|
}
|
|
|
|
async function sendDemo(val?: string): Promise<void> {
|
|
// 优先使用 ChatSender 回传的 value,避免组件未来在发送时清空 modelValue 导致空消息
|
|
const text = (val ?? demoInput.value).trim()
|
|
if (!text || isSending.value) return
|
|
if (!sdkToken.value.trim()) {
|
|
MessagePlugin.warning('请先在左侧获取 SDK Token')
|
|
return
|
|
}
|
|
|
|
demoInput.value = ''
|
|
isSending.value = true
|
|
if (!demoChatId.value) demoChatId.value = 'sdk_demo_' + Date.now()
|
|
|
|
demoMessages.value.push({ id: genId(), role: 'user', content: text, streaming: false, time: fmtTime() })
|
|
const assistantMsg: ChatMessage = { id: genId(), role: 'assistant', content: '', streaming: true, time: fmtTime(), sources: [] }
|
|
demoMessages.value.push(assistantMsg)
|
|
await scrollDemoBottom()
|
|
|
|
const opts = { enableRag: demoRag.value, rewriteStrategy: demoRag.value ? demoStrategy.value : undefined }
|
|
const url = buildDemoUrl(demoMode.value === 'sync' ? 'chat' : 'chat/stream', text, demoChatId.value, opts)
|
|
const headers = sdkAuthHeaders()
|
|
|
|
try {
|
|
if (abortController) abortController.abort()
|
|
abortController = new AbortController()
|
|
|
|
if (demoMode.value === 'sync') {
|
|
const res = await fetch(url, { headers, signal: abortController.signal })
|
|
if (!res.ok) throw new Error('HTTP ' + res.status)
|
|
assistantMsg.content = await res.text()
|
|
} else {
|
|
// 复用 readSSEStream(内部手写解析 OpenAI delta.content),逐 chunk 追加
|
|
await readSSEStream(
|
|
url,
|
|
(chunk: string) => {
|
|
assistantMsg.content += chunk
|
|
demoMessages.value = [...demoMessages.value]
|
|
},
|
|
undefined,
|
|
headers,
|
|
abortController.signal,
|
|
)
|
|
}
|
|
|
|
// RAG 模式下拉取引用来源
|
|
if (demoRag.value) {
|
|
try {
|
|
const sres = await fetch(buildDemoUrl('chat/sources', text, demoChatId.value, opts), { headers })
|
|
const json = await sres.json()
|
|
if (json && json.success) assistantMsg.sources = json.data || []
|
|
} catch {
|
|
/* 来源获取失败不影响主回答 */
|
|
}
|
|
}
|
|
} catch (e: any) {
|
|
if (e.name === 'AbortError') {
|
|
assistantMsg.content = assistantMsg.content || '已取消'
|
|
} else {
|
|
assistantMsg.content = '请求失败:' + (e.message || e)
|
|
assistantMsg.error = true
|
|
}
|
|
} finally {
|
|
assistantMsg.streaming = false
|
|
isSending.value = false
|
|
demoMessages.value = [...demoMessages.value]
|
|
await scrollDemoBottom()
|
|
}
|
|
}
|
|
|
|
function abortDemo(): void {
|
|
if (abortController) abortController.abort()
|
|
isSending.value = false
|
|
const last = demoMessages.value[demoMessages.value.length - 1]
|
|
if (last && last.role === 'assistant' && last.streaming) {
|
|
last.streaming = false
|
|
if (!last.content) last.content = '已取消'
|
|
demoMessages.value = [...demoMessages.value]
|
|
}
|
|
}
|
|
|
|
function clearDemo(): void {
|
|
abortDemo()
|
|
demoChatId.value = ''
|
|
demoMessages.value = [welcomeMessage()]
|
|
}
|
|
|
|
async function onDemoAction(action: string, index: number): Promise<void> {
|
|
const msg = demoMessages.value[index]
|
|
if (!msg) return
|
|
if (action === 'copy') {
|
|
try {
|
|
await navigator.clipboard.writeText(msg.content)
|
|
MessagePlugin.success('已复制回复')
|
|
} catch {
|
|
MessagePlugin.error('复制失败')
|
|
}
|
|
}
|
|
}
|
|
|
|
// ==================== SDK 集成验证区 ====================
|
|
interface TestLog {
|
|
text: string
|
|
cls: string
|
|
}
|
|
interface TestCaseResult {
|
|
id: string
|
|
name: string
|
|
desc: string
|
|
phase: 'p0' | 'p1' | 'p2'
|
|
status: 'idle' | 'running' | 'pass' | 'fail' | 'skip'
|
|
summary: string
|
|
logs: TestLog[]
|
|
fn: (log: (text: string, cls?: string) => void, mark: (s: 'pass' | 'fail' | 'skip', t: string) => void) => Promise<void>
|
|
}
|
|
|
|
const sdkFilter = ref<'all' | 'p0' | 'p1' | 'p2'>('all')
|
|
const sdkRunning = ref(false)
|
|
const passCount = ref(0)
|
|
const failCount = ref(0)
|
|
const skipCount = ref(0)
|
|
const apiCount = ref(0)
|
|
const apiDurations = ref<number[]>([])
|
|
|
|
const testResults = ref<TestCaseResult[]>([
|
|
{
|
|
id: 'T1', name: 'SDK 全局加载', desc: '验证 window.ChatbotSDK 方法', phase: 'p0', status: 'idle', summary: '', logs: [],
|
|
fn: async (log, mark) => {
|
|
log('检测 window.ChatbotSDK ...', 'info')
|
|
const sdk = getSdk()
|
|
assert(sdk, 'window.ChatbotSDK 未定义(SDK 脚本未加载)')
|
|
const methods = ['init', 'destroy', 'open', 'close', 'toggle', 'clearHistory', 'setToken', 'refreshToken']
|
|
for (const m of methods) {
|
|
assert(typeof sdk[m] === 'function', 'ChatbotSDK.' + m + ' 不是函数')
|
|
log('✓ ChatbotSDK.' + m + '()', 'pass')
|
|
}
|
|
mark('pass', '通过 (' + methods.length + ' 个方法)')
|
|
},
|
|
},
|
|
{
|
|
id: 'T2', name: '必传参数拦截', desc: '缺失 integrateId / requestDomain / 非法 URL 时拦截', phase: 'p0', status: 'idle', summary: '', logs: [],
|
|
fn: async (log, mark) => {
|
|
const sdk = getSdk()
|
|
sdk.init({})
|
|
await sleep(200)
|
|
assert(!qid('csk-launcher'), '缺失 integrateId 时不应创建 DOM')
|
|
log('✓ 缺失 integrateId 正确拦截', 'pass')
|
|
|
|
sdk.init({ integrateId: 't' })
|
|
await sleep(200)
|
|
assert(!qid('csk-launcher'), '缺失 requestDomain 时不应创建 DOM')
|
|
log('✓ 缺失 requestDomain 正确拦截', 'pass')
|
|
|
|
sdk.init({ integrateId: 't', requestDomain: 'abc' })
|
|
await sleep(200)
|
|
assert(!qid('csk-launcher'), '非法 URL 时不应创建 DOM')
|
|
log('✓ 非法 URL 正确拦截', 'pass')
|
|
mark('pass', '通过')
|
|
},
|
|
},
|
|
{
|
|
id: 'T3', name: 'DOM 创建', desc: '悬浮按钮 + 弹窗 + 消息区/输入框/发送按钮 + CSS 注入', phase: 'p0', status: 'idle', summary: '', logs: [],
|
|
fn: async (log, mark) => {
|
|
const sdk = getSdk()
|
|
sdk.destroy()
|
|
await sleep(100)
|
|
sdk.init(buildSdkInitConfig())
|
|
await sleep(500)
|
|
assert(qid('csk-launcher'), '悬浮按钮未创建')
|
|
assert(qid('csk-window'), '弹窗未创建')
|
|
assert(qid('csk-messages'), '#csk-messages 未创建')
|
|
assert(qid('csk-input'), '#csk-input 未创建')
|
|
assert(qid('csk-input')?.tagName === 'T-CHAT-SENDER', '输入区应为 t-chat-sender')
|
|
log('✓ 消息区/输入框(t-chat-sender)齐全', 'pass')
|
|
assert(q('style[data-csk-sdk]'), 'CSS 未注入')
|
|
log('✓ CSS 已注入', 'pass')
|
|
mark('pass', '通过')
|
|
},
|
|
},
|
|
{
|
|
id: 'T4', name: 'open/close', desc: '窗口 API', phase: 'p0', status: 'idle', summary: '', logs: [],
|
|
fn: async (log, mark) => {
|
|
const sdk = getSdk()
|
|
sdk.destroy()
|
|
await sleep(100)
|
|
sdk.init(buildSdkInitConfig())
|
|
await sleep(300)
|
|
const w = qid('csk-window')
|
|
assert(w, '弹窗不存在')
|
|
sdk.open()
|
|
await sleep(100)
|
|
assert(!w.classList.contains('csk-window--hidden'), 'open() 失败')
|
|
log('✓ open()', 'pass')
|
|
sdk.close()
|
|
await sleep(100)
|
|
assert(w.classList.contains('csk-window--hidden'), 'close() 失败')
|
|
log('✓ close()', 'pass')
|
|
mark('pass', '通过')
|
|
},
|
|
},
|
|
{
|
|
id: 'T5', name: 'destroy 清理', desc: '移除 DOM + CSS', phase: 'p0', status: 'idle', summary: '', logs: [],
|
|
fn: async (log, mark) => {
|
|
const sdk = getSdk()
|
|
sdk.destroy()
|
|
await sleep(100)
|
|
sdk.init(buildSdkInitConfig())
|
|
await sleep(300)
|
|
assert(qid('csk-launcher') && qid('csk-window'), '初始化后 DOM 应存在')
|
|
sdk.destroy()
|
|
await sleep(200)
|
|
assert(!qid('csk-launcher'), 'destroy 后按钮应移除')
|
|
assert(!qid('csk-window'), 'destroy 后弹窗应移除')
|
|
assert(document.querySelectorAll('style[data-csk-sdk]').length === 0, 'destroy 后 CSS 应移除')
|
|
log('✓ DOM + CSS 已清理', 'pass')
|
|
mark('pass', '通过')
|
|
},
|
|
},
|
|
{
|
|
id: 'T6', name: '实际对话', desc: '发送消息 → AI 回复', phase: 'p0', status: 'idle', summary: '', logs: [],
|
|
fn: async (log, mark) => {
|
|
const sdk = getSdk()
|
|
sdk.destroy()
|
|
await sleep(100)
|
|
sdk.init(buildSdkInitConfig())
|
|
await sleep(500)
|
|
sdk.open()
|
|
await sleep(300)
|
|
const sender = qid('csk-input')
|
|
if (!sender) {
|
|
log('⚠ 输入框不存在', 'warn')
|
|
mark('skip', '输入框不存在')
|
|
return
|
|
}
|
|
// t-chat-sender 的 send 事件携带 value(等价于输入后点击发送)
|
|
sender.dispatchEvent(new CustomEvent('send', { detail: { value: '你好' } }))
|
|
log('已触发发送,等待 AI 回复...', 'info')
|
|
await sleep(9000)
|
|
const aiMsgs = document.querySelectorAll('.csk-msg--ai t-chat-item')
|
|
if (aiMsgs.length > 0) {
|
|
const last = aiMsgs[aiMsgs.length - 1]
|
|
// t-chat-item 为 shadow DOM,文本在其 shadowRoot 内
|
|
const shadowText = (last as HTMLElement).shadowRoot?.textContent || ''
|
|
log('AI 回复: ' + shadowText.substring(0, 100), 'pass')
|
|
mark('pass', '通过(有 AI 回复)')
|
|
} else {
|
|
log('⚠ 等待 AI 回复超时', 'warn')
|
|
mark('skip', 'AI 未回复')
|
|
}
|
|
},
|
|
},
|
|
{
|
|
id: 'T7', name: 'API SSE 流式', desc: 'GET /ai/chat/stream(OpenAI Chat Completions 格式)', phase: 'p1', status: 'idle', summary: '', logs: [],
|
|
fn: async (log, mark) => {
|
|
const url = buildDemoUrl('chat/stream', '你好', 'verify_sse', { enableRag: false })
|
|
apiCount.value++
|
|
const t0 = performance.now()
|
|
let res: Response
|
|
try {
|
|
res = await fetch(url, { headers: sdkAuthHeaders(), signal: AbortSignal.timeout(20000) })
|
|
} catch {
|
|
log('⚠ 请求失败', 'warn')
|
|
mark('skip', '后端不可用')
|
|
return
|
|
}
|
|
if (!res.ok) {
|
|
log('⚠ HTTP ' + res.status, 'warn')
|
|
mark('skip', '接口异常')
|
|
return
|
|
}
|
|
const reader = res.body!.getReader()
|
|
const decoder = new TextDecoder()
|
|
let total = ''
|
|
let chunks = 0
|
|
while (true) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
total += decoder.decode(value, { stream: true })
|
|
chunks++
|
|
}
|
|
const elapsed = Math.round(performance.now() - t0)
|
|
apiDurations.value.push(elapsed)
|
|
assert(total.includes('choices') || total.includes('[DONE]'), '非 OpenAI Chat Completions 格式')
|
|
log('✓ OpenAI 格式(含 choices 或 [DONE])', 'pass')
|
|
log('✓ SSE ' + chunks + ' chunks, ' + total.length + ' chars', 'pass')
|
|
mark('pass', '通过 (' + elapsed + 'ms)')
|
|
},
|
|
},
|
|
{
|
|
id: 'T8', name: 'API RAG 引用来源', desc: 'GET /ai/chat/sources', phase: 'p1', status: 'idle', summary: '', logs: [],
|
|
fn: async (log, mark) => {
|
|
const url = buildDemoUrl('chat/sources', '请假', 'verify_sources', { enableRag: true, rewriteStrategy: 'REWRITE' })
|
|
apiCount.value++
|
|
let res: Response
|
|
try {
|
|
res = await fetch(url, { headers: sdkAuthHeaders(), signal: AbortSignal.timeout(15000) })
|
|
} catch {
|
|
log('⚠ 请求失败', 'warn')
|
|
mark('skip', '后端不可用')
|
|
return
|
|
}
|
|
if (!res.ok) {
|
|
log('⚠ HTTP ' + res.status, 'warn')
|
|
mark('skip', '接口异常')
|
|
return
|
|
}
|
|
const json = await res.json()
|
|
log('返回 success=' + json.success + ' data=' + (json.data ? json.data.length : 0) + ' 条', 'info')
|
|
assert(json.success !== undefined, '应返回 success 字段')
|
|
log('✓ RAG 引用来源接口可用', 'pass')
|
|
mark('pass', '通过')
|
|
},
|
|
},
|
|
{
|
|
id: 'T9', name: '知识库分类切换', desc: 'showCategorySwitch 下拉框 UI', phase: 'p1', status: 'idle', summary: '', logs: [],
|
|
fn: async (log, mark) => {
|
|
const sdk = getSdk()
|
|
sdk.destroy()
|
|
await sleep(100)
|
|
sdk.init({ ...buildSdkInitConfig(), showCategorySwitch: true })
|
|
await sleep(300)
|
|
assert(qid('csk-category-select'), '分类下拉框未创建')
|
|
log('✓ 分类下拉框已创建', 'pass')
|
|
sdk.destroy()
|
|
await sleep(100)
|
|
sdk.init({ ...buildSdkInitConfig(), showCategorySwitch: false })
|
|
await sleep(300)
|
|
assert(!qid('csk-category-select'), '关闭后不应创建分类下拉框')
|
|
log('✓ showCategorySwitch=false 时不创建', 'pass')
|
|
mark('pass', '通过')
|
|
},
|
|
},
|
|
{
|
|
id: 'T10', name: '多语言国际化', desc: 'zh-CN / en placeholder', phase: 'p2', status: 'idle', summary: '', logs: [],
|
|
fn: async (log, mark) => {
|
|
const sdk = getSdk()
|
|
sdk.destroy()
|
|
await sleep(100)
|
|
sdk.init({ ...buildSdkInitConfig(), locale: 'zh-CN' })
|
|
await sleep(300)
|
|
const zh = qid('csk-input')
|
|
assert(zh && zh.getAttribute('placeholder') === '输入您的问题...', '中文 placeholder 错误')
|
|
log('✓ 中文界面', 'pass')
|
|
sdk.destroy()
|
|
await sleep(100)
|
|
sdk.init({ ...buildSdkInitConfig(), locale: 'en' })
|
|
await sleep(300)
|
|
const en = qid('csk-input')
|
|
assert(en && en.getAttribute('placeholder') === 'Type your question...', '英文 placeholder 错误')
|
|
log('✓ 英文界面', 'pass')
|
|
mark('pass', '通过')
|
|
},
|
|
},
|
|
])
|
|
|
|
const totalTests = computed(() => testResults.value.length)
|
|
const avgLatency = computed(() => {
|
|
if (!apiDurations.value.length) return '-'
|
|
return Math.round(apiDurations.value.reduce((a, b) => a + b, 0) / apiDurations.value.length) + 'ms'
|
|
})
|
|
const filteredCases = computed(() => {
|
|
if (sdkFilter.value === 'all') return testResults.value
|
|
return testResults.value.filter((tc) => tc.phase === sdkFilter.value)
|
|
})
|
|
|
|
function statusText(tc: TestCaseResult): string {
|
|
switch (tc.status) {
|
|
case 'running': return '🔄 执行中'
|
|
case 'pass': return '✅ ' + (tc.summary || '通过')
|
|
case 'fail': return '❌ ' + (tc.summary || '失败')
|
|
case 'skip': return '⏭ ' + (tc.summary || '跳过')
|
|
default: return '⏳ 等待'
|
|
}
|
|
}
|
|
|
|
async function runAll(): Promise<void> {
|
|
if (sdkRunning.value) return
|
|
if (!getSdk()) {
|
|
MessagePlugin.error('SDK 脚本未加载,请刷新页面后重试')
|
|
return
|
|
}
|
|
sdkRunning.value = true
|
|
passCount.value = 0
|
|
failCount.value = 0
|
|
skipCount.value = 0
|
|
apiCount.value = 0
|
|
apiDurations.value = []
|
|
getSdk().destroy()
|
|
|
|
for (const tc of testResults.value) {
|
|
tc.status = 'idle'
|
|
tc.summary = ''
|
|
tc.logs = []
|
|
}
|
|
await sleep(300)
|
|
|
|
for (const tc of testResults.value) {
|
|
if (!sdkRunning.value) break
|
|
tc.status = 'running'
|
|
const log = (text: string, cls?: string) => { tc.logs.push({ text, cls: cls || '' }) }
|
|
const mark = (s: 'pass' | 'fail' | 'skip', t: string) => {
|
|
tc.status = s
|
|
tc.summary = t
|
|
if (s === 'pass') passCount.value++
|
|
else if (s === 'fail') failCount.value++
|
|
else skipCount.value++
|
|
}
|
|
try {
|
|
await tc.fn(log, mark)
|
|
if (tc.status === 'running') mark('pass', '通过')
|
|
} catch (e: any) {
|
|
log('异常: ' + (e.message || e), 'fail')
|
|
mark('fail', e.message || '未知错误')
|
|
}
|
|
await sleep(150)
|
|
}
|
|
sdkRunning.value = false
|
|
MessagePlugin.success('测试完成:' + passCount.value + ' 通过 / ' + failCount.value + ' 失败 / ' + skipCount.value + ' 跳过')
|
|
}
|
|
|
|
// ==================== 后端连通性探测 ====================
|
|
function probeBackend(): void {
|
|
fetch(window.location.origin + '/index.html', { signal: AbortSignal.timeout(5000) })
|
|
.then((r) => { backendOnline.value = r.ok || r.status < 500 })
|
|
.catch(() => { backendOnline.value = false })
|
|
}
|
|
|
|
// ==================== 生命周期 ====================
|
|
onMounted(() => {
|
|
loadSdkScript()
|
|
probeBackend()
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
if (abortController) abortController.abort()
|
|
abortController = null
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* ==================== 整体布局 ==================== */
|
|
.sdk-test {
|
|
display: flex;
|
|
height: 100vh;
|
|
overflow: hidden;
|
|
background: var(--td-bg-color-page);
|
|
}
|
|
|
|
/* ==================== 左侧面板 ==================== */
|
|
.sdk-panel {
|
|
width: 380px;
|
|
min-width: 380px;
|
|
background: var(--td-bg-color-container);
|
|
border-right: 1px solid var(--td-border-level-1-color);
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
.sdk-panel-header {
|
|
padding: 16px 20px;
|
|
border-bottom: 1px solid var(--td-border-level-1-color);
|
|
}
|
|
.sdk-panel-links {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
margin-bottom: 8px;
|
|
}
|
|
.sdk-panel-links a {
|
|
font-size: 12px;
|
|
color: var(--td-brand-color);
|
|
text-decoration: none;
|
|
}
|
|
.sdk-panel-header h1 {
|
|
font-size: 20px;
|
|
color: var(--td-text-color-primary);
|
|
margin: 0 0 4px;
|
|
}
|
|
.sdk-panel-header p {
|
|
font-size: 12px;
|
|
color: var(--td-text-color-placeholder);
|
|
margin: 0;
|
|
}
|
|
.sdk-panel-body {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 16px 20px;
|
|
}
|
|
|
|
.sdk-status-row {
|
|
display: flex;
|
|
gap: 8px;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.mapping-note {
|
|
background: var(--td-warning-color-1, #fffbeb);
|
|
border: 1px solid var(--td-warning-color-3, #fde68a);
|
|
border-radius: 8px;
|
|
padding: 10px 14px;
|
|
margin-bottom: 14px;
|
|
font-size: 11px;
|
|
color: #92400e;
|
|
line-height: 1.6;
|
|
}
|
|
|
|
.token-box {
|
|
background: #f0fdf4;
|
|
border: 1px solid #bbf7d0;
|
|
border-radius: 8px;
|
|
padding: 12px;
|
|
margin-bottom: 14px;
|
|
}
|
|
.token-title {
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
color: #166534;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.cfg-field {
|
|
margin-bottom: 12px;
|
|
}
|
|
.cfg-field label {
|
|
display: block;
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
color: var(--td-text-color-secondary);
|
|
margin-bottom: 4px;
|
|
}
|
|
.cfg-field .hint {
|
|
font-size: 11px;
|
|
color: var(--td-text-color-placeholder);
|
|
margin-top: 3px;
|
|
}
|
|
.cfg-row {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 10px;
|
|
}
|
|
.inline-flex {
|
|
display: flex;
|
|
gap: 6px;
|
|
align-items: center;
|
|
}
|
|
.color-wrap {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
.color-input {
|
|
width: 36px;
|
|
height: 32px;
|
|
padding: 2px;
|
|
border: 1px solid var(--td-border-level-2-color);
|
|
border-radius: 6px;
|
|
background: var(--td-bg-color-container);
|
|
cursor: pointer;
|
|
}
|
|
.color-hex {
|
|
font-size: 12px;
|
|
color: var(--td-text-color-secondary);
|
|
font-family: 'SF Mono', Consolas, Menlo, monospace;
|
|
}
|
|
|
|
.cfg-switch-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 8px 14px;
|
|
margin-bottom: 14px;
|
|
}
|
|
.cfg-switch {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
font-size: 12px;
|
|
color: var(--td-text-color-secondary);
|
|
}
|
|
|
|
.cfg-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.divider {
|
|
border: none;
|
|
border-top: 1px solid var(--td-border-level-1-color);
|
|
margin: 16px 0;
|
|
}
|
|
|
|
.tips {
|
|
font-size: 12px;
|
|
color: var(--td-text-color-secondary);
|
|
line-height: 1.7;
|
|
}
|
|
|
|
/* ==================== 接入代码 ==================== */
|
|
.code-section {
|
|
border: 1px solid var(--td-border-level-1-color);
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
}
|
|
.code-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 8px 12px;
|
|
background: var(--td-bg-color-secondarycontainer);
|
|
border-bottom: 1px solid var(--td-border-level-1-color);
|
|
}
|
|
.code-title {
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
color: var(--td-text-color-secondary);
|
|
}
|
|
.code-header-actions {
|
|
display: flex;
|
|
gap: 6px;
|
|
align-items: center;
|
|
}
|
|
.code-pre {
|
|
margin: 0;
|
|
padding: 12px;
|
|
font-size: 11.5px;
|
|
line-height: 1.6;
|
|
font-family: 'SF Mono', Consolas, Menlo, monospace;
|
|
white-space: pre;
|
|
overflow-x: auto;
|
|
color: var(--td-text-color-primary);
|
|
max-height: 260px;
|
|
overflow-y: auto;
|
|
tab-size: 2;
|
|
}
|
|
.code-pre :deep(.cm) { color: var(--td-text-color-placeholder); }
|
|
.code-pre :deep(.kw) { color: #7c3aed; }
|
|
.code-pre :deep(.st) { color: #059669; }
|
|
.code-pre :deep(.nu) { color: #2563eb; }
|
|
|
|
/* ==================== 右侧功能区 ==================== */
|
|
.sdk-main {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
.sdk-main-header {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 12px 20px;
|
|
background: var(--td-bg-color-container);
|
|
border-bottom: 1px solid var(--td-border-level-1-color);
|
|
}
|
|
|
|
/* ==================== 对话演示区 ==================== */
|
|
.demo-area {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
background: var(--td-bg-color-container);
|
|
}
|
|
.demo-toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 10px 16px;
|
|
border-bottom: 1px solid var(--td-border-level-1-color);
|
|
}
|
|
.demo-mode { width: 110px; }
|
|
.demo-toggle { display: flex; align-items: center; gap: 4px; font-size: 12px; cursor: pointer; }
|
|
.demo-strategy { width: 140px; }
|
|
.demo-spacer { flex: 1; }
|
|
.demo-warning {
|
|
background: var(--td-warning-color-1, #fffbeb);
|
|
color: #92400e;
|
|
border-bottom: 1px solid var(--td-warning-color-3, #fde68a);
|
|
padding: 8px 16px;
|
|
font-size: 12px;
|
|
}
|
|
.demo-list {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 16px;
|
|
}
|
|
.demo-chat-list {
|
|
--td-chat-item-max-width: 85%;
|
|
}
|
|
.demo-content {
|
|
font-size: 14px;
|
|
line-height: 1.7;
|
|
}
|
|
.demo-content .demo-user-text { white-space: pre-wrap; }
|
|
.demo-content .demo-thinking { color: var(--td-text-color-placeholder); font-style: italic; animation: blink 1s infinite; }
|
|
@keyframes blink { 50% { opacity: 0.5; } }
|
|
.demo-content :deep(p) { margin: 4px 0; }
|
|
.demo-content :deep(ul), .demo-content :deep(ol) { padding-left: 20px; margin: 6px 0; }
|
|
.demo-content :deep(code) { background: rgba(0, 0, 0, 0.06); padding: 2px 6px; border-radius: 3px; font-size: 13px; }
|
|
.demo-content :deep(pre) { background: rgba(0, 0, 0, 0.05); padding: 10px; border-radius: 8px; overflow-x: auto; }
|
|
.demo-content :deep(pre code) { background: none; padding: 0; }
|
|
|
|
.demo-sources {
|
|
margin-top: 10px;
|
|
border-top: 1px dashed var(--td-border-level-2-color);
|
|
padding-top: 8px;
|
|
}
|
|
.demo-sources-title { font-size: 12px; color: var(--td-text-color-secondary); margin-bottom: 6px; }
|
|
.demo-source-item {
|
|
background: var(--td-bg-color-secondarycontainer);
|
|
border-radius: 6px;
|
|
padding: 6px 10px;
|
|
margin-bottom: 4px;
|
|
}
|
|
.demo-source-head { display: flex; align-items: center; justify-content: space-between; }
|
|
.demo-source-doc { font-size: 12px; font-weight: 600; color: var(--td-brand-color); }
|
|
.demo-source-score { font-size: 11px; color: var(--td-text-color-placeholder); }
|
|
.demo-source-snippet { font-size: 12px; color: var(--td-text-color-secondary); margin-top: 2px; }
|
|
|
|
.demo-composer {
|
|
padding: 12px 16px;
|
|
border-top: 1px solid var(--td-border-level-1-color);
|
|
}
|
|
|
|
/* ==================== SDK 集成验证区 ==================== */
|
|
.sdk-verify {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
.sdk-verify-toolbar {
|
|
padding: 14px 20px 12px;
|
|
border-bottom: 1px solid var(--td-border-level-1-color);
|
|
background: var(--td-bg-color-container);
|
|
}
|
|
.sdk-overview {
|
|
display: grid;
|
|
grid-template-columns: repeat(6, 1fr);
|
|
gap: 10px;
|
|
margin-bottom: 12px;
|
|
}
|
|
.sdk-stat {
|
|
background: var(--td-bg-color-secondarycontainer);
|
|
border: 1px solid var(--td-border-level-1-color);
|
|
border-radius: 8px;
|
|
padding: 12px;
|
|
text-align: center;
|
|
}
|
|
.sdk-stat-num { font-size: 24px; font-weight: 700; }
|
|
.sdk-stat-num.num-total { color: var(--td-brand-color); }
|
|
.sdk-stat-num.num-ok { color: var(--td-success-color); }
|
|
.sdk-stat-num.num-err { color: var(--td-error-color); }
|
|
.sdk-stat-num.num-skip { color: var(--td-warning-color); }
|
|
.sdk-stat-lbl { font-size: 11px; color: var(--td-text-color-placeholder); margin-top: 2px; }
|
|
.sdk-verify-actions { display: flex; align-items: center; gap: 10px; }
|
|
|
|
.sdk-cases {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 20px;
|
|
}
|
|
.sdk-case {
|
|
background: var(--td-bg-color-container);
|
|
border: 1px solid var(--td-border-level-1-color);
|
|
border-radius: 10px;
|
|
margin-bottom: 14px;
|
|
overflow: hidden;
|
|
}
|
|
.sdk-case-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 12px 16px;
|
|
background: var(--td-bg-color-secondarycontainer);
|
|
border-bottom: 1px solid var(--td-border-level-1-color);
|
|
}
|
|
.sdk-case-title { font-size: 13px; font-weight: 600; color: var(--td-text-color-primary); }
|
|
.sdk-badge {
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
padding: 2px 6px;
|
|
border-radius: 4px;
|
|
margin-left: 8px;
|
|
}
|
|
.badge-p0 { background: #dbeafe; color: #1d4ed8; }
|
|
.badge-p1 { background: #fef3c7; color: #92400e; }
|
|
.badge-p2 { background: #f3e8ff; color: #7c3aed; }
|
|
|
|
.sdk-case-tag { font-size: 12px; font-weight: 600; padding: 3px 10px; border-radius: 12px; }
|
|
.tag-idle { background: #f3f4f6; color: #6b7280; }
|
|
.tag-running { background: #dbeafe; color: #1d4ed8; }
|
|
.tag-pass { background: #d1fae5; color: #065f46; }
|
|
.tag-fail { background: #fee2e2; color: #991b1b; }
|
|
.tag-skip { background: #fef3c7; color: #92400e; }
|
|
|
|
.sdk-case-body { padding: 16px; }
|
|
.sdk-case-desc { font-size: 12px; color: var(--td-text-color-secondary); margin-bottom: 10px; line-height: 1.5; }
|
|
.sdk-logs {
|
|
background: #1f2937;
|
|
color: #d1d5db;
|
|
font-size: 12px;
|
|
line-height: 1.7;
|
|
padding: 14px;
|
|
border-radius: 6px;
|
|
max-height: 240px;
|
|
overflow-y: auto;
|
|
white-space: pre-wrap;
|
|
word-break: break-all;
|
|
font-family: 'SF Mono', Consolas, Menlo, monospace;
|
|
}
|
|
.sdk-log-line.log-pass { color: #6ee7b7; }
|
|
.sdk-log-line.log-fail { color: #fca5a5; }
|
|
.sdk-log-line.log-info { color: #93c5fd; }
|
|
.sdk-log-line.log-warn { color: #fcd34d; }
|
|
|
|
@media (max-width: 768px) {
|
|
.sdk-panel { display: none; }
|
|
}
|
|
</style>
|