本地 RAG 知识库
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

<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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
const re =
/(&lt;!--[\s\S]*?--&gt;)|('[^']*'|"[^"]*")|(\/\/[^\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>