4 Commits

Author SHA1 Message Date
wanghanlin c22d044e5c fix(sdk): 修复 marked 列表渲染异常导致回复挤成一行 2 weeks ago
wanghanlin 0205af7b72 fix(app): SSE 流式输出保留行尾空白避免 Markdown 标题损坏 2 weeks ago
wanghanlin 0953e9481b chatsdk 对markdown解析器进行重构 2 weeks ago
wanghanlin 7ce8028d73 重构客服角色管理页面排版 2 weeks ago
  1. 1
      .gitignore
  2. 4
      client/CLAUDE.md
  3. 6
      client/README.md
  4. BIN
      client/assets/launcher-logo.png
  5. 69
      client/assets/marked.min.js
  6. 5700
      client/dist/chatbot-sdk.js
  7. 1
      client/dist/chatbot-sdk.min.js
  8. 1
      client/rollup.config.js
  9. 5
      client/src/index.ts
  10. 480
      client/src/markdown.ts
  11. 12
      client/src/styles.ts
  12. 200
      client/tests/markdown.test.ts
  13. 572
      frontend/src/views/RoleManager.vue
  14. 66
      src/main/java/com/wok/supportbot/app/AssistantApp.java
  15. 5
      src/main/java/com/wok/supportbot/controller/CustomerServiceRoleController.java
  16. 30
      src/main/java/com/wok/supportbot/service/CustomerServiceRoleService.java
  17. 5700
      src/main/resources/static/sdk/chatbot-sdk.js
  18. 1
      src/main/resources/static/sdk/chatbot-sdk.min.js
  19. BIN
      src/main/resources/static/sdk/launcher-logo.png

1
.gitignore

@ -46,3 +46,4 @@ build/
/src/main/resources/static/index.html /src/main/resources/static/index.html
/logs/ /logs/
/node/ /node/
/src/main/resources/static/sdk/

4
client/CLAUDE.md

@ -68,7 +68,7 @@ init(rawConfig)
- **`api.ts`**:HTTP 封装 + SSE 流解析。三种 SSE 接口(普通流式、RAG 流式、同步)的解析逻辑都在这里,注意流式解析的边界处理。 - **`api.ts`**:HTTP 封装 + SSE 流解析。三种 SSE 接口(普通流式、RAG 流式、同步)的解析逻辑都在这里,注意流式解析的边界处理。
- **`dom.ts`**:纯 DOM 构建与事件绑定,含知识库下拉、RAG 来源卡片、历史会话面板的渲染。 - **`dom.ts`**:纯 DOM 构建与事件绑定,含知识库下拉、RAG 来源卡片、历史会话面板的渲染。
- **`styles.ts`**:所有 CSS 字符串模板,按 `primaryColor` 动态着色。改动 UI 视觉改这里,**不要**在 `dom.ts` 里写内联样式。 - **`styles.ts`**:所有 CSS 字符串模板,按 `primaryColor` 动态着色。改动 UI 视觉改这里,**不要**在 `dom.ts` 里写内联样式。
- **`markdown.ts`**零依赖轻量 Markdown 渲染器。**安全模型**:所有非代码块内容先 HTML 转义再转换语法,代码块内容同样转义;链接只允许 http/https。修改渲染器时必须维持这一 XSS 防护顺序。
- **`markdown.ts`**基于 marked(源文件 `assets/marked.min.js`,实际为 **v15.0.12**,构建时由 rollup 自动复制到 `dist/` 与后端 `static/sdk/`)的 Markdown 渲染器,`preloadMarked` 异步加载并缓存安全实例。**安全模型**:自定义 renderer 全接管输出,链接/图片仅允许 http/https,禁止 raw HTML,代码内容 escapeHtml 转义;修改渲染器时必须维持这一 XSS 防护顺序。**列表渲染坑**:`list(token)` 渲染列表项必须调用 `this.listitem(item)`,**不可** `this.parser.parse([item])` —— marked v15 的 `Parser.parse``token.type` 做 switch 分发、无 `list_item` 分支,直接 parse 会抛 `Token with "list_item" type was not found`,被 `renderMarkdown` 的 catch 降级为纯文本,导致整段回复挤成一行。
### 单例与生命周期 ### 单例与生命周期
@ -102,4 +102,4 @@ init(rawConfig)
## 部署 ## 部署
构建后将 `dist/chatbot-sdk.min.js` 上传到后端 `src/main/resources/static/sdk/`(或 CDN),宿主页面通过 `<script src="/sdk/chatbot-sdk.min.js"></script>` 引入。`README.md` 有完整接入示例与全部配置参数表,文档是 SDK 对外契约的一部分,改公开 API(`init/destroy/open/close/toggle/clearHistory`)或 `SDKConfig` 字段时同步更新 README。
构建脚本(`rollup.config.js` 的 `copyAssets` 插件)会自动把 `chatbot-sdk.js` / `chatbot-sdk.min.js`、`marked.min.js`(运行时依赖,由 `markdown.ts` 动态加载)和 `launcher-logo.png` 复制到后端 `src/main/resources/static/sdk/`,宿主页面通过 `<script src="/sdk/chatbot-sdk.min.js"></script>` 引入;CDN 分发时需同时部署这三类文件,且 `marked.min.js` 必须与 SDK 同目录(`getSdkBasePath()` 从 script src 推断加载路径)。`README.md` 有完整接入示例与全部配置参数表,文档是 SDK 对外契约的一部分,改公开 API(`init/destroy/open/close/toggle/clearHistory`)或 `SDKConfig` 字段时同步更新 README。

6
client/README.md

@ -133,6 +133,8 @@ AI 回复支持 Markdown 格式渲染,包括:
> **XSS 安全**:所有非代码块内容先转义 HTML,再转换 Markdown 语法为安全 HTML 标签。代码块内容同样转义。 > **XSS 安全**:所有非代码块内容先转义 HTML,再转换 Markdown 语法为安全 HTML 标签。代码块内容同样转义。
> **实现说明**:渲染基于 marked v15.0.12(外部脚本 `marked.min.js`,SDK 运行时异步加载,未就绪时降级为纯文本)。自定义 renderer 的列表项须用 `this.listitem(item)` 渲染,不能 `this.parser.parse([item])` —— marked v15 的 `Parser.parse``list_item` 分支,会抛异常并降级为纯文本,导致整段回复换行折叠成一行。
### 4.3 知识库联动 ### 4.3 知识库联动
**方式一:直接启用 RAG(无需分类选择)** **方式一:直接启用 RAG(无需分类选择)**
@ -352,7 +354,7 @@ z-index 分层:悬浮按钮 9998,弹窗 9999。
- **语言**:TypeScript → ES2017 - **语言**:TypeScript → ES2017
- **打包**:Rollup → IIFE 格式 - **打包**:Rollup → IIFE 格式
- **压缩**:Terser - **压缩**:Terser
- **Markdown**内置轻量级渲染器(零外部依赖
- **Markdown**基于 marked v15.0.12(`assets/marked.min.js`,构建时自动复制到 `static/sdk/`
- **国际化**:内置 i18n 字典 - **国际化**:内置 i18n 字典
- **浏览器兼容**:Chrome 70+, Firefox 70+, Safari 13+, Edge 79+ - **浏览器兼容**:Chrome 70+, Firefox 70+, Safari 13+, Edge 79+
- **产物大小**:`.min.js` ~45KB(gzip ~15KB) - **产物大小**:`.min.js` ~45KB(gzip ~15KB)
@ -389,7 +391,7 @@ client/
│ ├── dom.ts # DOM 构建 + 拖拽 + P1 来源/分类 + P2 面板 │ ├── dom.ts # DOM 构建 + 拖拽 + P1 来源/分类 + P2 面板
│ ├── styles.ts # CSS 注入 + 主题 + P1 Markdown/来源 + P2 面板 │ ├── styles.ts # CSS 注入 + 主题 + P1 Markdown/来源 + P2 面板
│ ├── chat.ts # 对话核心 + P1 Markdown/RAG + P2 i18n │ ├── chat.ts # 对话核心 + P1 Markdown/RAG + P2 i18n
│ ├── markdown.ts # P1 轻量级 Markdown 渲染器
│ ├── markdown.ts # P1 Markdown 渲染器(基于 marked v15)
│ ├── i18n.ts # P2 多语言国际化 │ ├── i18n.ts # P2 多语言国际化
│ └── utils.ts # UUID、防抖、XSS 转义 │ └── utils.ts # UUID、防抖、XSS 转义
├── dist/ # 构建产物 ├── dist/ # 构建产物

BIN
client/assets/launcher-logo.png

Before

Width: 1024  |  Height: 1024  |  Size: 87 KiB

After

Width: 1254  |  Height: 1254  |  Size: 178 KiB

69
client/assets/marked.min.js
File diff suppressed because it is too large
View File

5700
client/dist/chatbot-sdk.js
File diff suppressed because it is too large
View File

1
client/dist/chatbot-sdk.min.js
File diff suppressed because it is too large
View File

1
client/rollup.config.js

@ -9,6 +9,7 @@ const BACKEND_DIR = resolve(ROOT, '../src/main/resources/static/sdk');
const ASSETS = [ const ASSETS = [
{ src: resolve(ROOT, 'assets/launcher-logo.png'), name: 'launcher-logo.png' }, { src: resolve(ROOT, 'assets/launcher-logo.png'), name: 'launcher-logo.png' },
{ src: resolve(ROOT, 'assets/marked.min.js'), name: 'marked.min.js' },
]; ];
const BUNDLE_FILES = ['chatbot-sdk.js', 'chatbot-sdk.min.js']; const BUNDLE_FILES = ['chatbot-sdk.js', 'chatbot-sdk.min.js'];

5
client/src/index.ts

@ -12,6 +12,7 @@ import { parseConfig } from './config';
import { setDebug, logger, setErrorCallback } from './logger'; import { setDebug, logger, setErrorCallback } from './logger';
import { setApiConfig, clearApiConfig, fetchSystemConfig } from './api'; import { setApiConfig, clearApiConfig, fetchSystemConfig } from './api';
import { injectStyles, removeStyles } from './styles'; import { injectStyles, removeStyles } from './styles';
import { preloadMarked, resetMarked } from './markdown';
import { createLauncher, createChatWindow, enableDrag, enableLauncherDrag, enableResize } from './dom'; import { createLauncher, createChatWindow, enableDrag, enableLauncherDrag, enableResize } from './dom';
import { initChat, initChatHistory, setCategory, loadHistoryConversations, sendQuickReply, retryFromMessage, handleFeedback, switchRole } from './chat'; import { initChat, initChatHistory, setCategory, loadHistoryConversations, sendQuickReply, retryFromMessage, handleFeedback, switchRole } from './chat';
import { clearMessages } from './storage'; import { clearMessages } from './storage';
@ -93,6 +94,9 @@ function init(rawConfig: SDKConfig): void {
// 5. 注入样式 // 5. 注入样式
injectStyles(config); injectStyles(config);
// 5.1 预加载 marked(外部 Markdown 渲染库,异步不阻塞 UI)
preloadMarked();
// 6. 创建悬浮按钮 // 6. 创建悬浮按钮
launcherEl = createLauncher(config, toggle); launcherEl = createLauncher(config, toggle);
document.body.appendChild(launcherEl); document.body.appendChild(launcherEl);
@ -417,6 +421,7 @@ function destroy(): void {
audioCtx = null; audioCtx = null;
disclaimerEl = null; disclaimerEl = null;
clearApiConfig(); clearApiConfig();
resetMarked(); // 重置 marked 加载状态,允许下次 init 重试
logger.lifecycleDestroy(oldIntegrateId || ''); logger.lifecycleDestroy(oldIntegrateId || '');
} }

480
client/src/markdown.ts

@ -1,290 +1,242 @@
/** /**
* Markdown - XSS
* Markdown - marked
* *
*
* HTML Markdown HTML
* marked v15.0.12 SDK 宿
*
*
* XSS
* 1. Renderer / SDK
* 2. / URL http/https
* 3. raw HTML
* 4. 使 escapeHtml
*/ */
import { escapeHtml } from './utils'; import { escapeHtml } from './utils';
/** 代码块占位符前缀 */
const CODE_BLOCK_PREFIX = '\x00CODEBLOCK_';
/** marked 是否已加载并完成配置 */
let markedReady = false;
/** 行内代码占位符前缀 */
const INLINE_CODE_PREFIX = '\x00INLINECODE_';
/** 加载 Promise,用于等待就绪 */
let markedLoadPromise: Promise<boolean> | null = null;
/**
* Markdown HTML
* @param text Markdown
* @returns HTML
*/
export function renderMarkdown(text: string): string {
if (!text || typeof text !== 'string') return '';
/** 已配置安全的 marked 实例闭包引用(不反复读取 window.marked,防止篡改) */
let _markedInstance: any = null;
// 1. 提取代码块(防止内部 Markdown 被处理)
const codeBlocks: string[] = [];
let processed = text;
// 提取围栏代码块 ```
processed = processed.replace(/```(\w*)\n([\s\S]*?)```/g, (_match, lang, code) => {
const idx = codeBlocks.length;
const escapedCode = escapeHtml(code.trimEnd());
const langClass = lang ? ` class="language-${escapeHtml(lang)}"` : '';
codeBlocks.push(`<pre class="csk-md-code-block"><code${langClass}>${escapedCode}</code></pre>`);
return `${CODE_BLOCK_PREFIX}${idx}\x00`;
});
// 2. 提取行内代码
const inlineCodes: string[] = [];
processed = processed.replace(/`([^`\n]+)`/g, (_match, code) => {
const idx = inlineCodes.length;
inlineCodes.push(`<code class="csk-md-inline-code">${escapeHtml(code)}</code>`);
return `${INLINE_CODE_PREFIX}${idx}\x00`;
});
// 3. 转义剩余 HTML(代码块和行内代码已安全处理)
processed = escapeHtml(processed);
// 4. 还原代码块和行内代码占位符(它们已经是安全 HTML)
processed = restorePlaceholders(processed, CODE_BLOCK_PREFIX, codeBlocks);
processed = restorePlaceholders(processed, INLINE_CODE_PREFIX, inlineCodes);
// 5. 逐行处理 Markdown 语法
const lines = processed.split('\n');
const result: string[] = [];
let inList = false;
let listType = ''; // 'ul' 或 'ol'
let inBlockquote = false;
let paragraphBuffer: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// 代码块已在占位符还原阶段处理,直接输出
if (line.includes(CODE_BLOCK_PREFIX) || line.includes('<pre class="csk-md-code-block">')) {
flushParagraph();
closeList();
closeBlockquote();
result.push(line);
continue;
}
// 标题
// Accept standard headings and common no-space forms before CJK text or digits.
const headingMatch = line.match(/^ {0,3}(#{1,6})(?:\s+|(?=[\u3400-\u9FFF\d]))(.+)/);
if (headingMatch) {
flushParagraph();
closeList();
closeBlockquote();
const level = headingMatch[1].length;
result.push(`<h${level} class="csk-md-h${level}">${headingMatch[2]}</h${level}>`);
continue;
}
// 引用
// GFM table: require a valid separator row to avoid matching ordinary pipe text.
if (i + 1 < lines.length && isTableSeparator(lines[i + 1])) {
const headers = splitTableRow(line);
const separators = splitTableRow(lines[i + 1]);
if (headers.length > 0 && headers.length === separators.length) {
flushParagraph();
closeList();
closeBlockquote();
const alignments = separators.map(getTableAlignment);
result.push('<div class="csk-md-table-wrap"><table class="csk-md-table">');
result.push('<thead><tr>');
headers.forEach((cell, index) => {
result.push(`<th${alignmentAttr(alignments[index])}>${inlineFormat(cell)}</th>`);
});
result.push('</tr></thead><tbody>');
i += 2;
while (i < lines.length && isTableDataRow(lines[i])) {
const cells = splitTableRow(lines[i]);
result.push('<tr>');
headers.forEach((_header, index) => {
result.push(`<td${alignmentAttr(alignments[index])}>${inlineFormat(cells[index] || '')}</td>`);
});
result.push('</tr>');
i++;
}
// ==================== 工具函数 ====================
result.push('</tbody></table></div>');
i--;
continue;
}
/** 从当前页面的 chatbot-sdk 脚本 src 推断 SDK 基础路径 */
function getSdkBasePath(): string {
if (typeof document === 'undefined') return '/sdk/';
const scripts = document.getElementsByTagName('script');
for (let i = 0; i < scripts.length; i++) {
const src = scripts[i].src;
if (src && /\/chatbot-sdk(\.min)?\.js([?#].*)?$/.test(src)) {
return src.replace(/chatbot-sdk(\.min)?\.js.*$/, '');
} }
}
return '/sdk/';
}
const quoteMatch = line.match(/^ {0,3}&gt;\s?(.*)/);
if (quoteMatch) {
flushParagraph();
closeList();
if (!inBlockquote) {
inBlockquote = true;
result.push('<blockquote class="csk-md-blockquote">');
}
result.push(`<p>${inlineFormat(quoteMatch[1])}</p>`);
continue;
} else if (inBlockquote) {
closeBlockquote();
}
/** 校验 URL 安全性:仅允许 http/https 且不含 HTML 属性破坏字符 */
function isSafeUrl(url: string): boolean {
return /^https?:\/\/[^\s"'<>]*$/i.test(url.trim());
}
// 无序列表
const ulMatch = line.match(/^ {0,3}[\-\*]\s+(.+)/);
if (ulMatch) {
flushParagraph();
closeBlockquote();
if (!inList || listType !== 'ul') {
closeList();
inList = true;
listType = 'ul';
result.push('<ul class="csk-md-ul">');
}
result.push(`<li>${inlineFormat(ulMatch[1])}</li>`);
continue;
}
// ==================== marked 配置 ====================
// 有序列表
const olMatch = line.match(/^ {0,3}\d+\.\s+(.+)/);
if (olMatch) {
flushParagraph();
closeBlockquote();
if (!inList || listType !== 'ol') {
closeList();
inList = true;
listType = 'ol';
result.push('<ol class="csk-md-ol">');
}
result.push(`<li>${inlineFormat(olMatch[1])}</li>`);
continue;
}
/**
* marked
* - 使 csk-md-* styles.ts
* - / http/https
* - raw HTML
*/
function setupMarked(m: any): void {
// 保存到闭包变量,后续 renderMarkdown 只使用此引用,防止 window.marked 被篡改
_markedInstance = m;
// 全局选项:聊天场景保留换行,启用 GFM
m.setOptions({ breaks: true, gfm: true });
m.use({
renderer: {
/** 标题 → <hN class="csk-md-hN"> */
heading(this: any, token: any): string {
const text = this.parser.parseInline(token.tokens);
return `<h${token.depth} class="csk-md-h${token.depth}">${text}</h${token.depth}>\n`;
},
/** 段落 → <p class="csk-md-p"> */
paragraph(this: any, token: any): string {
const text = this.parser.parseInline(token.tokens);
return `<p class="csk-md-p">${text}</p>\n`;
},
/** 代码块 → <pre class="csk-md-code-block"><code> */
code(token: any): string {
const langClass = token.lang ? ` class="language-${escapeHtml(token.lang)}"` : '';
return `<pre class="csk-md-code-block"><code${langClass}>${escapeHtml(token.text)}</code></pre>\n`;
},
/** 行内代码 → <code class="csk-md-inline-code"> */
codespan(token: any): string {
return `<code class="csk-md-inline-code">${escapeHtml(token.text)}</code>`;
},
/** 斜体(补充 csk-md-* 类名体系) */
em(this: any, token: any): string {
return `<em class="csk-md-em">${this.parser.parseInline(token.tokens)}</em>`;
},
/** 粗体(补充 csk-md-* 类名体系) */
strong(this: any, token: any): string {
return `<strong class="csk-md-strong">${this.parser.parseInline(token.tokens)}</strong>`;
},
/** 删除线(补充 csk-md-* 类名体系) */
del(this: any, token: any): string {
return `<del class="csk-md-del">${this.parser.parseInline(token.tokens)}</del>`;
},
/** 引用块 → <blockquote class="csk-md-blockquote"> */
blockquote(this: any, token: any): string {
const body = this.parser.parse(token.tokens);
return `<blockquote class="csk-md-blockquote">\n${body}</blockquote>\n`;
},
/** 列表 → <ul class="csk-md-ul"> / <ol class="csk-md-ol"> */
list(this: any, token: any): string {
const tag = token.ordered ? 'ol' : 'ul';
const cls = token.ordered ? 'csk-md-ol' : 'csk-md-ul';
const startAttr = token.ordered && token.start !== 1 ? ` start="${token.start}"` : '';
// 列表项须用 this.listitem(item) 渲染,不可 this.parser.parse([item]):
// marked v15 的 Parser.parse 按 token.type 做 switch 分发,无 "list_item" 分支,直接 parse 会抛异常
const body = token.items.map((item: any) => this.listitem(item)).join('');
return `<${tag} class="${cls}"${startAttr}>\n${body}</${tag}>\n`;
},
/** 列表项(支持任务列表 checkbox) */
listitem(this: any, token: any): string {
let checkbox = '';
if (token.task) {
checkbox = token.checked
? '<input type="checkbox" checked disabled> '
: '<input type="checkbox" disabled> ';
}
const body = this.parser.parse(token.tokens);
return `<li>${checkbox}${body}</li>\n`;
},
/** 表格(包裹在可滚动容器中) */
table(this: any, token: any): string {
const headerCells = token.header.map((cell: any) => {
const align = cell.align ? ` class="csk-md-align-${cell.align}"` : '';
return `<th${align}>${this.parser.parseInline(cell.tokens)}</th>`;
}).join('');
const bodyRows = token.rows.map((row: any) => {
const cells = row.map((cell: any) => {
const align = cell.align ? ` class="csk-md-align-${cell.align}"` : '';
return `<td${align}>${this.parser.parseInline(cell.tokens)}</td>`;
}).join('');
return `<tr>${cells}</tr>`;
}).join('');
return `<div class="csk-md-table-wrap"><table class="csk-md-table">\n<thead><tr>${headerCells}</tr></thead>\n<tbody>${bodyRows}</tbody>\n</table></div>\n`;
},
/** 链接 → <a class="csk-md-link">(仅允许 http/https) */
link(this: any, token: any): string {
const safeHref = isSafeUrl(token.href) ? token.href : '#';
const titleAttr = token.title ? ` title="${escapeHtml(token.title)}"` : '';
const text = this.parser.parseInline(token.tokens);
return `<a class="csk-md-link" href="${safeHref}"${titleAttr} target="_blank" rel="noopener noreferrer">${text}</a>`;
},
/** 图片 → <img class="csk-md-img">(仅允许 http/https,非法降级为 alt 文本) */
image(token: any): string {
if (!isSafeUrl(token.href)) return escapeHtml(token.text);
const titleAttr = token.title ? ` title="${escapeHtml(token.title)}"` : '';
return `<img class="csk-md-img" src="${token.href}" alt="${escapeHtml(token.text)}"${titleAttr}>`;
},
/** 水平线 → <hr class="csk-md-hr"> */
hr(): string {
return '<hr class="csk-md-hr">\n';
},
/** 禁止 raw HTML(关键 XSS 防线) */
html(): string {
return '';
},
},
});
}
// 空行 → 段落分隔
if (line.trim() === '') {
flushParagraph();
closeList();
continue;
}
// ==================== 公开 API ====================
// 水平线
if (/^(\*{3,}|-{3,}|_{3,})$/.test(line.trim())) {
flushParagraph();
closeList();
closeBlockquote();
result.push('<hr class="csk-md-hr">');
continue;
/**
* marked
* SDK init
*/
export function preloadMarked(): void {
if (markedLoadPromise) return;
markedLoadPromise = new Promise<boolean>((resolve) => {
// 1. 检查是否已存在(宿主手动引入或相同域名已加载)
const existing = (window as any).marked;
if (existing && typeof existing.parse === 'function') {
setupMarked(existing);
markedReady = true;
resolve(true);
return;
} }
// 普通文本 → 收集到段落缓冲
closeList();
closeBlockquote();
paragraphBuffer.push(inlineFormat(line));
}
flushParagraph();
closeList();
closeBlockquote();
return result.join('\n');
// === 辅助函数 ===
/** 行内格式化:粗体、斜体、链接 */
function inlineFormat(text: string): string {
// 粗体 **text** 或 __text__
text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
text = text.replace(/__(.+?)__/g, '<strong>$1</strong>');
// 斜体 *text* 或 _text_
text = text.replace(/\*(.+?)\*/g, '<em>$1</em>');
text = text.replace(/(?<!\w)_(.+?)_(?!\w)/g, '<em>$1</em>');
// 删除线 ~~text~~
text = text.replace(/~~(.+?)~~/g, '<del>$1</del>');
// 链接 [text](url)
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, linkText, url) => {
// 只允许 http/https 链接,防止 javascript: 协议
const safeUrl = /^https?:\/\//i.test(url) ? url : '#';
return `<a class="csk-md-link" href="${safeUrl}" target="_blank" rel="noopener noreferrer">${linkText}</a>`;
});
return text;
}
/** 将段落缓冲输出为 <p> */
function isTableSeparator(line: string): boolean {
const cells = splitTableRow(line);
return cells.length > 0 && cells.every(cell => /^:?-{3,}:?$/.test(cell.trim()));
}
function isTableDataRow(line: string): boolean {
return line.trim() !== '' && line.includes('|');
}
function splitTableRow(line: string): string[] {
const trimmed = line.trim().replace(/^\|/, '').replace(/\|$/, '');
if (!trimmed.includes('|')) return [];
const cells: string[] = [];
let cell = '';
for (let i = 0; i < trimmed.length; i++) {
const char = trimmed[i];
if (char === '\\' && trimmed[i + 1] === '|') {
cell += '|';
i++;
} else if (char === '|') {
cells.push(cell.trim());
cell = '';
} else {
cell += char;
// 2. 动态创建 <script> 加载 marked
const basePath = getSdkBasePath();
const url = basePath + 'marked.min.js';
const el = document.createElement('script');
el.src = url;
el.onload = () => {
const m = (window as any).marked;
if (m && typeof m.parse === 'function') {
setupMarked(m);
markedReady = true;
} }
}
cells.push(cell.trim());
return cells;
}
function getTableAlignment(separator: string): '' | 'left' | 'center' | 'right' {
const value = separator.trim();
if (value.startsWith(':') && value.endsWith(':')) return 'center';
if (value.endsWith(':')) return 'right';
if (value.startsWith(':')) return 'left';
return '';
}
function alignmentAttr(alignment: '' | 'left' | 'center' | 'right'): string {
return alignment ? ` class="csk-md-align-${alignment}"` : '';
}
resolve(markedReady);
};
el.onerror = () => {
resolve(false);
};
document.head.appendChild(el);
});
}
function flushParagraph(): void {
if (paragraphBuffer.length > 0) {
result.push(`<p class="csk-md-p">${paragraphBuffer.join('<br>')}</p>`);
paragraphBuffer = [];
}
}
/**
* Markdown HTML
*
* @param text Markdown
* @returns HTML
*
* marked GFM
* / <p>
*/
export function renderMarkdown(text: string): string {
if (!text || typeof text !== 'string') return '';
/** 关闭列表 */
function closeList(): void {
if (inList) {
result.push(listType === 'ul' ? '</ul>' : '</ol>');
inList = false;
listType = '';
}
// marked 未就绪 → 降级纯文本
if (!markedReady || !_markedInstance) {
return `<p class="csk-md-p">${escapeHtml(text)}</p>`;
} }
/** 关闭引用块 */
function closeBlockquote(): void {
if (inBlockquote) {
result.push('</blockquote>');
inBlockquote = false;
}
try {
return _markedInstance.parse(text) as string;
} catch {
// 解析异常降级
return `<p class="csk-md-p">${escapeHtml(text)}</p>`;
} }
} }
/** 还原占位符为安全 HTML */
function restorePlaceholders(text: string, prefix: string, replacements: string[]): string {
return text.replace(new RegExp(escapeRegex(prefix) + '(\\d+)\x00', 'g'), (_m, idx) => {
return replacements[parseInt(idx)] || '';
});
}
/** 转义正则特殊字符 */
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
/** 导出复位函数,供 destroy() 调用,允许重新 init 时重试加载 marked */
export function resetMarked(): void {
markedReady = false;
_markedInstance = null;
markedLoadPromise = null;
} }

12
client/src/styles.ts

@ -118,9 +118,9 @@ function getStyles(config: ResolvedConfig): string {
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.15)); filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.15));
} }
.csk-launcher img { .csk-launcher img {
width: 36px;
height: 36px;
object-fit: contain;
width: 100%;
height: 100%;
object-fit: cover;
position: relative; position: relative;
z-index: 1; z-index: 1;
-webkit-user-drag: none; -webkit-user-drag: none;
@ -841,6 +841,11 @@ function getStyles(config: ResolvedConfig): string {
.csk-md-table .csk-md-align-center { text-align: center; } .csk-md-table .csk-md-align-center { text-align: center; }
.csk-md-table .csk-md-align-right { text-align: right; } .csk-md-table .csk-md-align-right { text-align: right; }
/* marked 新增元素 */
.csk-md-img { max-width: 100%; height: auto; display: block; margin: 6px 0; border-radius: 8px; }
.csk-msg--ai .csk-msg__bubble .csk-md-ul input[type="checkbox"],
.csk-msg--ai .csk-msg__bubble .csk-md-ol input[type="checkbox"] { margin-right: 6px; vertical-align: middle; }
/* ========== P2: 会话管理面板 ========== */ /* ========== P2: 会话管理面板 ========== */
.csk-history-panel { .csk-history-panel {
position: absolute; position: absolute;
@ -1282,6 +1287,7 @@ function getStyles(config: ResolvedConfig): string {
.csk-dark .csk-md-link { color: var(--csk-primary-light); } .csk-dark .csk-md-link { color: var(--csk-primary-light); }
.csk-dark .csk-md-table th { background: rgba(var(--csk-primary-rgb), 0.14); color: #E2E8F0; } .csk-dark .csk-md-table th { background: rgba(var(--csk-primary-rgb), 0.14); color: #E2E8F0; }
.csk-dark .csk-md-table tbody tr:nth-child(even) { background: rgba(148, 163, 184, 0.05); } .csk-dark .csk-md-table tbody tr:nth-child(even) { background: rgba(148, 163, 184, 0.05); }
.csk-dark .csk-md-img { opacity: 0.9; }
.csk-dark .csk-msg--ai .csk-msg__bubble { background: var(--csk-bg-ai); color: var(--csk-text-ai); border-color: var(--csk-border); } .csk-dark .csk-msg--ai .csk-msg__bubble { background: var(--csk-bg-ai); color: var(--csk-text-ai); border-color: var(--csk-border); }
.csk-dark .csk-welcome__title { color: #E2E8F0; } .csk-dark .csk-welcome__title { color: #E2E8F0; }
.csk-dark .csk-welcome__desc { color: #6B7280; } .csk-dark .csk-welcome__desc { color: #6B7280; }

200
client/tests/markdown.test.ts

@ -1,200 +0,0 @@
/**
* markdown.ts Markdown
*
*
* 1.
* 2. XSS http/https
*/
import { describe, it, expect } from 'vitest';
import { renderMarkdown } from '../src/markdown';
describe('renderMarkdown - 基础语法', () => {
it('空字符串返回空', () => {
expect(renderMarkdown('')).toBe('');
});
it('null/undefined 返回空', () => {
expect(renderMarkdown(null as never)).toBe('');
expect(renderMarkdown(undefined as never)).toBe('');
});
it('普通文本渲染为段落', () => {
expect(renderMarkdown('你好世界')).toContain('<p class="csk-md-p">你好世界</p>');
});
it('标题渲染 h1-h6', () => {
expect(renderMarkdown('# 标题一')).toContain('<h1 class="csk-md-h1">');
expect(renderMarkdown('## 标题二')).toContain('<h2 class="csk-md-h2">');
expect(renderMarkdown('### 标题三')).toContain('<h3 class="csk-md-h3">');
});
it('粗体渲染', () => {
expect(renderMarkdown('**粗体文本**')).toContain('<strong>粗体文本</strong>');
});
it('斜体渲染', () => {
expect(renderMarkdown('*斜体文本*')).toContain('<em>斜体文本</em>');
});
it('删除线渲染', () => {
expect(renderMarkdown('~~删除文本~~')).toContain('<del>删除文本</del>');
});
it('无序列表渲染', () => {
const result = renderMarkdown('- 项目一\n- 项目二');
expect(result).toContain('<ul class="csk-md-ul">');
expect(result).toContain('<li>');
expect(result).toContain('项目一');
expect(result).toContain('项目二');
});
it('有序列表渲染', () => {
const result = renderMarkdown('1. 第一步\n2. 第二步');
expect(result).toContain('<ol class="csk-md-ol">');
expect(result).toContain('<li>');
});
it('引用渲染', () => {
const result = renderMarkdown('> 这是引用');
expect(result).toContain('<blockquote class="csk-md-blockquote">');
expect(result).toContain('这是引用');
});
it('链接渲染(http 协议)', () => {
const result = renderMarkdown('[百度](https://www.baidu.com)');
expect(result).toContain('href="https://www.baidu.com"');
expect(result).toContain('target="_blank"');
expect(result).toContain('rel="noopener noreferrer"');
});
it('水平线渲染', () => {
expect(renderMarkdown('---')).toContain('<hr class="csk-md-hr">');
});
});
describe('renderMarkdown - 代码', () => {
it('行内代码渲染', () => {
const result = renderMarkdown('使用 `console.log` 调试');
expect(result).toContain('<code class="csk-md-inline-code">console.log</code>');
});
it('代码块渲染', () => {
const result = renderMarkdown('```js\nconsole.log("hello");\n```');
expect(result).toContain('<pre class="csk-md-code-block">');
expect(result).toContain('class="language-js"');
expect(result).toContain('console.log');
});
it('代码块内容不被 Markdown 处理', () => {
const result = renderMarkdown('```\n**粗体** *斜体*\n```');
// 代码块内的 Markdown 语法不应被转换
expect(result).toContain('**粗体**');
expect(result).toContain('*斜体*');
expect(result).not.toContain('<strong>');
});
});
describe('renderMarkdown - XSS 防护', () => {
it('转义 HTML 标签', () => {
const result = renderMarkdown('<script>alert("xss")</script>');
expect(result).not.toContain('<script>');
expect(result).toContain('&lt;script&gt;');
});
it('转义 img 标签的 onerror', () => {
const result = renderMarkdown('<img src=x onerror=alert(1)>');
// < 被转义为 &lt;,标签不会执行,onerror 变为纯文本
expect(result).not.toContain('<img');
expect(result).toContain('&lt;img');
});
it('链接只允许 http/https 协议', () => {
const result = renderMarkdown('[恶意](javascript:alert(1))');
expect(result).toContain('href="#"');
expect(result).not.toContain('javascript:');
});
it('允许 http 链接', () => {
const result = renderMarkdown('[安全](http://example.com)');
expect(result).toContain('href="http://example.com"');
});
it('代码块内容同样被转义', () => {
const result = renderMarkdown('```\n<script>alert(1)</script>\n```');
expect(result).not.toContain('<script>alert');
expect(result).toContain('&lt;script&gt;');
});
it('行内代码内容同样被转义', () => {
const result = renderMarkdown('`<img src=x>`');
expect(result).toContain('&lt;img');
expect(result).not.toContain('<img');
});
});
describe('renderMarkdown - 复合内容', () => {
it('混合标题和段落', () => {
const result = renderMarkdown('# 标题\n\n这是正文');
expect(result).toContain('<h1');
expect(result).toContain('<p class="csk-md-p">这是正文</p>');
});
it('多段落正确分隔', () => {
const result = renderMarkdown('第一段\n\n第二段');
expect(result).toContain('第一段');
expect(result).toContain('第二段');
// 应有两个 <p> 标签
const pCount = (result.match(/<p class="csk-md-p">/g) || []).length;
expect(pCount).toBe(2);
});
});
describe('renderMarkdown - GFM tables and tolerant headings', () => {
it('accepts a no-space heading before CJK text', () => {
expect(renderMarkdown('##\u4e00\u3001\u5e72\u51bb\u8d27/\u751f\u9c9c\u6a21\u5757')).toContain(
'<h2 class="csk-md-h2">\u4e00\u3001\u5e72\u51bb\u8d27/\u751f\u9c9c\u6a21\u5757</h2>',
);
});
it('accepts a no-space heading before a digit', () => {
expect(renderMarkdown('##1 purchase warehouse')).toContain(
'<h2 class="csk-md-h2">1 purchase warehouse</h2>',
);
});
it('accepts up to three leading spaces for block syntax', () => {
const result = renderMarkdown(
' ## 1\uFE0F\u20E3 purchase warehouse\n\n - **field**: warehouseid',
);
expect(result).toContain('<h2 class="csk-md-h2">1\uFE0F\u20E3 purchase warehouse</h2>');
expect(result).toContain('<ul class="csk-md-ul">');
expect(result).toContain('<strong>field</strong>');
});
it('does not treat an English hashtag as a heading', () => {
expect(renderMarkdown('#topic')).toContain('<p class="csk-md-p">#topic</p>');
});
it('renders a GFM table with alignment and inline formatting', () => {
const result = renderMarkdown(
'|\u5b57\u6bb5|\u8bf4\u660e|\u6821\u9a8c\u89c4\u5219|\n|---|:---:|---:|\n|**warehouseId**|ID|required|',
);
expect(result).toContain('<table class="csk-md-table">');
expect(result).toContain('<th>\u5b57\u6bb5</th>');
expect(result).toContain('<th class="csk-md-align-center">\u8bf4\u660e</th>');
expect(result).toContain('<td class="csk-md-align-right">required</td>');
expect(result).toContain('<strong>warehouseId</strong>');
});
it('keeps table cell HTML escaped', () => {
const result = renderMarkdown('|field|note|\n|---|---|\n|name|<img src=x onerror=alert(1)>|');
expect(result).not.toContain('<img');
expect(result).toContain('&lt;img');
});
it('does not treat ordinary pipe text as a table', () => {
const result = renderMarkdown('A | B\nnot a separator');
expect(result).not.toContain('<table');
});
});

572
frontend/src/views/RoleManager.vue

@ -2,88 +2,538 @@
<t-card title="🎭 客服角色管理" :bordered="false"> <t-card title="🎭 客服角色管理" :bordered="false">
<p class="desc-text">为角色设定人设并授权可检索的知识库分类对话页只能选择这里配置好的角色检索范围由服务端按角色强制限定</p> <p class="desc-text">为角色设定人设并授权可检索的知识库分类对话页只能选择这里配置好的角色检索范围由服务端按角色强制限定</p>
<!-- 新增/编辑表单 -->
<div class="card-box form-bar">
<t-row :gutter="12">
<t-col :span="4"><t-input v-model="form.name" placeholder="角色名称 *" size="small" /></t-col>
<t-col :span="4"><t-input v-model="form.description" placeholder="职责描述(可选)" size="small" /></t-col>
<t-col :span="2"><t-checkbox v-model="form.enabled">启用</t-checkbox></t-col>
</t-row>
<t-textarea v-model="form.prompt" :autosize="{minRows:2,maxRows:3}" placeholder="角色人设/风格提示词" style="margin-top:8px;" />
<t-space style="margin-top:8px;">
<t-button theme="success" size="small" @click="submit">{{ editingId?'保存修改':'新增角色' }}</t-button>
<t-button v-show="editingId" variant="outline" size="small" @click="resetForm">取消编辑</t-button>
<t-button variant="outline" size="small" @click="reload">刷新</t-button>
</t-space>
</div>
<!-- 角色列表 -->
<t-empty v-if="!roles.length" description="暂无角色" />
<div v-else>
<div v-for="role in roles" :key="role.id" class="role-card" :style="{opacity:role.enabled?'1':'.6'}">
<div class="role-header">
<div>
<strong class="role-name">{{ role.name }}</strong>
<t-tag v-if="!role.enabled" size="small" theme="danger" variant="light" style="margin-left:6px;">已停用</t-tag>
<div class="role-desc">{{ role.description || '—' }}</div>
</div>
<t-space :size="4">
<t-button size="small" variant="text" @click="startEdit(role)">编辑</t-button>
<t-button size="small" variant="text" theme="danger" @click="remove(role)">删除</t-button>
<div class="role-shell">
<!-- ===== 左侧角色列表 ===== -->
<aside class="role-sidebar">
<div class="sidebar-header">
<span class="sidebar-title">角色列表</span>
<t-space :size="6">
<t-button theme="primary" size="small" @click="startCreate"> 新增</t-button>
<t-button variant="outline" size="small" @click="handleRefresh">刷新</t-button>
</t-space> </t-space>
</div> </div>
<div v-if="role.prompt" class="role-prompt">人设{{ role.prompt }}</div>
<div class="role-categories">
<span class="cat-label">可检索知识库</span>
<t-checkbox v-for="c in categoryStore.categories" :key="c.id"
:checked="role.categoryIds.includes(String(c.id))"
@change="toggleCategory(role, String(c.id), ($event as any))">
{{ c.name }}
</t-checkbox>
<span v-if="!categoryStore.categories.length" class="cat-hint">暂无知识库分类</span>
</div>
</div>
<t-loading size="small" :loading="loading" class="sidebar-body">
<t-empty v-if="!roles.length" description="暂无角色" />
<div v-else class="role-items">
<div
v-for="role in roles"
:key="role.id"
:class="['role-item', selectedId === role.id && 'active']"
@click="selectRole(role)"
tabindex="0"
role="button"
:aria-label="'选择角色 ' + role.name"
@keydown.enter="selectRole(role)"
@keydown.space.prevent="selectRole(role)"
>
<div class="role-item-main">
<strong class="role-item-name">{{ role.name }}</strong>
<t-tag size="small" :theme="role.enabled ? 'success' : 'default'" variant="light">
{{ role.enabled ? '启用' : '停用' }}
</t-tag>
</div>
<div class="role-item-desc" :title="role.description || ''">{{ role.description || '—' }}</div>
</div>
</div>
</t-loading>
</aside>
<!-- ===== 右侧编辑区 ===== -->
<section class="role-detail">
<!-- 角色已被删除或不存在 -->
<t-empty v-if="mode === 'edit' && !selectedRole" description="该角色已被删除,请选择其他角色" />
<!-- 未选中且非新建 -->
<t-empty v-else-if="mode === 'view'" description="请选择左侧角色,或点击「+ 新增」创建角色" />
<!-- 新建 / 编辑 -->
<template v-else>
<div class="detail-header">
<div class="detail-title-row">
<h3 class="detail-title">{{ mode === 'create' ? '新增角色' : selectedRole?.name }}</h3>
<t-tag
v-if="mode === 'edit' && selectedRole"
size="small"
:theme="selectedRole.enabled ? 'success' : 'default'"
variant="light"
>
{{ selectedRole.enabled ? '启用' : '停用' }}
</t-tag>
</div>
<t-space :size="6">
<t-button
v-if="mode === 'edit' && selectedRole"
variant="outline"
theme="danger"
size="small"
:loading="deleting"
@click="handleDelete"
>
删除角色
</t-button>
</t-space>
</div>
<t-form label-align="top" class="detail-form">
<t-row :gutter="16">
<t-col :span="8">
<t-form-item label="角色名称 *">
<t-input v-model="form.name" :disabled="saving" placeholder="请输入角色名称" />
</t-form-item>
</t-col>
<t-col :span="4">
<t-form-item label="状态">
<t-switch v-model="form.enabled" :disabled="saving" :label="['启用', '停用']" />
</t-form-item>
</t-col>
</t-row>
<t-form-item label="职责描述">
<t-input v-model="form.description" :disabled="saving" placeholder="职责描述(可选)" />
</t-form-item>
<t-form-item label="人设 / 风格提示词">
<t-textarea
v-model="form.prompt"
:disabled="saving"
:autosize="{ minRows: 4, maxRows: 8 }"
placeholder="为角色设定人设、风格与回答约束,该提示词将注入到 AI 系统 Prompt 中"
/>
</t-form-item>
<t-form-item label="可检索知识库授权">
<t-checkbox-group v-if="categoryStore.categories.length" v-model="form.categoryIds">
<t-checkbox
v-for="c in categoryStore.categories"
:key="c.id"
:value="String(c.id)"
:disabled="saving"
>
{{ c.name }}
</t-checkbox>
</t-checkbox-group>
<span v-else class="desc-text">暂无知识库分类请先在知识库管理中创建分类</span>
</t-form-item>
</t-form>
<div class="detail-footer">
<t-button variant="outline" size="small" :disabled="saving" @click="handleCancel">取消</t-button>
<t-button theme="primary" size="small" :loading="saving" @click="handleSubmit">保存角色</t-button>
</div>
</template>
</section>
</div> </div>
</t-card> </t-card>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useCategoryStore } from '@/stores/category' import { useCategoryStore } from '@/stores/category'
import { getAllRoles, createRole, updateRole, deleteRole, updateRoleCategories } from '@/api/role' import { getAllRoles, createRole, updateRole, deleteRole, updateRoleCategories } from '@/api/role'
import { toast } from '@/utils/toast' import { toast } from '@/utils/toast'
import { useConfirm } from '@/composables/useConfirm' import { useConfirm } from '@/composables/useConfirm'
const { confirm } = useConfirm() const { confirm } = useConfirm()
const categoryStore = useCategoryStore() const categoryStore = useCategoryStore()
// ---- ----
const roles = ref<any[]>([]) const roles = ref<any[]>([])
const editingId = ref('')
const form = reactive({name:'',description:'',prompt:'',enabled:true})
const loading = ref(false)
const saving = ref(false)
const deleting = ref(false)
const mode = ref<'view' | 'create' | 'edit'>('view')
const selectedId = ref<string | null>(null)
const form = reactive({
name: '',
description: '',
prompt: '',
enabled: true,
categoryIds: [] as string[],
})
// ---- ref ----
const snapshot = ref(takeSnapshot())
function takeSnapshot(): string {
return JSON.stringify({
name: form.name,
description: form.description,
prompt: form.prompt,
enabled: form.enabled,
categoryIds: [...form.categoryIds].sort(),
})
}
function resetForm(): void {
form.name = ''
form.description = ''
form.prompt = ''
form.enabled = true
form.categoryIds = []
snapshot.value = takeSnapshot()
}
function setFormFromRole(role: any): void {
form.name = role.name || ''
form.description = role.description || ''
form.prompt = role.prompt || ''
form.enabled = role.enabled !== false
form.categoryIds = [...(role.categoryIds || [])]
snapshot.value = takeSnapshot()
}
const isDirty = computed(() => takeSnapshot() !== snapshot.value)
// ---- ----
const selectedRole = computed(() => roles.value.find(r => r.id === selectedId.value) ?? null)
// ---- ----
onMounted(() => {
reload()
categoryStore.loadCategories()
})
// ---- selectedRole 退 ----
watch(selectedRole, (role) => {
if (!role && mode.value === 'edit') {
mode.value = 'view'
selectedId.value = null
resetForm()
}
})
// ---- ----
async function reload(): Promise<void> {
const currentSelectedId = selectedId.value
loading.value = true
try {
const r = await getAllRoles()
if (r.success) {
roles.value = (r.data || []).map((ro: any) => ({
...ro,
categoryIds: (ro.categoryIds || []).map(String),
}))
//
if (currentSelectedId && selectedId.value === currentSelectedId && mode.value === 'edit') {
const fresh = roles.value.find(x => x.id === currentSelectedId)
if (fresh) {
setFormFromRole(fresh)
}
}
} else {
toast('加载角色列表失败:' + (r.message || '未知错误'), 'error')
}
} catch (e: any) {
toast('加载失败:' + e.message, 'error')
} finally {
loading.value = false
}
}
onMounted(()=>{reload();categoryStore.loadCategories()})
// ---- ----
async function handleRefresh(): Promise<void> {
if (!(await guardDirty('刷新'))) return
await reload()
}
async function reload(){try{const r=await getAllRoles();if(r.success)roles.value=(r.data||[]).map((ro:any)=>({...ro,categoryIds:(ro.categoryIds||[]).map(String)}))}catch(e:any){toast('加载失败:'+e.message,'error')}}
// ---- ----
async function guardDirty(action: string = '切换'): Promise<boolean> {
if (!isDirty.value) return true
return await confirm(`${action}会丢失未保存的修改`, '确定放弃当前修改吗?')
}
function startEdit(role:any){editingId.value=role.id;form.name=role.name;form.description=role.description||'';form.prompt=role.prompt||'';form.enabled=role.enabled}
function resetForm(){editingId.value='';form.name='';form.description='';form.prompt='';form.enabled=true}
// ---- / ----
async function startCreate(): Promise<void> {
if (!(await guardDirty('新建角色'))) return
mode.value = 'create'
selectedId.value = null
resetForm()
}
async function submit(){
if(!form.name.trim()){toast('请输入角色名称','error');return}
const d:any={name:form.name.trim(),description:form.description.trim(),prompt:form.prompt,enabled:form.enabled}
try{let r;if(editingId.value)r=await updateRole(editingId.value,d);else r=await createRole(d)
if(r.success){toast(editingId.value?'修改成功':'新增成功','success');resetForm();reload()}else toast(r.message||'操作失败','error')}catch(e:any){toast('操作失败:'+e.message,'error')}}
async function selectRole(role: any): Promise<void> {
if (selectedId.value === role.id) return
if (!(await guardDirty('切换角色'))) return
mode.value = 'edit'
selectedId.value = role.id
setFormFromRole(role)
}
async function remove(role:any){if(!await confirm(`确定删除「${role.name}」?`))return
try{const r=await deleteRole(role.id);if(r.success){toast('删除成功','success');reload()}else toast(r.message||'删除失败','error')}catch(e:any){toast('删除失败:'+e.message,'error')}}
// ---- ----
function handleCancel(): void {
if (mode.value === 'create') {
mode.value = 'view'
selectedId.value = null
resetForm()
} else if (mode.value === 'edit') {
if (selectedRole.value) {
setFormFromRole(selectedRole.value)
} else {
//
mode.value = 'view'
selectedId.value = null
resetForm()
}
}
}
async function toggleCategory(role:any, catId:string, checked:boolean){
const ids=new Set(role.categoryIds);if(checked)ids.add(catId);else ids.delete(catId)
try{const r=await updateRoleCategories(role.id,Array.from(ids));if(r.success){role.categoryIds=Array.from(ids)}else toast(r.message||'授权失败','error')}catch(e:any){toast('授权失败:'+e.message,'error')}}
// ---- ----
async function handleSubmit(): Promise<void> {
const name = form.name.trim()
if (!name) {
toast('请输入角色名称', 'error')
return
}
saving.value = true
try {
const payload = {
name,
description: form.description.trim(),
prompt: form.prompt.trim(),
enabled: form.enabled,
}
let roleId: string | null = selectedId.value
if (mode.value === 'create') {
const r = await createRole(payload)
if (!r.success) { toast(r.message || '保存失败', 'error'); return }
roleId = r.data?.id || null
if (!roleId) {
// success id
toast('新增成功', 'success')
await reload()
const matched = roles.value.find(x => x.name === name)
if (matched) {
roleId = matched.id
}
}
} else {
if (!roleId) {
toast('系统错误:未找到当前编辑的角色', 'error')
return
}
const r = await updateRole(roleId, payload)
if (!r.success) { toast(r.message || '保存失败', 'error'); return }
}
//
if (roleId) {
const catRes = await updateRoleCategories(roleId, [...form.categoryIds])
if (!catRes.success) {
toast(catRes.message || '保存成功,但知识库授权失败', 'error')
}
} else {
toast('保存成功,但无法获取角色 ID,知识库授权未提交', 'warning')
}
if (mode.value === 'create') {
toast('新增成功', 'success')
} else {
toast('保存成功', 'success')
}
await reload()
//
if (roleId) {
mode.value = 'edit'
selectedId.value = roleId
const updated = roles.value.find(x => x.id === roleId)
if (updated) setFormFromRole(updated)
} else {
mode.value = 'view'
selectedId.value = null
resetForm()
}
} catch (e: any) {
toast('操作失败:' + e.message, 'error')
} finally {
saving.value = false
}
}
// ---- ----
async function handleDelete(): Promise<void> {
const role = selectedRole.value
if (!role) return
if (!(await confirm(`确定删除角色「${role.name}」?`, '删除后不可恢复,已绑定此角色的 API Key 将无法使用。'))) return
deleting.value = true
try {
const r = await deleteRole(role.id)
if (r.success) {
toast('删除成功', 'success')
selectedId.value = null
mode.value = 'view'
resetForm()
await reload()
} else {
toast(r.message || '删除失败', 'error')
}
} catch (e: any) {
toast('删除失败:' + e.message, 'error')
} finally {
deleting.value = false
}
}
</script> </script>
<style scoped> <style scoped>
.role-card{padding:12px;border:1px solid #e7e7e7;border-radius:8px;margin-bottom:10px;}
.role-header{display:flex;justify-content:space-between;align-items:flex-start;gap:8px;}.role-name{font-size:14px;}.role-desc{font-size:12px;color:#999;margin-top:2px;}
.role-prompt{font-size:12px;color:#333;margin-top:6px;background:#f9fafb;border-radius:6px;padding:6px 8px;}
.role-categories{margin-top:8px;font-size:12px;display:flex;flex-wrap:wrap;gap:4px 12px;align-items:center;}.cat-label{color:#999;}.cat-hint{color:#999;}
/* ===== 整体分栏 ===== */
.role-shell {
display: flex;
gap: 16px;
min-height: 520px;
}
/* ===== 左侧列表 ===== */
.role-sidebar {
width: 260px;
flex-shrink: 0;
display: flex;
flex-direction: column;
background: var(--td-bg-color-page);
border: 1px solid var(--td-border-level-1-color);
border-radius: 10px;
overflow: hidden;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px 12px;
border-bottom: 1px solid var(--td-border-level-1-color);
}
.sidebar-title {
font-size: 13px;
font-weight: 600;
color: var(--td-text-color-secondary);
}
.sidebar-body {
flex: 1;
overflow-y: auto;
padding: 10px 12px;
}
.role-items {
display: flex;
flex-direction: column;
gap: 6px;
}
.role-item {
padding: 10px 12px;
border-radius: 8px;
border: 1px solid transparent;
cursor: pointer;
background: var(--td-bg-color-container);
transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
}
.role-item:hover {
background: var(--td-bg-color-component);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
.role-item:focus-visible {
outline: 2px solid var(--td-brand-color);
outline-offset: -2px;
}
.role-item.active {
background: var(--td-brand-color-light);
border-color: var(--td-brand-color);
}
.role-item-main {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 3px;
}
.role-item-name {
font-size: 13px;
color: var(--td-text-color-primary);
}
.role-item-desc {
font-size: 12px;
color: var(--td-text-color-placeholder);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ===== 右侧编辑区 ===== */
.role-detail {
flex: 1;
display: flex;
flex-direction: column;
background: var(--td-bg-color-container);
border: 1px solid var(--td-border-level-1-color);
border-radius: 10px;
padding: 20px 24px;
overflow-y: auto;
}
.detail-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 20px;
padding-bottom: 14px;
border-bottom: 1px solid var(--td-border-level-1-color);
}
.detail-title-row {
display: flex;
align-items: center;
gap: 8px;
}
.detail-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--td-text-color-primary);
}
.detail-form {
flex: 1;
}
.detail-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 24px;
padding-top: 14px;
border-top: 1px solid var(--td-border-level-1-color);
}
/* ===== 响应式 ===== */
@media (max-width: 1024px) {
.role-sidebar {
width: 220px;
}
.role-detail {
padding: 16px 20px;
}
}
@media (max-width: 768px) {
.role-shell {
flex-direction: column;
}
.role-sidebar {
width: auto;
max-height: 260px;
}
}
</style> </style>

66
src/main/java/com/wok/supportbot/app/AssistantApp.java

@ -18,7 +18,9 @@ import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import reactor.core.Disposable;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.FluxSink;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
@ -97,6 +99,9 @@ public class AssistantApp {
private static final String SYSTEM_PROMPT = ""; private static final String SYSTEM_PROMPT = "";
/** 尾部空白缓冲上限:超过后强制发出,避免纯空白输出导致 buffer 无界增长 */
private static final int MAX_TRAILING_WHITESPACE_BUFFER = 256;
/** /**
* 初始化 ChatClient * 初始化 ChatClient
* *
@ -235,7 +240,8 @@ public class AssistantApp {
} }
ChatRequest req = chatPipeline.buildRequest(ctx); ChatRequest req = chatPipeline.buildRequest(ctx);
if (req.faqHit()) { if (req.faqHit()) {
// FAQ 命中直接返回答案文本不附加 SSE 协议行Spring 会自动做 data: 帧包装
// FAQ 命中整段答案原样输出 SSE 编码器处理内部换行
// 后端不做任何格式增删不拆行不加换行不补空格
return Flux.just(req.faqAnswer().get()); return Flux.just(req.faqAnswer().get());
} }
McpToolCallback.resetEvents(); McpToolCallback.resetEvents();
@ -249,7 +255,7 @@ public class AssistantApp {
} }
// 原始文本流推荐问题已不再由主回复同步生成改由 SuggestionGenerator 异步按需生成 // 原始文本流推荐问题已不再由主回复同步生成改由 SuggestionGenerator 异步按需生成
Flux<String> rawStream = spec.stream().content(); Flux<String> rawStream = spec.stream().content();
return rawStream
return preserveTrailingWhitespace(rawStream)
.doOnComplete(() -> aiCircuitBreaker.recordSuccess(AI_CIRCUIT_KEY)) .doOnComplete(() -> aiCircuitBreaker.recordSuccess(AI_CIRCUIT_KEY))
.doOnError(e -> { .doOnError(e -> {
aiCircuitBreaker.recordFailure(AI_CIRCUIT_KEY); aiCircuitBreaker.recordFailure(AI_CIRCUIT_KEY);
@ -263,6 +269,62 @@ public class AssistantApp {
.onErrorResume(e -> Flux.just("抱歉,AI 服务调用失败:" + e.getMessage())); .onErrorResume(e -> Flux.just("抱歉,AI 服务调用失败:" + e.getMessage()));
} }
/**
* 缓冲以空白字符结尾的 chunk将其与下一个 chunk 合并后再发出
* <p>
* 原因前端 SSE 解析会对每行执行 trim()如果 chunk 以空白结尾 Markdown 标题 "## "
* 行尾空白会被削掉导致 "## " + "一、..." 拼成 "##一、..."破坏 Markdown 渲染
* 本方法不改变大模型输出的文本内容只调整 chunk 边界以避开 SSE 的空白截断
*
* @param source 原始大模型输出流
* @return 调整后的流每个元素均不以空白字符结尾流末尾除外
*/
private static Flux<String> preserveTrailingWhitespace(Flux<String> source) {
return Flux.create(sink -> {
StringBuilder buffer = new StringBuilder();
// 捕获内部订阅下游取消/释放时同步取消上游避免资源泄漏
Disposable subscription = source.subscribe(
chunk -> {
if (chunk == null) {
return;
}
buffer.append(chunk);
String current = buffer.toString();
if (current.isEmpty()) {
return;
}
char last = current.charAt(current.length() - 1);
// 当前累积内容不以空白结尾可以安全发出
if (!Character.isWhitespace(last)) {
sink.next(current);
buffer.setLength(0);
} else if (buffer.length() >= MAX_TRAILING_WHITESPACE_BUFFER) {
// 尾部空白过长如纯空白输出强制发出以限制内存占用
sink.next(current);
buffer.setLength(0);
}
// 若以空白结尾继续缓存等下一个 chunk
},
e -> {
// 出错前先把已缓冲内容发出避免已生成文本丢失
if (buffer.length() > 0) {
sink.next(buffer.toString());
buffer.setLength(0);
}
sink.error(e);
},
() -> {
if (buffer.length() > 0) {
sink.next(buffer.toString());
}
sink.complete();
}
);
sink.onCancel(subscription);
sink.onDispose(subscription);
}, FluxSink.OverflowStrategy.BUFFER);
}
/** /**
* 统一检索引用来源新入口委托 {@link ChatPipeline#retrieveSources} * 统一检索引用来源新入口委托 {@link ChatPipeline#retrieveSources}
* *

5
src/main/java/com/wok/supportbot/controller/CustomerServiceRoleController.java

@ -51,10 +51,11 @@ public class CustomerServiceRoleController {
@PreAuthorize("hasRole('admin')") @PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> createRole(@RequestBody Map<String, Object> body) { public ResponseEntity<Map<String, Object>> createRole(@RequestBody Map<String, Object> body) {
try { try {
customerServiceRoleService.createRole(
Long newId = customerServiceRoleService.createRole(
str(body.get("roleKey")), str(body.get("name")), str(body.get("description")), str(body.get("roleKey")), str(body.get("name")), str(body.get("description")),
str(body.get("prompt")), boolOrNull(body.get("enabled"))); str(body.get("prompt")), boolOrNull(body.get("enabled")));
return ResponseEntity.ok(Map.of("success", true, "message", "角色创建成功"));
return ResponseEntity.ok(Map.of("success", true, "message", "角色创建成功",
"data", Map.of("id", newId.toString())));
} catch (Exception e) { } catch (Exception e) {
return ResponseEntity.status(500).body(Map.of("success", false, "message", "创建角色失败:" + e.getMessage())); return ResponseEntity.status(500).body(Map.of("success", false, "message", "创建角色失败:" + e.getMessage()));
} }

30
src/main/java/com/wok/supportbot/service/CustomerServiceRoleService.java

@ -5,9 +5,13 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.sql.PreparedStatement;
import java.sql.Statement;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
@ -97,21 +101,33 @@ public class CustomerServiceRoleService {
/** /**
* 新增角色role_key 缺省时自动生成 * 新增角色role_key 缺省时自动生成
* @return 新建角色的 ID
*/ */
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void createRole(String roleKey, String name, String description, String prompt, Boolean enabled) {
public Long createRole(String roleKey, String name, String description, String prompt, Boolean enabled) {
if (name == null || name.trim().isEmpty()) { if (name == null || name.trim().isEmpty()) {
throw new IllegalArgumentException("角色名称不能为空"); throw new IllegalArgumentException("角色名称不能为空");
} }
String key = (roleKey == null || roleKey.trim().isEmpty()) String key = (roleKey == null || roleKey.trim().isEmpty())
? "role_" + System.currentTimeMillis() ? "role_" + System.currentTimeMillis()
: roleKey.trim(); : roleKey.trim();
jdbcTemplate.update("""
INSERT INTO customer_service_role (role_key, name, description, prompt, enabled)
VALUES (?, ?, ?, ?, ?)
""",
key, name.trim(), description, prompt,
enabled != null ? enabled : true);
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(con -> {
PreparedStatement ps = con.prepareStatement(
"INSERT INTO customer_service_role (role_key, name, description, prompt, enabled) VALUES (?, ?, ?, ?, ?)",
Statement.RETURN_GENERATED_KEYS);
ps.setString(1, key);
ps.setString(2, name.trim());
ps.setString(3, description);
ps.setString(4, prompt);
ps.setBoolean(5, enabled != null ? enabled : true);
return ps;
}, keyHolder);
Number generatedId = keyHolder.getKey();
if (generatedId == null) {
throw new RuntimeException("创建角色失败:无法获取自动生成的 ID");
}
return generatedId.longValue();
} }
/** /**

5700
src/main/resources/static/sdk/chatbot-sdk.js
File diff suppressed because it is too large
View File

1
src/main/resources/static/sdk/chatbot-sdk.min.js
File diff suppressed because it is too large
View File

BIN
src/main/resources/static/sdk/launcher-logo.png

Before

Width: 1024  |  Height: 1024  |  Size: 87 KiB

After

Width: 1254  |  Height: 1254  |  Size: 178 KiB

Loading…
Cancel
Save