Browse Source

AI回复增加推荐问题功能

TDesign-Vue-Next-1.20.6
wanghanlin 3 weeks ago
parent
commit
bfb5a66393
  1. 160
      client/dist/chatbot-sdk.js
  2. 2
      client/dist/chatbot-sdk.min.js
  3. 25
      client/src/api.ts
  4. 27
      client/src/chat.ts
  5. 1
      client/src/config.ts
  6. 59
      client/src/dom.ts
  7. 40
      client/src/styles.ts
  8. 4
      client/src/types.ts
  9. 23
      frontend/src/api/chat.ts
  10. 26
      frontend/src/views/ChatPanel.vue
  11. 20
      pom.xml
  12. 9
      src/main/java/com/wok/supportbot/app/AssistantApp.java
  13. 12
      src/main/java/com/wok/supportbot/app/ChatPipeline.java
  14. 17
      src/main/java/com/wok/supportbot/app/ChatResult.java
  15. 201
      src/main/java/com/wok/supportbot/app/SuggestionGenerator.java
  16. 285
      src/main/java/com/wok/supportbot/app/SuggestionResponseParser.java
  17. 114
      src/main/java/com/wok/supportbot/cache/SuggestionCache.java
  18. 27
      src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
  19. 28
      src/main/java/com/wok/supportbot/controller/AiController.java
  20. 39
      src/main/java/com/wok/supportbot/controller/ConversationController.java
  21. 31
      src/main/java/com/wok/supportbot/controller/OpenApiController.java
  22. 10
      src/main/resources/logback-spring.xml
  23. 160
      src/main/resources/static/sdk/chatbot-sdk.js
  24. 2
      src/main/resources/static/sdk/chatbot-sdk.min.js

160
client/dist/chatbot-sdk.js

@ -102,7 +102,7 @@ var ChatbotSDK = (function () {
* 解析并校验用户传入的配置填充默认值 * 解析并校验用户传入的配置填充默认值
*/ */
function parseConfig(raw) { function parseConfig(raw) {
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m;
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o;
// 校验必传参数:integrateId(对应后端 roleId) // 校验必传参数:integrateId(对应后端 roleId)
if (!raw.integrateId || (typeof raw.integrateId !== 'string' && typeof raw.integrateId !== 'number') if (!raw.integrateId || (typeof raw.integrateId !== 'string' && typeof raw.integrateId !== 'number')
|| (typeof raw.integrateId === 'string' && raw.integrateId.trim() === '')) { || (typeof raw.integrateId === 'string' && raw.integrateId.trim() === '')) {
@ -118,7 +118,7 @@ var ChatbotSDK = (function () {
try { try {
new URL(raw.requestDomain); new URL(raw.requestDomain);
} }
catch (_o) {
catch (_p) {
logger.error(`requestDomain 不是合法的 URL 格式:${raw.requestDomain}。请提供完整的域名,如 https://api.example.com`); logger.error(`requestDomain 不是合法的 URL 格式:${raw.requestDomain}。请提供完整的域名,如 https://api.example.com`);
return null; return null;
} }
@ -145,18 +145,19 @@ var ChatbotSDK = (function () {
quickReplies: Array.isArray(raw.quickReplies) quickReplies: Array.isArray(raw.quickReplies)
? raw.quickReplies.map(s => String(s).trim()).filter(Boolean) ? raw.quickReplies.map(s => String(s).trim()).filter(Boolean)
: [], : [],
suggestions: (_f = raw.suggestions) !== null && _f !== void 0 ? _f : true,
theme: raw.theme === 'dark' ? 'dark' : 'light', theme: raw.theme === 'dark' ? 'dark' : 'light',
showTeaser: (_f = raw.showTeaser) !== null && _f !== void 0 ? _f : true,
showTeaser: (_g = raw.showTeaser) !== null && _g !== void 0 ? _g : true,
teaserText: (typeof raw.teaserText === 'string' && raw.teaserText.trim()) || '', teaserText: (typeof raw.teaserText === 'string' && raw.teaserText.trim()) || '',
resizable: (_g = raw.resizable) !== null && _g !== void 0 ? _g : true,
resizable: (_h = raw.resizable) !== null && _h !== void 0 ? _h : true,
watermark: (typeof raw.watermark === 'string' && raw.watermark.trim()) || undefined, watermark: (typeof raw.watermark === 'string' && raw.watermark.trim()) || undefined,
streaming: (_h = raw.streaming) !== null && _h !== void 0 ? _h : true,
enableRag: (_j = raw.enableRag) !== null && _j !== void 0 ? _j : true,
streaming: (_j = raw.streaming) !== null && _j !== void 0 ? _j : true,
enableRag: (_k = raw.enableRag) !== null && _k !== void 0 ? _k : true,
rewriteStrategy: raw.rewriteStrategy || 'REWRITE', rewriteStrategy: raw.rewriteStrategy || 'REWRITE',
locale: raw.locale || 'zh-CN', locale: raw.locale || 'zh-CN',
debug: (_k = raw.debug) !== null && _k !== void 0 ? _k : true,
sound: (_l = raw.sound) !== null && _l !== void 0 ? _l : false,
notification: (_m = raw.notification) !== null && _m !== void 0 ? _m : false,
debug: (_l = raw.debug) !== null && _l !== void 0 ? _l : true,
sound: (_m = raw.sound) !== null && _m !== void 0 ? _m : false,
notification: (_o = raw.notification) !== null && _o !== void 0 ? _o : false,
onError: typeof raw.onError === 'function' ? raw.onError : undefined, onError: typeof raw.onError === 'function' ? raw.onError : undefined,
onReady: typeof raw.onReady === 'function' ? raw.onReady : undefined, onReady: typeof raw.onReady === 'function' ? raw.onReady : undefined,
onMessage: typeof raw.onMessage === 'function' ? raw.onMessage : undefined, onMessage: typeof raw.onMessage === 'function' ? raw.onMessage : undefined,
@ -966,6 +967,33 @@ var ChatbotSDK = (function () {
return ''; return '';
} }
} }
/**
* 获取 AI 推荐问题列表suggest-message-list
* AI 回复结束后调用从后端缓存中读取本次对话生成的建议问题
* @param chatId 当前对话 ID
* @returns 推荐问题数组最多 3 接口失败或功能关闭时返回空数组
*/
async function fetchSuggestions(chatId) {
if (!currentConfig || !chatId)
return [];
try {
const params = new URLSearchParams();
params.set('chatId', chatId);
const url = buildUrl(`/ai/suggestions?${params.toString()}`);
const response = await safeFetch(url);
if (!response.ok)
return [];
const json = await response.json();
if (json.success && json.data && Array.isArray(json.data.suggestions)) {
return json.data.suggestions.filter(s => typeof s === 'string' && s.trim().length > 0);
}
return [];
}
catch (_a) {
// 静默降级,不阻塞对话
return [];
}
}
let styleElement = null; let styleElement = null;
/** 将 hex 颜色解析为 "r, g, b" 字符串,用于 rgba() 拼接 */ /** 将 hex 颜色解析为 "r, g, b" 字符串,用于 rgba() 拼接 */
@ -1952,6 +1980,44 @@ var ChatbotSDK = (function () {
} }
.csk-quick-reply:active { transform: scale(0.96); } .csk-quick-reply:active { transform: scale(0.96); }
/* ========== 推荐问题(suggest-message-list) ========== */
.csk-suggestions {
margin-top: 10px;
padding: 8px 0 0 0;
border-top: 1px solid var(--csk-border);
}
.csk-suggestions__label {
font-size: 12px;
color: #9CA3AF;
margin-bottom: 6px;
}
.csk-suggestions__list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.csk-suggestion-item {
border: 1px solid var(--csk-border);
background: #fff;
color: #374151;
font-size: 12px;
padding: 6px 12px;
border-radius: 999px;
cursor: pointer;
font-family: inherit;
line-height: 1.3;
transition: all 0.18s ease;
max-width: 100%;
text-align: left;
}
.csk-suggestion-item:hover {
border-color: var(--csk-primary);
color: var(--csk-primary);
background: rgba(var(--csk-primary-rgb), 0.06);
transform: translateY(-1px);
}
.csk-suggestion-item:active { transform: scale(0.96); }
/* ========== AI 消息操作条 ========== */ /* ========== AI 消息操作条 ========== */
.csk-msg__actions { .csk-msg__actions {
display: flex; display: flex;
@ -2203,6 +2269,8 @@ var ChatbotSDK = (function () {
.csk-dark .csk-welcome__desc { color: #6B7280; } .csk-dark .csk-welcome__desc { color: #6B7280; }
.csk-dark .csk-quick-reply { background: var(--csk-bg-app); border-color: var(--csk-border); color: #CBD5E1; } .csk-dark .csk-quick-reply { background: var(--csk-bg-app); border-color: var(--csk-border); color: #CBD5E1; }
.csk-dark .csk-quick-reply:hover { background: rgba(var(--csk-primary-rgb), 0.12); border-color: var(--csk-primary); color: var(--csk-primary-light); } .csk-dark .csk-quick-reply:hover { background: rgba(var(--csk-primary-rgb), 0.12); border-color: var(--csk-primary); color: var(--csk-primary-light); }
.csk-dark .csk-suggestion-item { background: var(--csk-bg-app); border-color: var(--csk-border); color: #CBD5E1; }
.csk-dark .csk-suggestion-item:hover { background: rgba(var(--csk-primary-rgb), 0.12); border-color: var(--csk-primary); color: var(--csk-primary-light); }
.csk-dark .csk-teaser { background: #2D2D48; color: #E2E8F0; box-shadow: 0 4px 20px rgba(0,0,0,0.35); } .csk-dark .csk-teaser { background: #2D2D48; color: #E2E8F0; box-shadow: 0 4px 20px rgba(0,0,0,0.35); }
.csk-dark .csk-teaser::after { border-top-color: #2D2D48; } .csk-dark .csk-teaser::after { border-top-color: #2D2D48; }
.csk-dark .csk-teaser__close { color: #6B7280; } .csk-dark .csk-teaser__close { color: #6B7280; }
@ -3645,6 +3713,55 @@ var ChatbotSDK = (function () {
if (el) if (el)
el.remove(); el.remove();
} }
// ==================== 建议问题(suggest-message-list) ====================
/**
* AI 消息气泡下方渲染推荐问题列表
* @param wrapper AI 消息的 wrapper 元素
* @param suggestions 推荐问题文本数组
* @param onClick 点击回调传入问题文本
*/
function renderSuggestions(wrapper, suggestions, onClick) {
// 移除已有的建议区域
removeSuggestions(wrapper);
if (!suggestions || suggestions.length === 0)
return;
const section = document.createElement('div');
section.className = 'csk-suggestions';
const label = document.createElement('div');
label.className = 'csk-suggestions__label';
label.textContent = '💡 推荐问题';
const list = document.createElement('div');
list.className = 'csk-suggestions__list';
for (const text of suggestions) {
const chip = document.createElement('button');
chip.type = 'button';
chip.className = 'csk-suggestion-item';
chip.textContent = text;
chip.addEventListener('click', (e) => {
e.stopPropagation();
onClick(text);
});
list.appendChild(chip);
}
section.appendChild(label);
section.appendChild(list);
// 插入到操作栏和来源卡片之间(在 content 内末尾)
const content = wrapper.querySelector('.csk-msg__content');
if (content) {
content.appendChild(section);
}
else {
wrapper.appendChild(section);
}
}
/**
* 移除指定 wrapper 中的建议问题列表
*/
function removeSuggestions(wrapper) {
const existing = wrapper.querySelector('.csk-suggestions');
if (existing)
existing.remove();
}
const STORAGE_PREFIX = 'csk_history_'; const STORAGE_PREFIX = 'csk_history_';
const MAX_MESSAGES = 200; const MAX_MESSAGES = 200;
@ -4370,6 +4487,10 @@ var ChatbotSDK = (function () {
// RAG 引用来源 // RAG 引用来源
if (shouldUseRag) if (shouldUseRag)
fetchAndRenderSources(userText, aiMsg); fetchAndRenderSources(userText, aiMsg);
// 拉取并展示推荐问题(suggest-message-list)
if (config$1.suggestions) {
fetchAndShowSuggestions(aiMsgId);
}
// 发送成功后清除离线横幅(网络已恢复) // 发送成功后清除离线横幅(网络已恢复)
hideOfflineBanner(); hideOfflineBanner();
} }
@ -4795,6 +4916,27 @@ var ChatbotSDK = (function () {
} }
updateEmptyState(); updateEmptyState();
} }
// ==================== 建议问题(suggest-message-list) ====================
/**
* 拉取并展示 AI 推荐问题
* AI 回复成功后调用从后端缓存中读取本次对话生成的建议问题
*/
async function fetchAndShowSuggestions(aiMsgId) {
const chatId = getChatId();
if (!chatId || !messagesContainer$1)
return;
try {
const suggestions = await fetchSuggestions(chatId);
if (suggestions.length > 0 && messagesContainer$1) {
const wrapper = messagesContainer$1.querySelector(`[data-csk-msg-id="${aiMsgId}"]`);
if (wrapper)
renderSuggestions(wrapper, suggestions, sendQuickReply);
}
}
catch (_a) {
// 静默降级
}
}
// ==================== 单例状态 ==================== // ==================== 单例状态 ====================
let config = null; let config = null;

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

25
client/src/api.ts

@ -714,3 +714,28 @@ export async function fetchSystemConfig(key: string): Promise<string> {
return ''; return '';
} }
} }
/**
* AI suggest-message-list
* AI
* @param chatId ID
* @returns 3
*/
export async function fetchSuggestions(chatId: string): Promise<string[]> {
if (!currentConfig || !chatId) return [];
try {
const params = new URLSearchParams();
params.set('chatId', chatId);
const url = buildUrl(`/ai/suggestions?${params.toString()}`);
const response = await safeFetch(url);
if (!response.ok) return [];
const json: ApiResponse<{ suggestions: string[] }> = await response.json();
if (json.success && json.data && Array.isArray(json.data.suggestions)) {
return json.data.suggestions.filter(s => typeof s === 'string' && s.trim().length > 0);
}
return [];
} catch {
// 静默降级,不阻塞对话
return [];
}
}

27
client/src/chat.ts

@ -24,6 +24,7 @@ import {
setActiveRoleId, setActiveRoleId,
getActiveIntegrateId, getActiveIntegrateId,
CskError, CskError,
fetchSuggestions,
} from './api'; } from './api';
import { import {
renderUserBubble, renderUserBubble,
@ -37,6 +38,7 @@ import {
updateFeedbackUI, updateFeedbackUI,
showOfflineBanner, showOfflineBanner,
hideOfflineBanner, hideOfflineBanner,
renderSuggestions,
HistoryItemData, HistoryItemData,
} from './dom'; } from './dom';
import { saveMessages, loadMessages, clearMessages } from './storage'; import { saveMessages, loadMessages, clearMessages } from './storage';
@ -486,6 +488,11 @@ async function produceAIReply(userText: string): Promise<void> {
// RAG 引用来源 // RAG 引用来源
if (shouldUseRag) fetchAndRenderSources(userText, aiMsg); if (shouldUseRag) fetchAndRenderSources(userText, aiMsg);
// 拉取并展示推荐问题(suggest-message-list)
if (config.suggestions) {
fetchAndShowSuggestions(aiMsgId);
}
// 发送成功后清除离线横幅(网络已恢复) // 发送成功后清除离线横幅(网络已恢复)
hideOfflineBanner(); hideOfflineBanner();
} catch (err) { } catch (err) {
@ -942,3 +949,23 @@ export async function switchToConversation(conversationId: string): Promise<void
export function getMessages(): ChatMessage[] { export function getMessages(): ChatMessage[] {
return messages; return messages;
} }
// ==================== 建议问题(suggest-message-list) ====================
/**
* AI
* AI
*/
async function fetchAndShowSuggestions(aiMsgId: string): Promise<void> {
const chatId = getChatId();
if (!chatId || !messagesContainer) return;
try {
const suggestions = await fetchSuggestions(chatId);
if (suggestions.length > 0 && messagesContainer) {
const wrapper = messagesContainer.querySelector(`[data-csk-msg-id="${aiMsgId}"]`) as HTMLElement;
if (wrapper) renderSuggestions(wrapper, suggestions, sendQuickReply);
}
} catch {
// 静默降级
}
}

1
client/src/config.ts

@ -62,6 +62,7 @@ export function parseConfig(raw: SDKConfig): ResolvedConfig | null {
quickReplies: Array.isArray(raw.quickReplies) quickReplies: Array.isArray(raw.quickReplies)
? raw.quickReplies.map(s => String(s).trim()).filter(Boolean) ? raw.quickReplies.map(s => String(s).trim()).filter(Boolean)
: [], : [],
suggestions: raw.suggestions ?? true,
theme: raw.theme === 'dark' ? 'dark' : 'light', theme: raw.theme === 'dark' ? 'dark' : 'light',
showTeaser: raw.showTeaser ?? true, showTeaser: raw.showTeaser ?? true,
teaserText: (typeof raw.teaserText === 'string' && raw.teaserText.trim()) || '', teaserText: (typeof raw.teaserText === 'string' && raw.teaserText.trim()) || '',

59
client/src/dom.ts

@ -1342,3 +1342,62 @@ export function hideOfflineBanner(): void {
if (el) el.remove(); if (el) el.remove();
offlineBannerEl = null; offlineBannerEl = null;
} }
// ==================== 建议问题(suggest-message-list) ====================
/**
* AI
* @param wrapper AI wrapper
* @param suggestions
* @param onClick
*/
export function renderSuggestions(
wrapper: HTMLElement,
suggestions: string[],
onClick: (text: string) => void
): void {
// 移除已有的建议区域
removeSuggestions(wrapper);
if (!suggestions || suggestions.length === 0) return;
const section = document.createElement('div');
section.className = 'csk-suggestions';
const label = document.createElement('div');
label.className = 'csk-suggestions__label';
label.textContent = '💡 推荐问题';
const list = document.createElement('div');
list.className = 'csk-suggestions__list';
for (const text of suggestions) {
const chip = document.createElement('button');
chip.type = 'button';
chip.className = 'csk-suggestion-item';
chip.textContent = text;
chip.addEventListener('click', (e) => {
e.stopPropagation();
onClick(text);
});
list.appendChild(chip);
}
section.appendChild(label);
section.appendChild(list);
// 插入到操作栏和来源卡片之间(在 content 内末尾)
const content = wrapper.querySelector('.csk-msg__content');
if (content) {
content.appendChild(section);
} else {
wrapper.appendChild(section);
}
}
/**
* wrapper
*/
export function removeSuggestions(wrapper: HTMLElement): void {
const existing = wrapper.querySelector('.csk-suggestions');
if (existing) existing.remove();
}

40
client/src/styles.ts

@ -995,6 +995,44 @@ function getStyles(config: ResolvedConfig): string {
} }
.csk-quick-reply:active { transform: scale(0.96); } .csk-quick-reply:active { transform: scale(0.96); }
/* ========== 推荐问题(suggest-message-list) ========== */
.csk-suggestions {
margin-top: 10px;
padding: 8px 0 0 0;
border-top: 1px solid var(--csk-border);
}
.csk-suggestions__label {
font-size: 12px;
color: #9CA3AF;
margin-bottom: 6px;
}
.csk-suggestions__list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.csk-suggestion-item {
border: 1px solid var(--csk-border);
background: #fff;
color: #374151;
font-size: 12px;
padding: 6px 12px;
border-radius: 999px;
cursor: pointer;
font-family: inherit;
line-height: 1.3;
transition: all 0.18s ease;
max-width: 100%;
text-align: left;
}
.csk-suggestion-item:hover {
border-color: var(--csk-primary);
color: var(--csk-primary);
background: rgba(var(--csk-primary-rgb), 0.06);
transform: translateY(-1px);
}
.csk-suggestion-item:active { transform: scale(0.96); }
/* ========== AI 消息操作条 ========== */ /* ========== AI 消息操作条 ========== */
.csk-msg__actions { .csk-msg__actions {
display: flex; display: flex;
@ -1246,6 +1284,8 @@ function getStyles(config: ResolvedConfig): string {
.csk-dark .csk-welcome__desc { color: #6B7280; } .csk-dark .csk-welcome__desc { color: #6B7280; }
.csk-dark .csk-quick-reply { background: var(--csk-bg-app); border-color: var(--csk-border); color: #CBD5E1; } .csk-dark .csk-quick-reply { background: var(--csk-bg-app); border-color: var(--csk-border); color: #CBD5E1; }
.csk-dark .csk-quick-reply:hover { background: rgba(var(--csk-primary-rgb), 0.12); border-color: var(--csk-primary); color: var(--csk-primary-light); } .csk-dark .csk-quick-reply:hover { background: rgba(var(--csk-primary-rgb), 0.12); border-color: var(--csk-primary); color: var(--csk-primary-light); }
.csk-dark .csk-suggestion-item { background: var(--csk-bg-app); border-color: var(--csk-border); color: #CBD5E1; }
.csk-dark .csk-suggestion-item:hover { background: rgba(var(--csk-primary-rgb), 0.12); border-color: var(--csk-primary); color: var(--csk-primary-light); }
.csk-dark .csk-teaser { background: #2D2D48; color: #E2E8F0; box-shadow: 0 4px 20px rgba(0,0,0,0.35); } .csk-dark .csk-teaser { background: #2D2D48; color: #E2E8F0; box-shadow: 0 4px 20px rgba(0,0,0,0.35); }
.csk-dark .csk-teaser::after { border-top-color: #2D2D48; } .csk-dark .csk-teaser::after { border-top-color: #2D2D48; }
.csk-dark .csk-teaser__close { color: #6B7280; } .csk-dark .csk-teaser__close { color: #6B7280; }

4
client/src/types.ts

@ -52,6 +52,8 @@ export interface SDKConfig {
showAdminPanel?: boolean; showAdminPanel?: boolean;
/** 欢迎态快捷问题列表,点击即自动发送,默认空数组 */ /** 欢迎态快捷问题列表,点击即自动发送,默认空数组 */
quickReplies?: string[]; quickReplies?: string[];
/** 是否在 AI 回复后展示推荐问题(suggest-message-list),默认 true */
suggestions?: boolean;
/** 主题模式,默认 'light' */ /** 主题模式,默认 'light' */
theme?: 'light' | 'dark'; theme?: 'light' | 'dark';
/** 是否显示首访提示气泡(延迟 1.5s 弹出),默认 true */ /** 是否显示首访提示气泡(延迟 1.5s 弹出),默认 true */
@ -132,6 +134,8 @@ export interface ResolvedConfig {
showAdminPanel: boolean; showAdminPanel: boolean;
/** 欢迎态快捷问题列表 */ /** 欢迎态快捷问题列表 */
quickReplies: string[]; quickReplies: string[];
/** 是否在 AI 回复后展示推荐问题 */
suggestions: boolean;
/** 主题模式 */ /** 主题模式 */
theme: 'light' | 'dark'; theme: 'light' | 'dark';
/** 是否显示首访提示气泡 */ /** 是否显示首访提示气泡 */

23
frontend/src/api/chat.ts

@ -51,3 +51,26 @@ export function ragSources(message: string, chatId: string, strategy: string, ro
if (accountId) path += `&accountId=${encodeURIComponent(accountId)}` if (accountId) path += `&accountId=${encodeURIComponent(accountId)}`
return request.get(path).then(r => r.data) return request.get(path).then(r => r.data)
} }
/**
* AI ChatPanel
* /conversation/{id}/suggestions JwtAuthFilter
*
* @param chatId ID conversationId
* @returns
*/
export async function fetchSuggestions(chatId: string): Promise<string[]> {
if (!chatId) return []
try {
const resp = await fetch(`${API_BASE}/conversation/${encodeURIComponent(chatId)}/suggestions`, {
headers: authHeaders(),
})
const json = await resp.json()
if (json.success && json.data && Array.isArray(json.data.suggestions)) {
return json.data.suggestions.filter((s: unknown) => typeof s === 'string' && s.trim().length > 0)
}
return []
} catch {
return []
}
}

26
frontend/src/views/ChatPanel.vue

@ -109,6 +109,19 @@
<!-- 引用来源 --> <!-- 引用来源 -->
<MessageSources v-if="m.role === 'assistant' && m.sources && m.sources.length" :sources="m.sources" /> <MessageSources v-if="m.role === 'assistant' && m.sources && m.sources.length" :sources="m.sources" />
<!-- 推荐问题suggest-message-list -->
<div class="suggest-row" v-if="i === messages.length - 1 && currentSuggestions.length && !isSending">
<span class="suggest-label">💡 推荐问题</span>
<t-tag
v-for="q in currentSuggestions"
:key="q"
theme="default"
variant="light"
class="quick-tag"
@click="useQuickQuestion(q)"
>{{ q }}</t-tag>
</div>
<!-- 消息操作栏 --> <!-- 消息操作栏 -->
<div class="msg-tools"> <div class="msg-tools">
<span>{{ m.time }}</span> <span>{{ m.time }}</span>
@ -147,7 +160,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, nextTick, onMounted, onBeforeUnmount } from 'vue' import { ref, computed, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { chatSync, chatRagSync, chatSSEUrl, chatRagSSEUrl, ragSources } from '@/api/chat'
import { chatSync, chatRagSync, chatSSEUrl, chatRagSSEUrl, ragSources, fetchSuggestions } from '@/api/chat'
import { getRoleList } from '@/api/role' import { getRoleList } from '@/api/role'
import { getActiveModelConfig } from '@/api/model-config' import { getActiveModelConfig } from '@/api/model-config'
import { truncateConversation } from '@/api/conversation' import { truncateConversation } from '@/api/conversation'
@ -203,6 +216,7 @@ const msgAreaRef = ref<HTMLElement | null>(null)
const editingIndex = ref(-1) const editingIndex = ref(-1)
const editingText = ref('') const editingText = ref('')
let sseAbortController: AbortController | null = null let sseAbortController: AbortController | null = null
const currentSuggestions = ref<string[]>([]) // suggest-message-list
// ==================== ==================== // ==================== ====================
interface ChatMessage { interface ChatMessage {
@ -294,6 +308,7 @@ function providerLabel(provider: string): string {
function selectRole(roleKey: string): void { function selectRole(roleKey: string): void {
selectedRole.value = roleKey selectedRole.value = roleKey
newChatId() newChatId()
currentSuggestions.value = [] //
const role = roles.value.find(r => r.key === roleKey) || FALLBACK_ROLE const role = roles.value.find(r => r.key === roleKey) || FALLBACK_ROLE
messages.value = [{ messages.value = [{
id: generateMsgId(), id: generateMsgId(),
@ -366,6 +381,7 @@ async function send(): Promise<void> {
userInput.value = '' userInput.value = ''
lastUserInput.value = text lastUserInput.value = text
isSending.value = true isSending.value = true
currentSuggestions.value = [] //
// //
messages.value.push({ messages.value.push({
@ -447,6 +463,14 @@ async function send(): Promise<void> {
} finally { } finally {
assistantMsg.streaming = false assistantMsg.streaming = false
isSending.value = false isSending.value = false
// suggest-message-listAbortError /
if (!sseAbortController?.signal.aborted && assistantMsg.content && !assistantMsg.error) {
fetchSuggestions(chatId.value).then(items => {
if (items.length) currentSuggestions.value = items
}).catch(() => {})
}
await scrollToBottom() await scrollToBottom()
} }
} }

20
pom.xml

@ -8,11 +8,10 @@
<version>3.4.4</version> <version>3.4.4</version>
<relativePath/> <relativePath/>
</parent> </parent>
<groupId>com.yupi</groupId>
<artifactId>yu-ai-agent</artifactId>
<groupId>com.cdw</groupId>
<artifactId>chat-bot</artifactId>
<version>0.0.1-SNAPSHOT</version> <version>0.0.1-SNAPSHOT</version>
<name>yu-ai-agent</name>
<description>yu-ai-agent</description>
<name>智能客服</name>
<properties> <properties>
<java.version>17</java.version> <java.version>17</java.version>
<spring-ai-alibaba.version>1.0.0.4</spring-ai-alibaba.version> <spring-ai-alibaba.version>1.0.0.4</spring-ai-alibaba.version>
@ -41,19 +40,6 @@
<profiles.active>prod</profiles.active> <profiles.active>prod</profiles.active>
</properties> </properties>
</profile> </profile>
<!-- 禁用 Spring Boot 父 POM 自带的 GraalVM 原生镜像 profile(IDEA 面板不再显示) -->
<profile>
<id>native</id>
<activation>
<property><name>skipNativeProfile</name></property>
</activation>
</profile>
<profile>
<id>nativeTest</id>
<activation>
<property><name>skipNativeTestProfile</name></property>
</activation>
</profile>
</profiles> </profiles>
<dependencyManagement> <dependencyManagement>
<dependencies> <dependencies>

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

@ -191,7 +191,9 @@ public class AssistantApp {
} }
String text = spec.call().chatResponse().getResult().getOutput().getText(); String text = spec.call().chatResponse().getResult().getOutput().getText();
aiCircuitBreaker.recordSuccess(AI_CIRCUIT_KEY); aiCircuitBreaker.recordSuccess(AI_CIRCUIT_KEY);
return new ChatResult(text, McpToolCallback.drainEvents());
// 推荐问题已不再由主回复同步生成改由 SuggestionGenerator 异步按需生成
return new ChatResult(text, McpToolCallback.drainEvents(), List.of());
} catch (Exception e) { } catch (Exception e) {
aiCircuitBreaker.recordFailure(AI_CIRCUIT_KEY); aiCircuitBreaker.recordFailure(AI_CIRCUIT_KEY);
log.error("AI 同步调用失败: chatId={}, error={}", ctx.chatId(), e.getMessage()); log.error("AI 同步调用失败: chatId={}, error={}", ctx.chatId(), e.getMessage());
@ -228,8 +230,9 @@ public class AssistantApp {
if (StringUtils.hasText(req.finalSystemPrompt())) { if (StringUtils.hasText(req.finalSystemPrompt())) {
spec = spec.system(req.finalSystemPrompt()); spec = spec.system(req.finalSystemPrompt());
} }
// 直接返回纯文本流不附加任何 SSE 协议行
return spec.stream().content()
// 原始文本流推荐问题已不再由主回复同步生成改由 SuggestionGenerator 异步按需生成
Flux<String> rawStream = spec.stream().content();
return 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);

12
src/main/java/com/wok/supportbot/app/ChatPipeline.java

@ -72,7 +72,7 @@ public class ChatPipeline {
// 意图路由先用 IntentRouter 做细粒度分类 // 意图路由先用 IntentRouter 做细粒度分类
IntentRouter.IntentResult intent = routeIntent(ctx.message()); IntentRouter.IntentResult intent = routeIntent(ctx.message());
// FAQ 高置信度仅走 FAQ 三级匹配不降级 RAG避免 LLM 代为生成不准确的答案
// FAQ 高置信度优先匹配标准答案未命中时降级到 RAG 检索避免知识库中已有答案却返回兜底提示
if (intent != null && "FAQ".equals(intent.getIntent()) if (intent != null && "FAQ".equals(intent.getIntent())
&& intent.getConfidence() >= FAQ_HIGH_CONFIDENCE_THRESHOLD) { && intent.getConfidence() >= FAQ_HIGH_CONFIDENCE_THRESHOLD) {
Optional<String> faqAnswer = ragPipeline.tryFaqMatch(ctx.message()); Optional<String> faqAnswer = ragPipeline.tryFaqMatch(ctx.message());
@ -80,9 +80,7 @@ public class ChatPipeline {
log.info("FAQ 高置信({}),命中标准答案: chatId={}", intent.getConfidence(), ctx.chatId()); log.info("FAQ 高置信({}),命中标准答案: chatId={}", intent.getConfidence(), ctx.chatId());
return new ChatRequest(ctx, ctx.message(), baseSystem, faqAnswer); return new ChatRequest(ctx, ctx.message(), baseSystem, faqAnswer);
} }
log.info("FAQ 意图高置信({})但未命中,返回兜底提示: chatId={}", intent.getConfidence(), ctx.chatId());
return new ChatRequest(ctx, ctx.message(), baseSystem,
Optional.of("抱歉,我未能找到与您问题精确匹配的答案。建议您换个方式描述您的问题,或联系人工客服获取帮助。"));
log.info("FAQ 高置信({}) 未命中标准答案,降级到 RAG 检索: chatId={}", intent.getConfidence(), ctx.chatId());
} }
// 寒暄/闲聊IntentRouter 判定 CHITCHAT 高置信跳过 KB 检索 // 寒暄/闲聊IntentRouter 判定 CHITCHAT 高置信跳过 KB 检索
@ -164,7 +162,10 @@ public class ChatPipeline {
/** /**
* 组合系统提示词全局系统提示词 system_config 动态读取 + 角色人设 * 组合系统提示词全局系统提示词 system_config 动态读取 + 角色人设
* 修改 system_config config_key = 'ai_system_prompt' 的配置后即时生效无需重启
* 修改 system_config 中对应配置后即时生效无需重启
* <p>
* 注意推荐问题suggest-message-list已不再由主回复生成
* 改由 {@link SuggestionGenerator} AI 回复结束后按需异步生成
*/ */
private String effectiveSystem(String rolePrompt) { private String effectiveSystem(String rolePrompt) {
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
@ -182,6 +183,7 @@ public class ChatPipeline {
} }
sb.append("【当前角色设定】\n").append(rolePrompt); sb.append("【当前角色设定】\n").append(rolePrompt);
} }
return sb.toString(); return sb.toString();
} }

17
src/main/java/com/wok/supportbot/app/ChatResult.java

@ -9,9 +9,24 @@ import java.util.List;
* <p> * <p>
* 除了回答文本还携带本次触发的 MCP 工具调用事件让同步对话也能像流式对话一样 * 除了回答文本还携带本次触发的 MCP 工具调用事件让同步对话也能像流式对话一样
* 展示工具调用过程原仅流式 {@code appendMcpToolEvents} 追加事件同步路径无事件 * 展示工具调用过程原仅流式 {@code appendMcpToolEvents} 追加事件同步路径无事件
* <p>
* 新增 {@code suggestions} 字段AI 推荐问题列表suggest-message-list
* {@link SuggestionResponseParser} LLM 输出中分离 LLM 路径FAQ/熔断为空列表
* *
* @param text AI 回答文本 * @param text AI 回答文本
* @param mcpEvents 本次触发的 MCP 工具调用事件无调用时为空列表 * @param mcpEvents 本次触发的 MCP 工具调用事件无调用时为空列表
* @param suggestions AI 推荐问题列表03 LLM 路径为空
*/ */
public record ChatResult(String text, List<McpToolCallback.ToolCallEvent> mcpEvents) {
public record ChatResult(String text, List<McpToolCallback.ToolCallEvent> mcpEvents, List<String> suggestions) {
/** 向后兼容构造器(无 suggestions) */
public ChatResult(String text, List<McpToolCallback.ToolCallEvent> mcpEvents) {
this(text, mcpEvents, List.of());
}
/** 紧凑构造器:保证不可变性 */
public ChatResult {
suggestions = suggestions != null ? List.copyOf(suggestions) : List.of();
mcpEvents = mcpEvents != null ? List.copyOf(mcpEvents) : List.of();
}
} }

201
src/main/java/com/wok/supportbot/app/SuggestionGenerator.java

@ -0,0 +1,201 @@
package com.wok.supportbot.app;
import com.wok.supportbot.advisor.MyLoggerAdvisor;
import com.wok.supportbot.cache.SuggestionCache;
import com.wok.supportbot.chatmemory.DatabaseChatMemory;
import com.wok.supportbot.config.ChatModelFactory;
import com.wok.supportbot.service.SystemConfigService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.MessageType;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* 推荐问题异步生成器
* <p>
* AI 主回复不再输出 suggestions前端在 AI 回复结束后调用 suggestions 接口时
* 本组件基于该会话的历史消息异步调用 LLM 生成 3 条推荐问题
*/
@Slf4j
@Component
public class SuggestionGenerator {
/** 生成超时时间(秒),超过后返回空列表,避免前端长时间等待 */
private static final long GENERATE_TIMEOUT_SECONDS = 15;
/** 读取历史消息条数 */
private static final int HISTORY_SIZE = 10;
private final ChatModelFactory chatModelFactory;
private final DatabaseChatMemory chatMemory;
private final SuggestionCache suggestionCache;
private final SystemConfigService systemConfigService;
/** 懒加载的独立 ChatClient,不带 MCP 工具和内容安全 Advisor */
private volatile ChatClient chatClient;
public SuggestionGenerator(ChatModelFactory chatModelFactory,
DatabaseChatMemory chatMemory,
SuggestionCache suggestionCache,
SystemConfigService systemConfigService) {
this.chatModelFactory = chatModelFactory;
this.chatMemory = chatMemory;
this.suggestionCache = suggestionCache;
this.systemConfigService = systemConfigService;
}
/**
* 生成推荐问题列表
* <ol>
* <li>若功能未启用直接返回空列表</li>
* <li>若缓存命中直接返回缓存结果</li>
* <li>若同一 chatId 正在生成等待已有任务结果</li>
* <li>否则启动新任务生成超时后降级为空列表</li>
* </ol>
*
* @param chatId 会话 ID
* @return 推荐问题列表最多 3
*/
public List<String> generate(String chatId) {
if (!StringUtils.hasText(chatId)) {
return Collections.emptyList();
}
if (!isEnabled()) {
log.debug("推荐问题功能未启用(suggestion_enabled != true),跳过生成: chatId={}", chatId);
return Collections.emptyList();
}
// 1. 优先读缓存
List<String> cached = suggestionCache.get(chatId).orElse(null);
if (cached != null) {
return cached;
}
// 2. 创建生成任务并注册到缓存避免并发重复生成
CompletableFuture<List<String>> future = suggestionCache.putIfAbsent(chatId,
CompletableFuture.supplyAsync(() -> doGenerate(chatId)));
try {
List<String> result = future.get(GENERATE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
return result != null ? result : Collections.emptyList();
} catch (java.util.concurrent.CancellationException e) {
log.debug("推荐问题生成被取消: chatId={}", chatId);
return Collections.emptyList();
} catch (Exception e) {
log.warn("推荐问题生成失败或超时: chatId={}", chatId, e);
return Collections.emptyList();
}
}
/**
* 实际执行 LLM 生成
*/
private List<String> doGenerate(String chatId) {
try {
List<Message> history = chatMemory.get(chatId, HISTORY_SIZE);
if (history == null || history.isEmpty()) {
log.info("无历史消息,跳过推荐问题生成: chatId={}", chatId);
return Collections.emptyList();
}
String historyText = formatHistory(history);
String prompt = buildPrompt(historyText);
String raw = getChatClient().prompt()
.system(prompt)
.user("请根据历史对话生成推荐问题")
.call()
.chatResponse()
.getResult()
.getOutput()
.getText();
List<String> suggestions = SuggestionResponseParser.parseSuggestionsJson(raw.strip());
log.info("推荐问题生成成功: chatId={}, count={}", chatId, suggestions.size());
return suggestions;
} catch (Exception e) {
log.warn("推荐问题生成异常: chatId={}", chatId, e);
return Collections.emptyList();
}
}
/**
* 格式化历史消息为纯文本上下文
*/
private String formatHistory(List<Message> history) {
return history.stream()
.map(msg -> {
String role = mapRole(msg.getMessageType());
String text = msg.getText();
return role + ": " + text;
})
.collect(Collectors.joining("\n"));
}
/**
* Spring AI MessageType 映射为中文角色名
*/
private String mapRole(MessageType messageType) {
if (messageType == MessageType.USER) {
return "用户";
}
if (messageType == MessageType.ASSISTANT) {
return "AI";
}
if (messageType == MessageType.SYSTEM) {
return "系统";
}
return "其他";
}
/**
* 构建生成推荐问题的完整提示词
*/
private String buildPrompt(String historyText) {
String basePrompt = systemConfigService.getValueByKey("suggestion_prompt");
if (!StringUtils.hasText(basePrompt)) {
basePrompt = "请根据历史对话,生成 3 条用户最可能继续提问的推荐问题。";
}
return basePrompt + "\n\n" +
"历史对话:\n" + historyText + "\n\n" +
"输出要求:\n" +
"1. 问题必须与对话主题相关,引导用户深入咨询\n" +
"2. 每条问题用中文,不超过 30 个字\n" +
"3. 只输出 JSON 数组格式,例如:[\"问题1\", \"问题2\", \"问题3\"]\n" +
"4. 不要输出任何其他解释、markdown 代码块或序号";
}
/**
* 检查 suggestions 功能是否启用
*/
private boolean isEnabled() {
return "true".equals(systemConfigService.getValueByKey("suggestion_enabled"));
}
/**
* 获取专用于 suggestions 生成的 ChatClient
* 不带 MCP 工具不带内容安全 Advisor仅注册日志 Advisor
*/
private ChatClient getChatClient() {
if (chatClient == null) {
synchronized (this) {
if (chatClient == null) {
chatClient = ChatClient.builder(chatModelFactory.getChatModel("CHAT"))
.defaultAdvisors(new MyLoggerAdvisor())
.build();
}
}
}
return chatClient;
}
}

285
src/main/java/com/wok/supportbot/app/SuggestionResponseParser.java

@ -0,0 +1,285 @@
package com.wok.supportbot.app;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.wok.supportbot.cache.SuggestionCache;
import lombok.extern.slf4j.Slf4j;
import reactor.core.publisher.Flux;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
/**
* 建议问题解析器 LLM 原始输出中分离 answer suggestions
* <p>
* 哨兵标记为 <code>___SUGGESTIONS___</code>之后为 JSON 字符串数组
* 支持同步路径直接分割完整文本和流式路径滑动窗口检测哨兵
*/
@Slf4j
public final class SuggestionResponseParser {
/** 主哨兵:严格匹配提示词要求的下划线分隔标记 */
private static final String SENTINEL = "___SUGGESTIONS___";
/** 兼容哨兵:模型偶尔不按指令输出时的大写变形 */
private static final String LOOSE_SENTINEL = "SUGGESTIONS";
private static final ObjectMapper objectMapper = new ObjectMapper();
private SuggestionResponseParser() {
}
/**
* 查找最佳哨兵位置优先严格哨兵其次兼容哨兵
* <p>
* 兼容哨兵必须后面紧跟 JSON 数组特征'[' 或空白 + '['
* 避免正文中出现 "SUGGESTIONS" 普通单词时误触发
*
* @param text 要搜索的文本
* @return 哨兵起始位置未找到返回 -1
*/
private static int findSentinelIndex(String text) {
int strictIdx = text.lastIndexOf(SENTINEL);
if (strictIdx >= 0) {
return strictIdx;
}
// 兼容模式从后往前找大写 SUGGESTIONS且后续需连接 JSON 数组
int idx = text.lastIndexOf(LOOSE_SENTINEL);
while (idx >= 0) {
int after = idx + LOOSE_SENTINEL.length();
if (after < text.length()) {
char c = text.charAt(after);
// 允许 SUGGESTIONS[...]SUGGESTIONS [...]SUGGESTIONS:\n[...]
if (c == '[' || Character.isWhitespace(c) || c == ':' || c == '-') {
return idx;
}
}
// 继续向前查找更早的兼容哨兵
idx = text.lastIndexOf(LOOSE_SENTINEL, idx - 1);
}
return -1;
}
/**
* 同步路径按最后一次出现哨兵的位置分割完整文本
*
* @param rawText LLM 原始输出
* @return 解析结果answer + suggestions
*/
public static ParsedResponse parse(String rawText) {
if (rawText == null || rawText.isEmpty()) {
return new ParsedResponse(rawText != null ? rawText : "", Collections.emptyList());
}
int lastIdx = findSentinelIndex(rawText);
if (lastIdx < 0) {
// 无哨兵标记整段作为 answer
return new ParsedResponse(rawText, Collections.emptyList());
}
String answer = rawText.substring(0, lastIdx).trim();
// 按实际匹配的哨兵长度截取后续内容严格哨兵或兼容哨兵长度不同
int matchedSentinelLength = rawText.startsWith(SENTINEL, lastIdx) ? SENTINEL.length() : LOOSE_SENTINEL.length();
String suggestionsPart = rawText.substring(lastIdx + matchedSentinelLength).trim();
List<String> suggestions = parseSuggestionsJson(suggestionsPart);
return new ParsedResponse(answer, suggestions);
}
/**
* 流式路径从原始 Flux 中分离 answer suggestions
* <p>
* 采用安全缓冲区策略防止哨兵泄漏到 UI
* <ol>
* <li>每次 chunk 追加到累积缓冲区</li>
* <li>安全区域 = 缓冲区去掉末尾 SENTINEL.length() 字符预留哨兵跨 chunk 截断空间</li>
* <li>在安全区域内检查哨兵找到则发出哨兵前的剩余内容后续全进 suggestionsBuffer</li>
* <li>未找到哨兵则发出安全区域内容</li>
* <li>doOnComplete 哨兵已发现则解析 suggestions未发现则发出安全缓冲区内剩余内容</li>
* </ol>
*
* @param rawStream LLM 原始输出流
* @param cache 建议缓存
* @param chatId 会话 ID
* @return answer Flux不含哨兵及之后的 suggestions
*/
/**
* 可变状态容器 lambda 内部修改
*/
private static class StreamState {
final StringBuilder buf = new StringBuilder();
int emitted = 0;
boolean sentinelFound = false;
}
public static Flux<String> parseFromStream(Flux<String> rawStream, SuggestionCache cache, String chatId) {
StreamState state = new StreamState();
return rawStream
.concatMap(chunk -> Flux.<String>create(sink -> {
if (state.sentinelFound) {
sink.complete();
return;
}
state.buf.append(chunk);
int maxSentinelLen = Math.max(SENTINEL.length(), LOOSE_SENTINEL.length());
int safeEnd = Math.max(0, state.buf.length() - maxSentinelLen);
if (safeEnd <= state.emitted) {
sink.complete();
return;
}
String safeRegion = state.buf.substring(state.emitted, safeEnd);
int sentinelIdx = findSentinelIndex(safeRegion);
if (sentinelIdx >= 0) {
state.sentinelFound = true;
String beforeSentinel = safeRegion.substring(0, sentinelIdx);
if (!beforeSentinel.isEmpty()) {
sink.next(beforeSentinel);
}
// 确定实际匹配到的哨兵长度
int matchedLen = safeRegion.startsWith(SENTINEL, sentinelIdx)
? SENTINEL.length()
: LOOSE_SENTINEL.length();
// 哨兵在安全区域内的位置 sentinelIdx相对全缓冲区即 emitted + sentinelIdx
// emitted 推进到哨兵结束后后续内容进 suggestions 解析
state.emitted = state.emitted + sentinelIdx + matchedLen;
log.debug("流式路径检测到哨兵: chatId={}, pos={}, matchedLen={}", chatId, state.emitted, matchedLen);
} else {
if (!safeRegion.isEmpty()) {
sink.next(safeRegion);
}
state.emitted = safeEnd;
}
sink.complete();
}))
// 流结束后发出安全缓冲区内未发出的残留内容无哨兵场景
.concatWith(Flux.defer(() -> {
if (!state.sentinelFound && state.emitted < state.buf.length()) {
String residual = state.buf.substring(state.emitted);
if (!residual.isEmpty()) {
return Flux.just(residual);
}
}
return Flux.empty();
}))
.doOnComplete(() -> {
if (state.sentinelFound) {
String suggestionsPart = state.buf.length() > state.emitted
? state.buf.substring(state.emitted) : "";
List<String> suggestions = parseSuggestionsJson(suggestionsPart.strip());
if (!suggestions.isEmpty()) {
cache.put(chatId, suggestions);
log.info("流式 suggestions 解析成功: chatId={}, count={}", chatId, suggestions.size());
} else {
log.debug("流式 suggestions 解析为空: chatId={}", chatId);
}
}
});
}
/**
* 容错解析 suggestions JSON 数组
* 先尝试标准 JSON 解析失败则按行切分取前 3 条非空行
*/
static List<String> parseSuggestionsJson(String jsonPart) {
if (jsonPart == null || jsonPart.isBlank()) {
return Collections.emptyList();
}
// 1. 尝试标准 JSON 解析
String trimmed = jsonPart.strip();
// 去掉可能的 markdown 代码块包裹
trimmed = trimCodeBlock(trimmed);
try {
List<String> parsed = objectMapper.readValue(trimmed, new TypeReference<List<String>>() {});
List<String> result = new ArrayList<>();
for (String s : parsed) {
if (s != null && !s.isBlank()) {
result.add(s.strip());
}
}
if (!result.isEmpty()) {
return result.size() <= 3 ? result : result.subList(0, 3);
}
} catch (Exception e) {
log.debug("标准 JSON 解析 suggestions 失败,尝试按行切分: {}", e.getMessage());
}
// 2. 降级按行切分取前 3
return parseByLines(trimmed);
}
/**
* 降级解析按行切分去掉序号前缀后取前 3 条非空行
*/
private static List<String> parseByLines(String text) {
List<String> lines = text.lines()
.map(String::strip)
.map(SuggestionResponseParser::stripNumberPrefix)
.map(SuggestionResponseParser::stripQuotes)
.filter(s -> !s.isBlank())
.collect(Collectors.toList());
if (lines.isEmpty()) {
return Collections.emptyList();
}
return lines.size() <= 3 ? lines : lines.subList(0, 3);
}
/** 去掉可能的 markdown 代码块包裹(支持 3 个及以上反引号) */
private static String trimCodeBlock(String s) {
if (s.startsWith("```") && s.endsWith("```")) {
// 计算开头的反引号数量
int openCount = 0;
while (openCount < s.length() && s.charAt(openCount) == '`') openCount++;
if (openCount >= 3 && s.endsWith("`".repeat(openCount))) {
String inner = s.substring(openCount, s.length() - openCount).strip();
if (inner.startsWith("json")) {
inner = inner.substring(4).strip();
} else if (inner.startsWith("JSON")) {
inner = inner.substring(4).strip();
}
return inner;
}
}
return s;
}
/** 去掉行首序号,如 "1." "2." "3." "1、" "2、" "3、" */
private static String stripNumberPrefix(String s) {
if (s.length() >= 2 && Character.isDigit(s.charAt(0)) &&
(s.charAt(1) == '.' || s.charAt(1) == '、' || s.charAt(1) == ')')) {
return s.substring(2).strip();
}
return s;
}
/** 去掉首尾引号 */
private static String stripQuotes(String s) {
if (s.length() >= 2) {
char first = s.charAt(0);
char last = s.charAt(s.length() - 1);
if ((first == '"' && last == '"') || (first == '\'' && last == '\'') ||
(first == '“' && last == '”')) { // 中文引号 " "
return s.substring(1, s.length() - 1).strip();
}
}
return s;
}
/**
* 解析结果值对象
*
* @param answer LLM 回答正文不含哨兵及之后的 suggestions
* @param suggestions 建议问题列表03
*/
public record ParsedResponse(String answer, List<String> suggestions) {
}
}

114
src/main/java/com/wok/supportbot/cache/SuggestionCache.java

@ -0,0 +1,114 @@
package com.wok.supportbot.cache;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
/**
* AI 推荐问题suggest-message-list的内存缓存
* <p>
* key chatIdvalue 为最近一次生成的 3 条建议问题
* 不持久化到数据库suggestions 是实时生成即时消费的短生命周期数据
* 每次新的生成会覆盖前一次的建议
* <p>
* 增加 {@code pending} 映射避免同一 chatId 的并发请求重复触发 LLM 生成
*/
@Component
@Slf4j
public class SuggestionCache {
/** 最大缓存条目数,防止无限增长 */
private static final int MAX_ENTRIES = 1000;
/** 生成中任务的并发控制:key=chatId,value=正在执行的生成任务 */
private final ConcurrentHashMap<String, CompletableFuture<List<String>>> pending = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, List<String>> cache = new ConcurrentHashMap<>();
/**
* 存入建议问题列表
*
* @param chatId 会话 ID
* @param suggestions 建议问题列表
*/
public void put(String chatId, List<String> suggestions) {
if (chatId == null || suggestions == null || suggestions.isEmpty()) {
return;
}
// 超过容量上限时清理一半旧条目简单 LRU 近似
if (cache.size() >= MAX_ENTRIES) {
int toRemove = MAX_ENTRIES / 2;
var it = cache.keySet().iterator();
while (it.hasNext() && toRemove > 0) {
it.next();
it.remove();
toRemove--;
}
log.info("SuggestionCache 达到上限 {},已清理 {} 条旧记录", MAX_ENTRIES, MAX_ENTRIES / 2);
}
cache.put(chatId, List.copyOf(suggestions));
log.debug("SuggestionCache 写入: chatId={}, count={}", chatId, suggestions.size());
}
/**
* 获取建议问题列表
*
* @param chatId 会话 ID
* @return 建议问题列表不存在则返回 Optional.empty()
*/
public Optional<List<String>> get(String chatId) {
if (chatId == null) {
return Optional.empty();
}
List<String> suggestions = cache.get(chatId);
return Optional.ofNullable(suggestions);
}
/**
* 注册一个正在生成中的任务
*
* @param chatId 会话 ID
* @param future 生成任务
* @return 若已存在相同 chatId 的任务返回已存在的任务否则返回传入的任务
*/
public CompletableFuture<List<String>> putIfAbsent(String chatId, CompletableFuture<List<String>> future) {
if (chatId == null || future == null) {
return future;
}
CompletableFuture<List<String>> existing = pending.putIfAbsent(chatId, future);
if (existing != null) {
return existing;
}
// future 完成后写入缓存并从 pending 移除
future.whenComplete((result, ex) -> {
pending.remove(chatId, future);
if (ex != null) {
log.warn("SuggestionCache 生成任务异常: chatId={}", chatId, ex);
} else if (result != null && !result.isEmpty()) {
put(chatId, result);
}
});
return future;
}
/**
* 清除指定会话的建议缓存会话删除时调用
*
* @param chatId 会话 ID
*/
public void evict(String chatId) {
if (chatId != null) {
cache.remove(chatId);
// 同时取消并清理可能正在进行的生成任务
CompletableFuture<List<String>> future = pending.remove(chatId);
if (future != null && !future.isDone()) {
future.cancel(true);
}
log.debug("SuggestionCache 清除: chatId={}", chatId);
}
}
}

27
src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java

@ -1142,6 +1142,33 @@ public class DatabaseInitConfig {
VALUES (?, ?, ?) VALUES (?, ?, ?)
ON CONFLICT (config_key) DO NOTHING ON CONFLICT (config_key) DO NOTHING
""", "ai_system_prompt", "", "AI 对话全局系统提示词为空则不注入修改后即时生效无需重启"); """, "ai_system_prompt", "", "AI 对话全局系统提示词为空则不注入修改后即时生效无需重启");
// AI 推荐问题suggest-message-list种子数据
String defaultSuggestionPrompt = """
推荐问题生成规则
在回答正文结束后请严格按以下格式生成 3 条用户可能继续追问的推荐问题
___SUGGESTIONS___
["推荐问题1", "推荐问题2", "推荐问题3"]
要求
1. 推荐问题需与当前回答内容相关覆盖用户可能关心的不同方面
2. 推荐问题是用户可直接点击发送的完整问题文本
3. 必须是合法的 JSON 字符串数组
4. ___SUGGESTIONS___ 是分隔标记不要在回答正文中出现此标记
""";
jdbcTemplate.update("""
INSERT INTO system_config (config_key, config_value, description)
VALUES (?, ?, ?)
ON CONFLICT (config_key) DO NOTHING
""", "suggestion_enabled", "false", "AI 推荐问题功能开关true/false");
jdbcTemplate.update("""
INSERT INTO system_config (config_key, config_value, description)
VALUES (?, ?, ?)
ON CONFLICT (config_key) DO NOTHING
""", "suggestion_prompt", defaultSuggestionPrompt, "AI 推荐问题 Prompt 模板仅在 suggestion_enabled=true 时生效");
} }
/** /**

28
src/main/java/com/wok/supportbot/controller/AiController.java

@ -3,6 +3,8 @@ package com.wok.supportbot.controller;
import com.wok.supportbot.app.AssistantApp; import com.wok.supportbot.app.AssistantApp;
import com.wok.supportbot.app.ChatContext; import com.wok.supportbot.app.ChatContext;
import com.wok.supportbot.app.ChatPipeline; import com.wok.supportbot.app.ChatPipeline;
import com.wok.supportbot.app.SuggestionGenerator;
import com.wok.supportbot.cache.SuggestionCache;
import com.wok.supportbot.config.RoleAccessConfig; import com.wok.supportbot.config.RoleAccessConfig;
import com.wok.supportbot.rag.CategoryFilter; import com.wok.supportbot.rag.CategoryFilter;
import com.wok.supportbot.service.ConversationService; import com.wok.supportbot.service.ConversationService;
@ -50,6 +52,10 @@ public class AiController {
private ChatPipeline chatPipeline; private ChatPipeline chatPipeline;
@Resource @Resource
private com.wok.supportbot.service.SystemConfigService systemConfigService; private com.wok.supportbot.service.SystemConfigService systemConfigService;
@Resource
private SuggestionCache suggestionCache;
@Resource
private SuggestionGenerator suggestionGenerator;
/** /**
@ -394,4 +400,26 @@ public class AiController {
} }
} }
// ==================== 建议问题suggest-message-list ====================
/**
* 获取 AI 推荐问题列表SDK SdkAuthFilter 守卫
* <p>
* AI 主回复不再同步生成 suggestions前端在 AI 回复结束后调用本接口
* 后端基于会话历史消息异步调用 LLM 生成 3 条推荐问题
*
* @param chatId 会话 ID必填与对话接口的 chatId 一致
* @return { success: true, data: { suggestions: [...] } }
*/
@GetMapping("/suggestions")
public Map<String, Object> getSuggestions(@RequestParam String chatId) {
try {
List<String> suggestions = suggestionGenerator.generate(chatId);
return Map.of("success", true, "data", Map.of("suggestions", suggestions));
} catch (Exception e) {
log.error("获取建议问题失败: chatId={}", chatId, e);
return Map.of("success", true, "data", Map.of("suggestions", List.of()));
}
}
} }

39
src/main/java/com/wok/supportbot/controller/ConversationController.java

@ -1,6 +1,9 @@
package com.wok.supportbot.controller; package com.wok.supportbot.controller;
import com.wok.supportbot.app.SuggestionGenerator;
import com.wok.supportbot.cache.SuggestionCache;
import com.wok.supportbot.service.ConversationService; import com.wok.supportbot.service.ConversationService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@ -15,11 +18,18 @@ import java.util.Map;
* 提供会话列表详情消息删除导出等 API * 提供会话列表详情消息删除导出等 API
*/ */
@RestController @RestController
@Slf4j
public class ConversationController { public class ConversationController {
@Autowired @Autowired
private ConversationService conversationService; private ConversationService conversationService;
@Autowired
private SuggestionCache suggestionCache;
@Autowired
private SuggestionGenerator suggestionGenerator;
// ==================== 会话列表 ==================== // ==================== 会话列表 ====================
/** /**
@ -142,6 +152,33 @@ public class ConversationController {
} }
} }
/**
* 获取会话的 AI 推荐问题列表管理后台 ChatPanel JwtAuthFilter 守卫
* <p>
* /ai/suggestions /open-api/suggestions 共享同一 SuggestionCache 实例
* AI 主回复不再同步生成 suggestions本接口按需异步生成
*
* @param conversationId 会话 ID chatId
* @return { success: true, data: { suggestions: [...] } }
*/
@GetMapping("/conversation/{id}/suggestions")
@PreAuthorize("hasAnyRole('admin','cs_agent','viewer')")
public ResponseEntity<Map<String, Object>> getConversationSuggestions(@PathVariable("id") String conversationId) {
try {
List<String> suggestions = suggestionGenerator.generate(conversationId);
return ResponseEntity.ok(Map.of(
"success", true,
"data", Map.of("suggestions", suggestions)
));
} catch (Exception e) {
log.error("获取会话建议问题失败: conversationId={}", conversationId, e);
return ResponseEntity.ok(Map.of(
"success", true,
"data", Map.of("suggestions", List.of())
));
}
}
// ==================== 删除会话 ==================== // ==================== 删除会话 ====================
/** /**
@ -156,6 +193,8 @@ public class ConversationController {
public ResponseEntity<Map<String, Object>> deleteConversation(@PathVariable("id") String conversationId) { public ResponseEntity<Map<String, Object>> deleteConversation(@PathVariable("id") String conversationId) {
try { try {
int count = conversationService.deleteConversation(conversationId); int count = conversationService.deleteConversation(conversationId);
// 删除会话时同步清理推荐问题缓存
suggestionCache.evict(conversationId);
return ResponseEntity.ok(Map.of( return ResponseEntity.ok(Map.of(
"success", true, "success", true,
"message", "会话删除成功", "message", "会话删除成功",

31
src/main/java/com/wok/supportbot/controller/OpenApiController.java

@ -2,6 +2,8 @@ package com.wok.supportbot.controller;
import com.wok.supportbot.app.AssistantApp; import com.wok.supportbot.app.AssistantApp;
import com.wok.supportbot.app.ChatContext; import com.wok.supportbot.app.ChatContext;
import com.wok.supportbot.app.SuggestionGenerator;
import com.wok.supportbot.cache.SuggestionCache;
import com.wok.supportbot.config.RoleAccessConfig; import com.wok.supportbot.config.RoleAccessConfig;
import com.wok.supportbot.entity.ApiKey; import com.wok.supportbot.entity.ApiKey;
import com.wok.supportbot.entity.SearchResult; import com.wok.supportbot.entity.SearchResult;
@ -42,6 +44,12 @@ public class OpenApiController {
@Autowired @Autowired
private HybridSearchService hybridSearchService; private HybridSearchService hybridSearchService;
@Autowired
private SuggestionCache suggestionCache;
@Autowired
private SuggestionGenerator suggestionGenerator;
@Autowired @Autowired
private ApiKeyService apiKeyService; private ApiKeyService apiKeyService;
@ -154,6 +162,29 @@ public class OpenApiController {
} }
} }
/**
* 获取 AI 推荐问题列表Open API ApiKeyAuthFilter 守卫
*
* @param chatId 会话 ID
* @return { success: true, data: { suggestions: [...] } }
*/
@GetMapping("/suggestions")
public ResponseEntity<Map<String, Object>> getSuggestions(@RequestParam String chatId) {
try {
List<String> suggestions = suggestionGenerator.generate(chatId);
return ResponseEntity.ok(Map.of(
"success", true,
"data", Map.of("suggestions", suggestions)
));
} catch (Exception e) {
log.error("开放 API 获取建议问题失败: chatId={}", chatId, e);
return ResponseEntity.ok(Map.of(
"success", true,
"data", Map.of("suggestions", List.of())
));
}
}
/** /**
* request attribute 获取已鉴权的 API Key 信息 * request attribute 获取已鉴权的 API Key 信息
*/ */

10
src/main/resources/logback-spring.xml

@ -48,10 +48,16 @@
<!-- ========== root 级别配置 ========== --> <!-- ========== root 级别配置 ========== -->
<root level="INFO"> <root level="INFO">
<appender-ref ref="CONSOLE"/> <appender-ref ref="CONSOLE"/>
<!-- FILE 和 FILE_ERROR 由 springProfile 控制,只有 prod 才生效 -->
</root>
<!-- prod 环境额外叠加文件日志 appender -->
<!-- 注意:<springProfile> 不得嵌套在 <root>/<logger>/<appender> 内部,
否则会损坏 appender 链导致所有日志静默丢失 -->
<springProfile name="prod"> <springProfile name="prod">
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE"/> <appender-ref ref="FILE"/>
<appender-ref ref="FILE_ERROR"/> <appender-ref ref="FILE_ERROR"/>
</springProfile>
</root> </root>
</springProfile>
</configuration> </configuration>

160
src/main/resources/static/sdk/chatbot-sdk.js

@ -102,7 +102,7 @@ var ChatbotSDK = (function () {
* 解析并校验用户传入的配置填充默认值 * 解析并校验用户传入的配置填充默认值
*/ */
function parseConfig(raw) { function parseConfig(raw) {
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m;
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o;
// 校验必传参数:integrateId(对应后端 roleId) // 校验必传参数:integrateId(对应后端 roleId)
if (!raw.integrateId || (typeof raw.integrateId !== 'string' && typeof raw.integrateId !== 'number') if (!raw.integrateId || (typeof raw.integrateId !== 'string' && typeof raw.integrateId !== 'number')
|| (typeof raw.integrateId === 'string' && raw.integrateId.trim() === '')) { || (typeof raw.integrateId === 'string' && raw.integrateId.trim() === '')) {
@ -118,7 +118,7 @@ var ChatbotSDK = (function () {
try { try {
new URL(raw.requestDomain); new URL(raw.requestDomain);
} }
catch (_o) {
catch (_p) {
logger.error(`requestDomain 不是合法的 URL 格式:${raw.requestDomain}。请提供完整的域名,如 https://api.example.com`); logger.error(`requestDomain 不是合法的 URL 格式:${raw.requestDomain}。请提供完整的域名,如 https://api.example.com`);
return null; return null;
} }
@ -145,18 +145,19 @@ var ChatbotSDK = (function () {
quickReplies: Array.isArray(raw.quickReplies) quickReplies: Array.isArray(raw.quickReplies)
? raw.quickReplies.map(s => String(s).trim()).filter(Boolean) ? raw.quickReplies.map(s => String(s).trim()).filter(Boolean)
: [], : [],
suggestions: (_f = raw.suggestions) !== null && _f !== void 0 ? _f : true,
theme: raw.theme === 'dark' ? 'dark' : 'light', theme: raw.theme === 'dark' ? 'dark' : 'light',
showTeaser: (_f = raw.showTeaser) !== null && _f !== void 0 ? _f : true,
showTeaser: (_g = raw.showTeaser) !== null && _g !== void 0 ? _g : true,
teaserText: (typeof raw.teaserText === 'string' && raw.teaserText.trim()) || '', teaserText: (typeof raw.teaserText === 'string' && raw.teaserText.trim()) || '',
resizable: (_g = raw.resizable) !== null && _g !== void 0 ? _g : true,
resizable: (_h = raw.resizable) !== null && _h !== void 0 ? _h : true,
watermark: (typeof raw.watermark === 'string' && raw.watermark.trim()) || undefined, watermark: (typeof raw.watermark === 'string' && raw.watermark.trim()) || undefined,
streaming: (_h = raw.streaming) !== null && _h !== void 0 ? _h : true,
enableRag: (_j = raw.enableRag) !== null && _j !== void 0 ? _j : true,
streaming: (_j = raw.streaming) !== null && _j !== void 0 ? _j : true,
enableRag: (_k = raw.enableRag) !== null && _k !== void 0 ? _k : true,
rewriteStrategy: raw.rewriteStrategy || 'REWRITE', rewriteStrategy: raw.rewriteStrategy || 'REWRITE',
locale: raw.locale || 'zh-CN', locale: raw.locale || 'zh-CN',
debug: (_k = raw.debug) !== null && _k !== void 0 ? _k : true,
sound: (_l = raw.sound) !== null && _l !== void 0 ? _l : false,
notification: (_m = raw.notification) !== null && _m !== void 0 ? _m : false,
debug: (_l = raw.debug) !== null && _l !== void 0 ? _l : true,
sound: (_m = raw.sound) !== null && _m !== void 0 ? _m : false,
notification: (_o = raw.notification) !== null && _o !== void 0 ? _o : false,
onError: typeof raw.onError === 'function' ? raw.onError : undefined, onError: typeof raw.onError === 'function' ? raw.onError : undefined,
onReady: typeof raw.onReady === 'function' ? raw.onReady : undefined, onReady: typeof raw.onReady === 'function' ? raw.onReady : undefined,
onMessage: typeof raw.onMessage === 'function' ? raw.onMessage : undefined, onMessage: typeof raw.onMessage === 'function' ? raw.onMessage : undefined,
@ -966,6 +967,33 @@ var ChatbotSDK = (function () {
return ''; return '';
} }
} }
/**
* 获取 AI 推荐问题列表suggest-message-list
* AI 回复结束后调用从后端缓存中读取本次对话生成的建议问题
* @param chatId 当前对话 ID
* @returns 推荐问题数组最多 3 接口失败或功能关闭时返回空数组
*/
async function fetchSuggestions(chatId) {
if (!currentConfig || !chatId)
return [];
try {
const params = new URLSearchParams();
params.set('chatId', chatId);
const url = buildUrl(`/ai/suggestions?${params.toString()}`);
const response = await safeFetch(url);
if (!response.ok)
return [];
const json = await response.json();
if (json.success && json.data && Array.isArray(json.data.suggestions)) {
return json.data.suggestions.filter(s => typeof s === 'string' && s.trim().length > 0);
}
return [];
}
catch (_a) {
// 静默降级,不阻塞对话
return [];
}
}
let styleElement = null; let styleElement = null;
/** 将 hex 颜色解析为 "r, g, b" 字符串,用于 rgba() 拼接 */ /** 将 hex 颜色解析为 "r, g, b" 字符串,用于 rgba() 拼接 */
@ -1952,6 +1980,44 @@ var ChatbotSDK = (function () {
} }
.csk-quick-reply:active { transform: scale(0.96); } .csk-quick-reply:active { transform: scale(0.96); }
/* ========== 推荐问题(suggest-message-list) ========== */
.csk-suggestions {
margin-top: 10px;
padding: 8px 0 0 0;
border-top: 1px solid var(--csk-border);
}
.csk-suggestions__label {
font-size: 12px;
color: #9CA3AF;
margin-bottom: 6px;
}
.csk-suggestions__list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.csk-suggestion-item {
border: 1px solid var(--csk-border);
background: #fff;
color: #374151;
font-size: 12px;
padding: 6px 12px;
border-radius: 999px;
cursor: pointer;
font-family: inherit;
line-height: 1.3;
transition: all 0.18s ease;
max-width: 100%;
text-align: left;
}
.csk-suggestion-item:hover {
border-color: var(--csk-primary);
color: var(--csk-primary);
background: rgba(var(--csk-primary-rgb), 0.06);
transform: translateY(-1px);
}
.csk-suggestion-item:active { transform: scale(0.96); }
/* ========== AI 消息操作条 ========== */ /* ========== AI 消息操作条 ========== */
.csk-msg__actions { .csk-msg__actions {
display: flex; display: flex;
@ -2203,6 +2269,8 @@ var ChatbotSDK = (function () {
.csk-dark .csk-welcome__desc { color: #6B7280; } .csk-dark .csk-welcome__desc { color: #6B7280; }
.csk-dark .csk-quick-reply { background: var(--csk-bg-app); border-color: var(--csk-border); color: #CBD5E1; } .csk-dark .csk-quick-reply { background: var(--csk-bg-app); border-color: var(--csk-border); color: #CBD5E1; }
.csk-dark .csk-quick-reply:hover { background: rgba(var(--csk-primary-rgb), 0.12); border-color: var(--csk-primary); color: var(--csk-primary-light); } .csk-dark .csk-quick-reply:hover { background: rgba(var(--csk-primary-rgb), 0.12); border-color: var(--csk-primary); color: var(--csk-primary-light); }
.csk-dark .csk-suggestion-item { background: var(--csk-bg-app); border-color: var(--csk-border); color: #CBD5E1; }
.csk-dark .csk-suggestion-item:hover { background: rgba(var(--csk-primary-rgb), 0.12); border-color: var(--csk-primary); color: var(--csk-primary-light); }
.csk-dark .csk-teaser { background: #2D2D48; color: #E2E8F0; box-shadow: 0 4px 20px rgba(0,0,0,0.35); } .csk-dark .csk-teaser { background: #2D2D48; color: #E2E8F0; box-shadow: 0 4px 20px rgba(0,0,0,0.35); }
.csk-dark .csk-teaser::after { border-top-color: #2D2D48; } .csk-dark .csk-teaser::after { border-top-color: #2D2D48; }
.csk-dark .csk-teaser__close { color: #6B7280; } .csk-dark .csk-teaser__close { color: #6B7280; }
@ -3645,6 +3713,55 @@ var ChatbotSDK = (function () {
if (el) if (el)
el.remove(); el.remove();
} }
// ==================== 建议问题(suggest-message-list) ====================
/**
* AI 消息气泡下方渲染推荐问题列表
* @param wrapper AI 消息的 wrapper 元素
* @param suggestions 推荐问题文本数组
* @param onClick 点击回调传入问题文本
*/
function renderSuggestions(wrapper, suggestions, onClick) {
// 移除已有的建议区域
removeSuggestions(wrapper);
if (!suggestions || suggestions.length === 0)
return;
const section = document.createElement('div');
section.className = 'csk-suggestions';
const label = document.createElement('div');
label.className = 'csk-suggestions__label';
label.textContent = '💡 推荐问题';
const list = document.createElement('div');
list.className = 'csk-suggestions__list';
for (const text of suggestions) {
const chip = document.createElement('button');
chip.type = 'button';
chip.className = 'csk-suggestion-item';
chip.textContent = text;
chip.addEventListener('click', (e) => {
e.stopPropagation();
onClick(text);
});
list.appendChild(chip);
}
section.appendChild(label);
section.appendChild(list);
// 插入到操作栏和来源卡片之间(在 content 内末尾)
const content = wrapper.querySelector('.csk-msg__content');
if (content) {
content.appendChild(section);
}
else {
wrapper.appendChild(section);
}
}
/**
* 移除指定 wrapper 中的建议问题列表
*/
function removeSuggestions(wrapper) {
const existing = wrapper.querySelector('.csk-suggestions');
if (existing)
existing.remove();
}
const STORAGE_PREFIX = 'csk_history_'; const STORAGE_PREFIX = 'csk_history_';
const MAX_MESSAGES = 200; const MAX_MESSAGES = 200;
@ -4370,6 +4487,10 @@ var ChatbotSDK = (function () {
// RAG 引用来源 // RAG 引用来源
if (shouldUseRag) if (shouldUseRag)
fetchAndRenderSources(userText, aiMsg); fetchAndRenderSources(userText, aiMsg);
// 拉取并展示推荐问题(suggest-message-list)
if (config$1.suggestions) {
fetchAndShowSuggestions(aiMsgId);
}
// 发送成功后清除离线横幅(网络已恢复) // 发送成功后清除离线横幅(网络已恢复)
hideOfflineBanner(); hideOfflineBanner();
} }
@ -4795,6 +4916,27 @@ var ChatbotSDK = (function () {
} }
updateEmptyState(); updateEmptyState();
} }
// ==================== 建议问题(suggest-message-list) ====================
/**
* 拉取并展示 AI 推荐问题
* AI 回复成功后调用从后端缓存中读取本次对话生成的建议问题
*/
async function fetchAndShowSuggestions(aiMsgId) {
const chatId = getChatId();
if (!chatId || !messagesContainer$1)
return;
try {
const suggestions = await fetchSuggestions(chatId);
if (suggestions.length > 0 && messagesContainer$1) {
const wrapper = messagesContainer$1.querySelector(`[data-csk-msg-id="${aiMsgId}"]`);
if (wrapper)
renderSuggestions(wrapper, suggestions, sendQuickReply);
}
}
catch (_a) {
// 静默降级
}
}
// ==================== 单例状态 ==================== // ==================== 单例状态 ====================
let config = null; let config = null;

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

Loading…
Cancel
Save