10 changed files with 735 additions and 95 deletions
-
52src/main/java/com/wok/supportbot/app/AssistantApp.java
-
75src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
-
24src/main/java/com/wok/supportbot/controller/AiController.java
-
53src/main/java/com/wok/supportbot/controller/CustomerServiceRoleController.java
-
33src/main/java/com/wok/supportbot/controller/DocumentController.java
-
80src/main/java/com/wok/supportbot/service/CustomerServiceRoleService.java
-
51src/main/java/com/wok/supportbot/service/DocumentService.java
-
352src/main/resources/static/components/ChatPanel.js
-
63src/main/resources/static/css/main.css
-
17src/main/resources/static/js/api.js
@ -0,0 +1,53 @@ |
|||||
|
package com.wok.supportbot.controller; |
||||
|
|
||||
|
import com.wok.supportbot.service.CustomerServiceRoleService; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.springframework.http.ResponseEntity; |
||||
|
import org.springframework.web.bind.annotation.GetMapping; |
||||
|
import org.springframework.web.bind.annotation.PathVariable; |
||||
|
import org.springframework.web.bind.annotation.PutMapping; |
||||
|
import org.springframework.web.bind.annotation.RequestBody; |
||||
|
import org.springframework.web.bind.annotation.RestController; |
||||
|
|
||||
|
import java.util.List; |
||||
|
import java.util.Map; |
||||
|
import java.util.Objects; |
||||
|
|
||||
|
@RestController |
||||
|
public class CustomerServiceRoleController { |
||||
|
|
||||
|
@Autowired |
||||
|
private CustomerServiceRoleService customerServiceRoleService; |
||||
|
|
||||
|
@GetMapping("/role/list") |
||||
|
public ResponseEntity<Map<String, Object>> listRoles() { |
||||
|
return ResponseEntity.ok(Map.of( |
||||
|
"success", true, |
||||
|
"data", customerServiceRoleService.listRoles() |
||||
|
)); |
||||
|
} |
||||
|
|
||||
|
@PutMapping("/role/{id}/categories") |
||||
|
public ResponseEntity<Map<String, Object>> updateRoleCategories( |
||||
|
@PathVariable("id") Long roleId, |
||||
|
@RequestBody Map<String, Object> body) { |
||||
|
List<Long> categoryIds = parseCategoryIds(body.get("categoryIds")); |
||||
|
customerServiceRoleService.updateRoleCategories(roleId, categoryIds); |
||||
|
return ResponseEntity.ok(Map.of( |
||||
|
"success", true, |
||||
|
"message", "更新成功" |
||||
|
)); |
||||
|
} |
||||
|
|
||||
|
private List<Long> parseCategoryIds(Object rawCategoryIds) { |
||||
|
if (!(rawCategoryIds instanceof List<?> list)) { |
||||
|
return List.of(); |
||||
|
} |
||||
|
return list.stream() |
||||
|
.filter(Objects::nonNull) |
||||
|
.map(item -> item instanceof Number number ? number.longValue() : Long.parseLong(item.toString())) |
||||
|
.filter(id -> id > 0) |
||||
|
.distinct() |
||||
|
.toList(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,80 @@ |
|||||
|
package com.wok.supportbot.service; |
||||
|
|
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.springframework.jdbc.core.JdbcTemplate; |
||||
|
import org.springframework.stereotype.Service; |
||||
|
import org.springframework.transaction.annotation.Transactional; |
||||
|
|
||||
|
import java.util.ArrayList; |
||||
|
import java.util.LinkedHashMap; |
||||
|
import java.util.List; |
||||
|
import java.util.Map; |
||||
|
import java.util.Objects; |
||||
|
|
||||
|
@Service |
||||
|
public class CustomerServiceRoleService { |
||||
|
|
||||
|
@Autowired |
||||
|
private JdbcTemplate jdbcTemplate; |
||||
|
|
||||
|
public List<Map<String, Object>> listRoles() { |
||||
|
String roleSql = """ |
||||
|
SELECT id::text AS id, role_key, name, description, prompt, sort_order, enabled |
||||
|
FROM customer_service_role |
||||
|
WHERE is_delete = false AND enabled = true |
||||
|
ORDER BY sort_order ASC, id ASC |
||||
|
"""; |
||||
|
List<Map<String, Object>> roles = jdbcTemplate.queryForList(roleSql); |
||||
|
|
||||
|
String categorySql = """ |
||||
|
SELECT rc.role_id::text AS role_id, rc.category_id::text AS category_id, c.name AS category_name |
||||
|
FROM customer_service_role_category rc |
||||
|
LEFT JOIN knowledge_category c ON c.id = rc.category_id AND c.is_delete = false |
||||
|
WHERE rc.is_delete = false |
||||
|
ORDER BY rc.role_id ASC, rc.id ASC |
||||
|
"""; |
||||
|
List<Map<String, Object>> relations = jdbcTemplate.queryForList(categorySql); |
||||
|
|
||||
|
Map<String, List<String>> categoryIdsByRole = new LinkedHashMap<>(); |
||||
|
Map<String, List<Map<String, Object>>> categoriesByRole = new LinkedHashMap<>(); |
||||
|
for (Map<String, Object> relation : relations) { |
||||
|
String roleId = String.valueOf(relation.get("role_id")); |
||||
|
String categoryId = String.valueOf(relation.get("category_id")); |
||||
|
categoryIdsByRole.computeIfAbsent(roleId, key -> new ArrayList<>()).add(categoryId); |
||||
|
categoriesByRole.computeIfAbsent(roleId, key -> new ArrayList<>()).add(Map.of( |
||||
|
"id", categoryId, |
||||
|
"name", Objects.toString(relation.get("category_name"), "") |
||||
|
)); |
||||
|
} |
||||
|
|
||||
|
for (Map<String, Object> role : roles) { |
||||
|
String roleId = String.valueOf(role.get("id")); |
||||
|
role.put("categoryIds", categoryIdsByRole.getOrDefault(roleId, List.of())); |
||||
|
role.put("categories", categoriesByRole.getOrDefault(roleId, List.of())); |
||||
|
} |
||||
|
return roles; |
||||
|
} |
||||
|
|
||||
|
@Transactional(rollbackFor = Exception.class) |
||||
|
public void updateRoleCategories(Long roleId, List<Long> categoryIds) { |
||||
|
jdbcTemplate.update("UPDATE customer_service_role_category SET is_delete = true WHERE role_id = ?", roleId); |
||||
|
|
||||
|
if (categoryIds == null || categoryIds.isEmpty()) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
List<Long> normalizedIds = categoryIds.stream() |
||||
|
.filter(Objects::nonNull) |
||||
|
.filter(id -> id > 0) |
||||
|
.distinct() |
||||
|
.toList(); |
||||
|
for (Long categoryId : normalizedIds) { |
||||
|
jdbcTemplate.update(""" |
||||
|
INSERT INTO customer_service_role_category (role_id, category_id, is_delete) |
||||
|
VALUES (?, ?, false) |
||||
|
ON CONFLICT (role_id, category_id) |
||||
|
DO UPDATE SET is_delete = false |
||||
|
""", roleId, categoryId); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -1,140 +1,362 @@ |
|||||
/** |
/** |
||||
* 💬 智能客服对话面板 |
|
||||
|
* 智能客服对话面板 |
||||
*/ |
*/ |
||||
import { ref, nextTick } from 'vue' |
|
||||
import { chatSync, chatRagSync, chatSSEUrl } from '../js/api.js' |
|
||||
|
import { ref, computed, nextTick, onMounted } from 'vue' |
||||
|
import { chatSync, chatRagSync, chatSSEUrl, getRoleList, updateRoleCategories } from '../js/api.js' |
||||
import { toast, readSSEStream } from '../js/utils.js' |
import { toast, readSSEStream } from '../js/utils.js' |
||||
|
import { store } from '../js/store.js' |
||||
|
|
||||
|
const FALLBACK_ROLE = { |
||||
|
id: '', |
||||
|
key: 'general', |
||||
|
name: '综合客服', |
||||
|
desc: '可检索全部知识库', |
||||
|
tone: '专业、耐心、简洁', |
||||
|
badge: '默认', |
||||
|
categoryIds: [] |
||||
|
} |
||||
|
|
||||
|
const QUICK_QUESTIONS = [ |
||||
|
'我的订单什么时候发货?', |
||||
|
'已经付款但订单状态没更新怎么办?', |
||||
|
'如何申请退款或退货?', |
||||
|
'物流显示签收但我没收到怎么办?', |
||||
|
'这个商品有哪些售后政策?' |
||||
|
] |
||||
|
|
||||
export default { |
export default { |
||||
template: `
|
template: `
|
||||
<div class="card"> |
|
||||
<h2>💬 智能客服对话</h2> |
|
||||
<p style="color:var(--sub);font-size:13px;margin-bottom:12px;">基于通义千问 · 电商客服场景 · 支持多轮对话上下文记忆</p> |
|
||||
|
|
||||
<div class="input-row"> |
|
||||
<input class="input input-sm" v-model="chatId" placeholder="会话ID"> |
|
||||
<select class="select" v-model="mode"> |
|
||||
<option value="sync">🔵 同步调用</option> |
|
||||
<option value="sse">🟢 SSE 流式 (Flux)</option> |
|
||||
<option value="sse2">🟡 ServerSentEvent</option> |
|
||||
<option value="sse3">🟣 SseEmitter</option> |
|
||||
</select> |
|
||||
<button class="btn btn-outline btn-sm" @click="newChatId">🔄 新会话</button> |
|
||||
<button class="btn btn-outline btn-sm" @click="clearMessages">🗑️ 清屏</button> |
|
||||
|
<div class="chat-shell"> |
||||
|
<aside class="chat-sidebar"> |
||||
|
<div class="agent-card"> |
||||
|
<div class="agent-avatar">SB</div> |
||||
|
<div> |
||||
|
<div class="agent-name">Support Bot</div> |
||||
|
<div class="agent-status"><span></span>在线客服助手</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="side-section"> |
||||
|
<div class="side-label">客服角色</div> |
||||
|
<button |
||||
|
v-for="role in roles" |
||||
|
:key="role.key" |
||||
|
:class="['role-option', selectedRole === role.key ? 'active' : '']" |
||||
|
@click="selectRole(role.key)" |
||||
|
> |
||||
|
<span class="role-badge">{{ role.badge }}</span> |
||||
|
<span> |
||||
|
<strong>{{ role.name }}</strong> |
||||
|
<small>{{ role.desc }}</small> |
||||
|
</span> |
||||
|
</button> |
||||
</div> |
</div> |
||||
|
|
||||
<div class="input-row" style="margin-top:8px;padding:12px;background:#f9fafb;border-radius:8px;border:1px solid var(--border);"> |
|
||||
<label style="display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer;"> |
|
||||
<input type="checkbox" v-model="isRagMode" style="width:16px;height:16px;cursor:pointer;"> |
|
||||
<span style="font-weight:600;color:var(--primary);">📚 启用 RAG 知识库检索</span> |
|
||||
|
<div class="side-section"> |
||||
|
<div class="side-label">知识库范围</div> |
||||
|
<label class="toggle-line"> |
||||
|
<input type="checkbox" v-model="isRagMode"> |
||||
|
<span>启用 RAG 检索</span> |
||||
</label> |
</label> |
||||
<select class="select" v-model="ragStrategy" v-show="isRagMode" style="margin-left:auto;"> |
|
||||
<option value="NONE">无重写</option> |
|
||||
|
<select class="select chat-select" v-model="ragStrategy" :disabled="!isRagMode"> |
||||
|
<option value="NONE">不重写查询</option> |
||||
<option value="REWRITE">查询重写</option> |
<option value="REWRITE">查询重写</option> |
||||
<option value="TRANSLATION">翻译扩展</option> |
<option value="TRANSLATION">翻译扩展</option> |
||||
<option value="COMPRESSION">查询压缩</option> |
<option value="COMPRESSION">查询压缩</option> |
||||
<option value="MULTI_QUERY">多路扩展</option> |
<option value="MULTI_QUERY">多路扩展</option> |
||||
</select> |
</select> |
||||
|
<div class="kb-check-list"> |
||||
|
<label v-for="c in store.categories" :key="c.id" class="kb-check-item"> |
||||
|
<input |
||||
|
type="checkbox" |
||||
|
:checked="selectedCategoryIds.includes(String(c.id))" |
||||
|
@change="toggleRoleCategory(String(c.id), $event.target.checked)" |
||||
|
> |
||||
|
<span>{{ c.name }}</span> |
||||
|
</label> |
||||
|
<div v-if="!store.categories.length" class="kb-empty">暂无知识库分类</div> |
||||
|
</div> |
||||
|
<p class="side-note">{{ roleKnowledgeScopeText }}</p> |
||||
</div> |
</div> |
||||
|
|
||||
<div v-show="isRagMode" style="margin-bottom:12px;padding:8px 12px;background:#eef2ff;border-radius:6px;font-size:12px;color:var(--primary);border-left:3px solid var(--primary);"> |
|
||||
💡 当前已启用 RAG 知识库检索(策略:<strong>{{ ragStrategyNames[ragStrategy] || ragStrategy }}</strong>) |
|
||||
|
<div class="side-section"> |
||||
|
<div class="side-label">当前会话</div> |
||||
|
<div class="session-box" :title="chatId">{{ shortChatId }}</div> |
||||
|
<div class="side-actions"> |
||||
|
<button class="btn btn-outline btn-sm" @click="newChatId">新会话</button> |
||||
|
<button class="btn btn-outline btn-sm" @click="clearMessages">清屏</button> |
||||
|
</div> |
||||
</div> |
</div> |
||||
|
</aside> |
||||
|
|
||||
<div class="msg-area" ref="msgArea"> |
|
||||
<div v-for="(m, i) in messages" :key="i" :class="['msg', m.role, m.streaming ? 'streaming' : '']"> |
|
||||
<div class="msg-avatar">{{ m.role === 'user' ? '👤' : '🤖' }}</div> |
|
||||
<div class="msg-bubble">{{ m.content }}</div> |
|
||||
|
<section class="chat-main"> |
||||
|
<header class="chat-header"> |
||||
|
<div> |
||||
|
<h2>智能客服对话</h2> |
||||
|
<p>{{ currentRole.desc }} · {{ currentRole.tone }}</p> |
||||
</div> |
</div> |
||||
|
<div class="chat-meta"> |
||||
|
<span :class="['rag-pill', isRagMode ? 'on' : '']">{{ ragStatusText }}</span> |
||||
|
<select class="select chat-mode" v-model="mode"> |
||||
|
<option value="sync">同步调用</option> |
||||
|
<option value="sse">SSE 流式</option> |
||||
|
<option value="sse2">ServerSentEvent</option> |
||||
|
<option value="sse3">SseEmitter</option> |
||||
|
</select> |
||||
</div> |
</div> |
||||
|
</header> |
||||
|
|
||||
<div class="input-row"> |
|
||||
<input class="input" v-model="userInput" placeholder="输入问题,Enter 发送..." @keydown.enter="send" :disabled="isSending"> |
|
||||
<button class="btn btn-primary" @click="send" :disabled="isSending">📨 发送</button> |
|
||||
|
<div class="quick-row" v-if="messages.length <= 1"> |
||||
|
<button v-for="q in quickQuestions" :key="q" @click="useQuickQuestion(q)">{{ q }}</button> |
||||
|
</div> |
||||
|
|
||||
|
<div class="msg-area chat-msg-area" ref="msgArea"> |
||||
|
<div v-for="(m, i) in messages" :key="i" :class="['msg', m.role, m.streaming ? 'streaming' : '']"> |
||||
|
<div class="msg-avatar">{{ m.role === 'user' ? '我' : 'AI' }}</div> |
||||
|
<div class="msg-content"> |
||||
|
<div class="msg-bubble">{{ m.content || (m.streaming ? '正在思考...' : '') }}</div> |
||||
|
<div class="msg-tools"> |
||||
|
<span>{{ m.time }}</span> |
||||
|
<button v-if="m.role === 'assistant' && m.content" @click="copyMessage(m.content)">复制</button> |
||||
|
<button v-if="m.error" @click="retryLast">重试</button> |
||||
</div> |
</div> |
||||
</div> |
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<footer class="chat-composer"> |
||||
|
<textarea |
||||
|
class="textarea chat-textarea" |
||||
|
v-model="userInput" |
||||
|
placeholder="输入问题,Enter 发送,Shift + Enter 换行" |
||||
|
@keydown.enter.exact.prevent="send" |
||||
|
:disabled="isSending" |
||||
|
></textarea> |
||||
|
<button class="btn btn-primary send-btn" @click="send" :disabled="isSending || !userInput.trim()"> |
||||
|
{{ isSending ? '发送中' : '发送' }} |
||||
|
</button> |
||||
|
</footer> |
||||
|
</section> |
||||
|
</div> |
||||
`,
|
`,
|
||||
setup() { |
setup() { |
||||
const chatId = ref('') |
const chatId = ref('') |
||||
const mode = ref('sync') |
const mode = ref('sync') |
||||
|
const selectedRole = ref('general') |
||||
|
const roles = ref([FALLBACK_ROLE]) |
||||
const isRagMode = ref(false) |
const isRagMode = ref(false) |
||||
const ragStrategy = ref('REWRITE') |
const ragStrategy = ref('REWRITE') |
||||
const userInput = ref('') |
const userInput = ref('') |
||||
|
const lastUserInput = ref('') |
||||
const isSending = ref(false) |
const isSending = ref(false) |
||||
const messages = ref([ |
const messages = ref([ |
||||
{ role: 'assistant', content: '您好!我是电商智能客服助手。\n可以帮您解答商品、订单、支付、物流和售后问题。\n\n💡 提示:右侧下拉可切换对话模式,切换新会话开始全新对话。\n📚 如需启用知识库检索,请勾选上方的"启用 RAG 知识库检索"选项。', streaming: false } |
|
||||
|
{ |
||||
|
role: 'assistant', |
||||
|
content: '您好,我是电商智能客服助手。可以咨询商品、订单、支付、物流和售后问题;如果需要基于知识库回答,请先开启左侧 RAG 检索。', |
||||
|
streaming: false, |
||||
|
time: formatTime() |
||||
|
} |
||||
]) |
]) |
||||
const msgArea = ref(null) |
const msgArea = ref(null) |
||||
|
|
||||
const ragStrategyNames = { |
|
||||
NONE: '无重写', REWRITE: '查询重写', TRANSLATION: '翻译扩展', |
|
||||
COMPRESSION: '查询压缩', MULTI_QUERY: '多路扩展' |
|
||||
|
const currentRole = computed(() => roles.value.find(role => role.key === selectedRole.value) || roles.value[0] || FALLBACK_ROLE) |
||||
|
const selectedCategoryIds = computed(() => currentRole.value.categoryIds || []) |
||||
|
const selectedCategoryNames = computed(() => { |
||||
|
const selected = new Set(selectedCategoryIds.value) |
||||
|
return store.categories |
||||
|
.filter(category => selected.has(String(category.id))) |
||||
|
.map(category => category.name) |
||||
|
}) |
||||
|
const roleKnowledgeScopeText = computed(() => { |
||||
|
if (!isRagMode.value) return '启用 RAG 后,会按当前客服勾选的知识库范围检索。' |
||||
|
if (selectedCategoryNames.value.length) return `当前客服可检索:${selectedCategoryNames.value.join('、')}。` |
||||
|
return '当前客服未绑定知识库,RAG 会检索全部知识库。' |
||||
|
}) |
||||
|
const shortChatId = computed(() => chatId.value ? chatId.value.replace(/^web_/, '') : '未创建') |
||||
|
const ragStatusText = computed(() => { |
||||
|
if (!isRagMode.value) return '普通对话' |
||||
|
const names = { |
||||
|
NONE: 'RAG:不重写', |
||||
|
REWRITE: 'RAG:查询重写', |
||||
|
TRANSLATION: 'RAG:翻译扩展', |
||||
|
COMPRESSION: 'RAG:查询压缩', |
||||
|
MULTI_QUERY: 'RAG:多路扩展' |
||||
|
} |
||||
|
return names[ragStrategy.value] || 'RAG 已启用' |
||||
|
}) |
||||
|
|
||||
|
function formatTime() { |
||||
|
return new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) |
||||
} |
} |
||||
|
|
||||
function newChatId() { |
function newChatId() { |
||||
chatId.value = 'web_' + Date.now() + '_' + Math.random().toString(36).substr(2, 6) |
|
||||
|
chatId.value = 'web_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8) |
||||
|
} |
||||
|
|
||||
|
function selectRole(roleKey) { |
||||
|
selectedRole.value = roleKey |
||||
|
newChatId() |
||||
|
clearMessages(roleKey) |
||||
|
} |
||||
|
|
||||
|
function clearMessages(roleKey = selectedRole.value) { |
||||
|
const role = roles.value.find(item => item.key === roleKey) || FALLBACK_ROLE |
||||
|
messages.value = [{ |
||||
|
role: 'assistant', |
||||
|
content: `已切换到${role.name}。我会按“${role.tone}”的方式处理问题。`, |
||||
|
streaming: false, |
||||
|
time: formatTime() |
||||
|
}] |
||||
} |
} |
||||
|
|
||||
function clearMessages() { |
|
||||
messages.value = [{ role: 'assistant', content: '已清屏,开始新的对话吧~', streaming: false }] |
|
||||
|
function useQuickQuestion(question) { |
||||
|
userInput.value = question |
||||
|
send() |
||||
|
} |
||||
|
|
||||
|
async function loadRoles() { |
||||
|
try { |
||||
|
const json = await getRoleList() |
||||
|
if (json.success) { |
||||
|
roles.value = (json.data || []).map(role => ({ |
||||
|
id: role.id, |
||||
|
key: role.role_key || role.roleKey || String(role.id), |
||||
|
name: role.name, |
||||
|
desc: role.description || '', |
||||
|
tone: role.prompt || '专业、耐心、简洁', |
||||
|
badge: role.name ? role.name.slice(0, 2) : '角色', |
||||
|
categoryIds: (role.categoryIds || []).map(String) |
||||
|
})) |
||||
|
if (!roles.value.some(role => role.key === selectedRole.value)) { |
||||
|
selectedRole.value = roles.value[0]?.key || 'general' |
||||
|
} |
||||
|
} |
||||
|
} catch (e) { |
||||
|
toast('加载客服角色失败:' + e.message, 'error') |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function toggleRoleCategory(categoryId, checked) { |
||||
|
const role = currentRole.value |
||||
|
if (!role.id) { |
||||
|
toast('当前角色未保存到数据库,不能配置知识库', 'error') |
||||
|
return |
||||
|
} |
||||
|
const current = new Set(role.categoryIds || []) |
||||
|
if (checked) { |
||||
|
current.add(categoryId) |
||||
|
} else { |
||||
|
current.delete(categoryId) |
||||
|
} |
||||
|
const nextIds = Array.from(current) |
||||
|
await updateRoleCategories(role.id, nextIds) |
||||
|
roles.value = roles.value.map(item => item.key === role.key ? { ...item, categoryIds: nextIds } : item) |
||||
|
toast('角色知识库范围已保存', 'success') |
||||
|
} |
||||
|
|
||||
|
function buildRoleAwareMessage(text) { |
||||
|
const scope = selectedCategoryNames.value.length |
||||
|
? `仅使用这些知识库范围内的信息:${selectedCategoryNames.value.join('、')}` |
||||
|
: '可使用全部知识库范围内的信息' |
||||
|
return `当前客服角色:${currentRole.value.name}。回复风格:${currentRole.value.tone}。知识库范围:${scope}。用户问题:${text}` |
||||
|
} |
||||
|
|
||||
|
async function scrollToBottom() { |
||||
|
await nextTick() |
||||
|
if (msgArea.value) msgArea.value.scrollTop = msgArea.value.scrollHeight |
||||
} |
} |
||||
|
|
||||
async function send() { |
async function send() { |
||||
const text = userInput.value.trim() |
const text = userInput.value.trim() |
||||
if (!text || isSending.value) return |
if (!text || isSending.value) return |
||||
|
|
||||
userInput.value = '' |
userInput.value = '' |
||||
|
lastUserInput.value = text |
||||
isSending.value = true |
isSending.value = true |
||||
|
messages.value.push({ role: 'user', content: text, streaming: false, time: formatTime() }) |
||||
|
|
||||
// 添加用户消息
|
|
||||
messages.value.push({ role: 'user', content: text, streaming: false }) |
|
||||
|
|
||||
// 添加助手占位
|
|
||||
const assistantMsg = { role: 'assistant', content: '', streaming: true } |
|
||||
|
const assistantMsg = { role: 'assistant', content: '', streaming: true, time: formatTime() } |
||||
messages.value.push(assistantMsg) |
messages.value.push(assistantMsg) |
||||
|
await scrollToBottom() |
||||
|
|
||||
const cid = chatId.value || ('web_' + Date.now()) |
const cid = chatId.value || ('web_' + Date.now()) |
||||
chatId.value = cid |
chatId.value = cid |
||||
|
const requestText = buildRoleAwareMessage(text) |
||||
|
|
||||
try { |
try { |
||||
if (isRagMode.value && mode.value === 'sync') { |
if (isRagMode.value && mode.value === 'sync') { |
||||
// RAG 同步
|
|
||||
const result = await chatRagSync(text, cid, ragStrategy.value) |
|
||||
assistantMsg.content = result |
|
||||
|
assistantMsg.content = await chatRagSync(requestText, cid, ragStrategy.value, selectedCategoryIds.value) |
||||
} else if (isRagMode.value && mode.value !== 'sync') { |
} else if (isRagMode.value && mode.value !== 'sync') { |
||||
assistantMsg.content = '⚠️ RAG 模式仅支持同步调用' |
|
||||
|
assistantMsg.content = '当前后端 RAG 接口只提供同步调用,请切换为“同步调用”后再试。' |
||||
|
assistantMsg.error = true |
||||
toast('RAG 模式暂不支持流式调用', 'error') |
toast('RAG 模式暂不支持流式调用', 'error') |
||||
} else if (mode.value === 'sync') { |
} else if (mode.value === 'sync') { |
||||
// 普通同步
|
|
||||
const result = await chatSync(text, cid) |
|
||||
assistantMsg.content = result |
|
||||
|
assistantMsg.content = await chatSync(requestText, cid) |
||||
} else { |
} else { |
||||
// SSE 流式
|
|
||||
const url = chatSSEUrl(text, cid, mode.value) |
|
||||
await readSSEStream(url, |
|
||||
(chunk) => { assistantMsg.content += chunk }, |
|
||||
() => {} |
|
||||
) |
|
||||
|
const url = chatSSEUrl(requestText, cid, mode.value) |
||||
|
await readSSEStream(url, async (chunk) => { |
||||
|
assistantMsg.content += chunk |
||||
|
await scrollToBottom() |
||||
|
}, () => {}) |
||||
} |
} |
||||
} catch (e) { |
} catch (e) { |
||||
assistantMsg.content = '请求失败:' + e.message |
assistantMsg.content = '请求失败:' + e.message |
||||
|
assistantMsg.error = true |
||||
toast('对话失败:' + e.message, 'error') |
toast('对话失败:' + e.message, 'error') |
||||
} finally { |
} finally { |
||||
assistantMsg.streaming = false |
assistantMsg.streaming = false |
||||
isSending.value = false |
isSending.value = false |
||||
// 滚动到底部
|
|
||||
nextTick(() => { |
|
||||
if (msgArea.value) msgArea.value.scrollTop = msgArea.value.scrollHeight |
|
||||
}) |
|
||||
|
await scrollToBottom() |
||||
|
} |
||||
} |
} |
||||
|
|
||||
|
function retryLast() { |
||||
|
if (!lastUserInput.value || isSending.value) return |
||||
|
userInput.value = lastUserInput.value |
||||
|
send() |
||||
} |
} |
||||
|
|
||||
// 初始化会话 ID
|
|
||||
|
async function copyMessage(content) { |
||||
|
try { |
||||
|
await navigator.clipboard.writeText(content) |
||||
|
toast('已复制回复', 'success') |
||||
|
} catch (e) { |
||||
|
toast('复制失败', 'error') |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
onMounted(() => { |
||||
newChatId() |
newChatId() |
||||
|
store.loadCategories() |
||||
|
loadRoles() |
||||
|
}) |
||||
|
|
||||
return { |
return { |
||||
chatId, mode, isRagMode, ragStrategy, ragStrategyNames, |
|
||||
userInput, isSending, messages, msgArea, |
|
||||
newChatId, clearMessages, send |
|
||||
|
store, |
||||
|
roles, |
||||
|
quickQuestions: QUICK_QUESTIONS, |
||||
|
chatId, |
||||
|
mode, |
||||
|
selectedRole, |
||||
|
selectedCategoryIds, |
||||
|
isRagMode, |
||||
|
ragStrategy, |
||||
|
userInput, |
||||
|
isSending, |
||||
|
messages, |
||||
|
msgArea, |
||||
|
currentRole, |
||||
|
roleKnowledgeScopeText, |
||||
|
shortChatId, |
||||
|
ragStatusText, |
||||
|
newChatId, |
||||
|
selectRole, |
||||
|
clearMessages, |
||||
|
useQuickQuestion, |
||||
|
toggleRoleCategory, |
||||
|
send, |
||||
|
retryLast, |
||||
|
copyMessage |
||||
} |
} |
||||
} |
} |
||||
} |
} |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue