feat: 新增多风格话术流式生成功能,优化对话查询逻辑

1. 新增多风格话术生成SSE接口,支持亲和/专业/促单风格话术流式输出
2. 重构对话查询逻辑,增加staffId参数避免消息串扰,新增自动推断员工身份接口
3. 优化前端侧边栏,适配企微聊天上下文,新增员工信息展示和登录流程
4. 补充相关Mapper和Service代码,完善归档消息查询能力
5. 更新部署文档,优化前端缓存处理
This commit is contained in:
jiao 2026-06-11 13:23:22 +08:00
parent 5e33db1ad3
commit 5606a56777
36 changed files with 562 additions and 221 deletions

View File

@ -20,17 +20,36 @@
# 1. 本地构建
cd frontend/sidebar && npm run build
# 2. 同步到服务器
# 2. 同步到服务器(注意带 JS hash 的文件名会变化)
scp -r -o StrictHostKeyChecking=no -i root.pem frontend/sidebar/dist/* root@8.133.162.25:/www/wwwroot/deploy-package/frontend/sidebar/dist/
# 3. SSH 登录服务器,重新构建镜像并重启容器
# 3. SSH 登录服务器,更新 index.html 缓存戳并重启容器
ssh -o StrictHostKeyChecking=no -i root.pem root@8.133.162.25 << 'EOF'
cd /www/wwwroot/deploy-package/frontend/sidebar
docker build -t deploy-package-sidebar .
docker stop ai-assistant-sidebar
docker rm ai-assistant-sidebar
cd /www/wwwroot/deploy-package
docker-compose up -d sidebar
cd /www/wwwroot/deploy-package/frontend/sidebar/dist
# 更新 index.html 中的 JS/CSS 引用,带上版本戳防缓存
JS_FILE=$(ls assets/index-*.js | head -1)
CSS_FILE=$(ls assets/index-*.css | head -1)
cat > index.html << HTML
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<title>AI坐席助手</title>
<script src="https://res.wx.qq.com/open/js/jweixin-1.2.0.js"></script>
<script type="module" crossorigin src="/sidebar/$JS_FILE?v=$(date +%s)"></script>
<link rel="stylesheet" crossorigin href="/sidebar/$CSS_FILE?v=$(date +%s)">
</head>
<body>
<div id="root"></div>
</body>
</html>
HTML
# 复制到容器并重启
docker cp . ai-assistant-sidebar:/usr/share/nginx/html/
docker exec ai-assistant-sidebar nginx -s reload
docker restart ai-assistant-nginx
EOF
```
@ -92,9 +111,12 @@ cd backend/auth-service && mvn clean package -DskipTests
scp -o StrictHostKeyChecking=no -i root.pem target/*.jar root@8.133.162.25:/www/wwwroot/deploy-package/backend/auth-service/
# 3. SSH 登录服务器,重启对应容器
# ⚠️ 注意:Dockerfile 中 COPY 的是 app.jar,必须先把新 jar 复制为 app.jar
ssh -o StrictHostKeyChecking=no -i root.pem root@8.133.162.25 << 'EOF'
cd /www/wwwroot/deploy-package/backend/auth-service
cp auth-service-*.jar app.jar
cd /www/wwwroot/deploy-package
docker-compose up -d --build auth-service
docker-compose up -d --build --no-cache auth-service
EOF
```

View File

@ -65,15 +65,17 @@ public class ConversationController {
@GetMapping("/last-message")
public Result<Map<String, Object>> getLastMessage(
@NotBlank(message = "customerId不能为空") @RequestParam("customerId") String customerId,
@NotBlank(message = "staffId不能为空") @RequestParam("staffId") String staffId,
@NotBlank(message = "corpId不能为空") @RequestParam("corpId") String corpId) {
ArchiveMessage message = archiveMessageMapper.selectLastMessage(customerId, corpId);
ArchiveMessage message = archiveMessageMapper.selectLastMessage(customerId, staffId, corpId);
Map<String, Object> result = new HashMap<>();
if (message != null) {
result.put("content", message.getContent());
result.put("msgTime", message.getMsgtime());
result.put("msgType", message.getMsgtype());
result.put("fromUser", message.getFromUser());
result.put("fromRole", message.getFromRole());
}
return Result.success(result);
}

View File

@ -9,11 +9,18 @@ import org.apache.ibatis.annotations.Select;
@Mapper
public interface ArchiveMessageMapper extends BaseMapper<ArchiveMessage> {
/**
* 获取指定学员在指定对话中的最后一条消息
* 通过 staffId 限定只查当前销售与学员的对话,避免学员同时和多个销售聊天时串消息
*/
@Select("SELECT * FROM archive_messages " +
"WHERE (from_user = #{userId} OR to_user LIKE CONCAT('%', #{userId}, '%')) " +
"WHERE from_user = #{userId} " +
"AND to_user LIKE CONCAT('%', #{staffId}, '%') " +
"AND corp_id = #{corpId} " +
"AND from_role = 'EXTERNAL' " +
"AND content IS NOT NULL AND content != '' " +
"ORDER BY msgtime DESC LIMIT 1")
ArchiveMessage selectLastMessage(@Param("userId") String userId,
@Param("staffId") String staffId,
@Param("corpId") String corpId);
}

View File

@ -9,13 +9,19 @@ import com.artedu.generation.service.PromptEngine;
import com.artedu.generation.service.QianwenClient;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import javax.validation.constraints.NotBlank;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@Slf4j
@Validated
@ -35,6 +41,73 @@ public class GenerationController {
@Autowired
private ArchiveMessageMapper archiveMessageMapper;
private final ExecutorService sseExecutor = Executors.newCachedThreadPool();
private static final List<Map<String, String>> STYLES = List.of(
Map.of("id", "friendly", "name", "亲和风格", "desc", "温暖亲切,像朋友聊天"),
Map.of("id", "professional", "name", "专业风格", "desc", "数据驱动,专业权威"),
Map.of("id", "urgent", "name", "促单风格", "desc", "制造紧迫感,促成决策")
);
/**
* 根据当前聊天对象自动推断员工身份
* 前端不知道 toUser 时调用,后端从聊天记录中反推
*/
@GetMapping("/whoami")
public Result<Map<String, Object>> whoami(
@RequestParam(value = "fromUser", required = false) String fromUser,
@RequestParam(value = "roomid", required = false) String roomid) {
String staffId = null;
String chatType = null;
if (roomid != null && !roomid.isEmpty()) {
// 群聊:从群消息中找最近一条 INTERNAL 角色的发送者
chatType = "group";
List<ArchiveMessage> msgs = archiveMessageMapper.selectGroupChatMessages(
roomid, get7DaysAgoStartTime(), getTodayEndTime());
if (msgs != null && !msgs.isEmpty()) {
for (ArchiveMessage msg : msgs) {
if ("INTERNAL".equals(msg.getFromRole())) {
staffId = msg.getFromUser();
break;
}
}
}
} else if (fromUser != null && !fromUser.isEmpty()) {
// 单聊:查该客户最近参与的所有消息,推断员工
chatType = "single";
List<ArchiveMessage> msgs = archiveMessageMapper.selectMessagesByFromUser(
fromUser, get7DaysAgoStartTime(), getTodayEndTime());
if (msgs != null && !msgs.isEmpty()) {
// 取最近一条,fromUser 是客户则 from_user=客户/to_user=员工 或 from_user=员工/to_user=客户
ArchiveMessage msg = msgs.get(0);
if (fromUser.equals(msg.getFromUser())) {
// 客户发的消息,to_user 里的是员工ID(JSON数组)
staffId = msg.getToUser();
} else {
// 员工发的消息,from_user 就是员工ID
staffId = msg.getFromUser();
}
// 清理 JSON 数组格式 ["userId"]
if (staffId != null) {
staffId = staffId.replaceAll("\\[|\\]|\"", "").trim();
}
}
}
if (staffId == null || staffId.isEmpty()) {
return Result.fail("未找到聊天记录,无法推断员工身份");
}
Map<String, Object> result = new HashMap<>();
result.put("userId", staffId);
result.put("chatType", chatType);
result.put("fromUser", fromUser);
result.put("roomid", roomid);
return Result.success(result);
}
@PostMapping("/utterance")
public Result<Map<String, Object>> generateUtterance(
@RequestParam(value = "fromUser", required = false) String fromUser,
@ -62,18 +135,25 @@ public class GenerationController {
List<ArchiveMessage> messages;
boolean isGroupChat = roomid != null && !roomid.isEmpty();
long startTime = getTodayStartTime();
// 查最近 7 天,避免今天没聊天就找不到记录
long startTime = get7DaysAgoStartTime();
long endTime = getTodayEndTime();
if (isGroupChat) {
messages = archiveMessageMapper.selectGroupChatMessages(roomid, startTime, endTime);
} else {
if (fromUser == null || fromUser.isEmpty() || toUser == null || toUser.isEmpty()) {
log.warn("单聊模式需要fromUser和toUser参数");
if (fromUser == null || fromUser.isEmpty()) {
log.warn("单聊模式需要fromUser参数");
return null;
}
toUser = "[\""+toUser+"\"]";
messages = archiveMessageMapper.selectSingleChatMessages(fromUser, toUser, startTime, endTime);
// toUser 为空或无效时,用兜底查询(根据 fromUser 查所有相关记录)
if (toUser == null || toUser.isEmpty() || "staff_001".equals(toUser)) {
log.warn("toUser无效,使用兜底查询, fromUser={}, toUser={}", fromUser, toUser);
messages = archiveMessageMapper.selectMessagesByFromUser(fromUser, startTime, endTime);
} else {
toUser = "[\""+toUser+"\"]";
messages = archiveMessageMapper.selectSingleChatMessages(fromUser, toUser, startTime, endTime);
}
}
if (messages == null || messages.isEmpty()) {
@ -95,6 +175,120 @@ public class GenerationController {
return java.time.LocalDate.now().plusDays(1).atStartOfDay(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli() - 1;
}
private long get7DaysAgoStartTime() {
return java.time.LocalDate.now().minusDays(7).atStartOfDay(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli();
}
/**
* 流式生成多风格话术(SSE)
* 页面打开即自动推荐 3 条不同风格话术
*/
@GetMapping(value = "/utterance-stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter streamUtterance(
@RequestParam("fromUser") String fromUser,
@RequestParam("toUser") String toUser,
@RequestParam(value = "roomid", required = false) String roomid) {
SseEmitter emitter = new SseEmitter(120000L);
sseExecutor.execute(() -> {
try {
Map<String, Object> startEvent = new HashMap<>();
startEvent.put("type", "start");
startEvent.put("styles", STYLES);
emitter.send(SseEmitter.event().name("start").data(startEvent));
String chatContext = buildChatContext(fromUser, toUser, roomid);
if (chatContext == null || chatContext.isEmpty()) {
Map<String, Object> err = new HashMap<>();
err.put("type", "error");
err.put("message", "未找到聊天记录");
emitter.send(SseEmitter.event().name("error").data(err));
emitter.complete();
return;
}
String systemPrompt = promptEngine.buildSystemPrompt(chatContext);
String userPrompt = promptEngine.buildMultiStyleUserPrompt();
long startTime = System.currentTimeMillis();
String generated = qianwenClient.generate(systemPrompt, userPrompt, 1200);
log.info("多风格话术生成完成, elapsed={}ms", System.currentTimeMillis() - startTime);
List<String> utterances = parseMultiStyle(generated);
if (utterances.size() < 3) {
while (utterances.size() < 3) {
utterances.add(generated != null ? generated : "生成失败,请稍后重试");
}
}
for (int i = 0; i < Math.min(3, utterances.size()); i++) {
String text = utterances.get(i);
String styleId = STYLES.get(i).get("id");
Map<String, Object> itemStart = new HashMap<>();
itemStart.put("type", "item-start");
itemStart.put("styleId", styleId);
itemStart.put("index", i);
emitter.send(SseEmitter.event().name("item-start").data(itemStart));
for (int j = 0; j < text.length(); j++) {
String ch = text.substring(j, j + 1);
Map<String, Object> chunk = new HashMap<>();
chunk.put("type", "chunk");
chunk.put("styleId", styleId);
chunk.put("text", ch);
chunk.put("index", i);
emitter.send(SseEmitter.event().name("chunk").data(chunk));
Thread.sleep(25);
}
Map<String, Object> complete = new HashMap<>();
complete.put("type", "complete");
complete.put("styleId", styleId);
complete.put("fullText", text);
complete.put("index", i);
emitter.send(SseEmitter.event().name("complete").data(complete));
}
Map<String, Object> done = new HashMap<>();
done.put("type", "done");
done.put("model", qianwenClient.getCurrentModel() != null
? qianwenClient.getCurrentModel().getModelName() : "default");
emitter.send(SseEmitter.event().name("done").data(done));
emitter.complete();
} catch (Exception e) {
log.error("流式生成失败", e);
try {
Map<String, Object> error = new HashMap<>();
error.put("type", "error");
error.put("message", e.getMessage());
emitter.send(SseEmitter.event().name("error").data(error));
} catch (IOException ignored) {}
emitter.completeWithError(e);
}
});
return emitter;
}
/**
* 解析多风格话术,按 "|||" 分隔
*/
private List<String> parseMultiStyle(String text) {
List<String> result = new ArrayList<>();
if (text == null || text.isEmpty()) return result;
String[] parts = text.split("\\|\\|\\|");
for (String part : parts) {
String trimmed = part.trim();
if (!trimmed.isEmpty() && trimmed.length() > 5) {
result.add(trimmed);
}
}
return result;
}
@PostMapping("/summary")
public Result<Map<String, Object>> generateSummary(
@NotBlank(message = "context不能为空") @RequestParam("context") String context) {

View File

@ -36,4 +36,26 @@ public interface ArchiveMessageMapper extends BaseMapper<ArchiveMessage> {
List<ArchiveMessage> selectGroupChatMessages(@Param("roomid") String roomid,
@Param("startTime") Long startTime,
@Param("endTime") Long endTime);
/**
* 根据 fromUser 查询该用户参与的所有单聊记录(不限制 toUser,用于 toUser 未知时兜底)
*/
@Select("SELECT msgtime, created_at, from_user, to_user, from_role, content FROM archive_messages " +
"WHERE (from_user = #{fromUser} OR to_user = #{fromUser}) " +
"AND msgtime >= #{startTime} AND msgtime < #{endTime} AND msgtype='text' " +
"AND content IS NOT NULL AND content != '' " +
"ORDER BY msgtime DESC LIMIT 100")
List<ArchiveMessage> selectMessagesByFromUser(@Param("fromUser") String fromUser,
@Param("startTime") Long startTime,
@Param("endTime") Long endTime);
/**
* 根据 fromUser 查询最近一条消息的 to_user(推断员工身份)
* to_user 格式为 JSON 数组 ["userId"]
*/
@Select("SELECT to_user FROM archive_messages " +
"WHERE (from_user = #{fromUser} OR to_user LIKE CONCAT('%', #{fromUser}, '%')) " +
"AND msgtype='text' AND content IS NOT NULL AND content != '' " +
"ORDER BY msgtime DESC LIMIT 1")
String selectRecentToUserByFromUser(@Param("fromUser") String fromUser);
}

View File

@ -129,6 +129,30 @@ public class PromptEngine {
return sb.toString();
}
/**
* 多风格话术生成的用户提示词
* 要求 LLM 输出 3 条不同风格话术,用 ||| 分隔
*/
public String buildMultiStyleUserPrompt() {
StringBuilder sb = new StringBuilder();
sb.append("请根据上述对话历史,生成 3 条不同风格的推荐回复话术。\n\n");
sb.append("风格要求(严格按顺序输出):\n");
sb.append("第1条 - 亲和风格:温暖亲切,像朋友聊天,拉近距离\n");
sb.append("第2条 - 专业风格:用数据、案例、专业分析增强说服力\n");
sb.append("第3条 - 促单风格:制造紧迫感,强调限时优惠或名额有限\n\n");
sb.append("输出要求(必须遵守):\n");
sb.append("- 每条话术 80-150 字,简洁自然\n");
sb.append("- 话术之间用「|||」分隔,不要加序号、标题或任何解释\n");
sb.append("- 直接输出话术内容,不要有开场白、总结、备注\n\n");
sb.append("输出格式示例:\n");
sb.append("亲和风格的话术内容...\n");
sb.append("|||\n");
sb.append("专业风格的话术内容...\n");
sb.append("|||\n");
sb.append("促单风格的话术内容...");
return sb.toString();
}
public String buildSummaryPrompt(String context) {
return "请对以下对话进行简要总结(50字以内),提炼学员核心需求和当前阶段:\n\n" + context;
}

View File

@ -1,7 +1,9 @@
com\artedu\generation\service\QianwenClient.class
com\artedu\generation\controller\GenerateRequest.class
com\artedu\generation\entity\ArchiveMessage.class
com\artedu\generation\service\LlmModelService.class
com\artedu\generation\client\DoubaoClient.class
com\artedu\generation\mapper\ArchiveMessageMapper.class
com\artedu\generation\client\LLMClientFactory.class
com\artedu\generation\controller\LlmModelController.class
com\artedu\generation\mapper\LlmModelMapper.class

View File

@ -7,8 +7,10 @@ D:\www\agent_9art\backend\generation-service\src\main\java\com\artedu\generation
D:\www\agent_9art\backend\generation-service\src\main\java\com\artedu\generation\controller\GenerateRequest.java
D:\www\agent_9art\backend\generation-service\src\main\java\com\artedu\generation\controller\GenerationController.java
D:\www\agent_9art\backend\generation-service\src\main\java\com\artedu\generation\controller\LlmModelController.java
D:\www\agent_9art\backend\generation-service\src\main\java\com\artedu\generation\entity\ArchiveMessage.java
D:\www\agent_9art\backend\generation-service\src\main\java\com\artedu\generation\entity\LlmModel.java
D:\www\agent_9art\backend\generation-service\src\main\java\com\artedu\generation\GenerationServiceApplication.java
D:\www\agent_9art\backend\generation-service\src\main\java\com\artedu\generation\mapper\ArchiveMessageMapper.java
D:\www\agent_9art\backend\generation-service\src\main\java\com\artedu\generation\mapper\LlmModelMapper.java
D:\www\agent_9art\backend\generation-service\src\main\java\com\artedu\generation\service\LlmModelService.java
D:\www\agent_9art\backend\generation-service\src\main\java\com\artedu\generation\service\PromptEngine.java

View File

@ -1,86 +1,129 @@
import { useEffect, useState } from 'react'
import { Tabs, Toast } from 'antd-mobile'
import { useEffect, useState, useCallback } from 'react'
import ScriptRecommendStream from './components/ScriptRecommendStream'
import ConversationAnalysis from './components/ConversationAnalysis'
import './styles.css'
const CORPID = 'wwd483c2fba24ae30a'
const AGENTID = '1000021'
const REDIRECT_URI = encodeURIComponent('https://ai.9artedu.com/sidebar/')
function App() {
const [activeTab, setActiveTab] = useState('recommend')
const [authState, setAuthState] = useState<'loading' | 'authed' | 'error'>('loading')
const [authState, setAuthState] = useState<'loading' | 'authed' | 'needLogin' | 'error'>('loading')
const [userInfo, setUserInfo] = useState<any>(null)
const [currentCustomerId, setCurrentCustomerId] = useState<string>('')
const [chatContext, setChatContext] = useState<{ chatType?: string; chatId?: string }>({})
useEffect(() => {
initWeCom()
initAuth()
}, [])
const initWeCom = async () => {
useEffect(() => {
if (authState === 'authed') {
initWxConfig()
}
}, [authState])
const noCacheFetch = useCallback(async (url: string, options?: RequestInit) => {
const sep = url.includes('?') ? '&' : '?'
const nocacheUrl = `${url}${sep}_t=${Date.now()}`
return fetch(nocacheUrl, {
...options,
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
...options?.headers
}
})
}, [])
// ========== 认证流程 ==========
const initAuth = async () => {
try {
const urlParams = new URLSearchParams(window.location.search)
const code = urlParams.get('code')
// 1. URL 中有 code → 移除并登录
if (code) {
const res = await fetch(`/api/v1/auth/login?code=${encodeURIComponent(code)}`, { method: 'POST' })
const data = await res.json()
if (data.code === 0 && data.data) {
localStorage.setItem('staff_token', data.data.token)
setUserInfo(data.data)
setAuthState('authed')
} else {
setAuthState('error')
Toast.show({ content: '登录失败: ' + (data.message || '未知错误'), icon: 'fail' })
}
} else {
const token = localStorage.getItem('staff_token')
if (token) {
const res = await fetch('/api/v1/auth/info', {
headers: { Authorization: `Bearer ${token}` }
})
const data = await res.json()
if (data.code === 0 && data.data) {
setUserInfo(data.data)
setAuthState('authed')
} else {
redirectToOAuth()
}
} else {
redirectToOAuth()
}
const cleanUrl = window.location.pathname + window.location.hash
window.history.replaceState({}, document.title, cleanUrl)
const ok = await tryCodeLogin(code)
if (ok) return
// login 失败 → 可能是 code 过期/已被使用,显示登录按钮让用户手动重试
setAuthState('needLogin')
return
}
// 2. 无 code → 检查 localStorage 缓存
const cachedUserId = localStorage.getItem('staff_user_id')
const cachedUserName = localStorage.getItem('staff_user_name')
const cachedToken = localStorage.getItem('staff_token')
if (cachedUserId && cachedToken) {
setUserInfo({
userId: cachedUserId,
userName: cachedUserName || cachedUserId,
avatar: localStorage.getItem('staff_avatar') || '',
corpId: CORPID,
role: localStorage.getItem('staff_role') || 'SEAT'
})
setAuthState('authed')
console.log('使用缓存员工信息:', cachedUserId)
return
}
// 3. 无 code 无缓存 → 显示登录按钮(iframe 中自动跳转可能被拦截)
setAuthState('needLogin')
} catch (e) {
console.error('initAuth error:', e)
setAuthState('error')
Toast.show({ content: '初始化失败', icon: 'fail' })
}
}
const redirectToOAuth = () => {
setAuthState('authed')
setUserInfo({
userId: 'staff_001',
userName: '测试顾问',
corpId: 'wwd483c2fba24ae30a',
role: 'SEAT'
})
// 用 code 调后端 login 接口
const tryCodeLogin = async (code: string): Promise<boolean> => {
try {
const res = await noCacheFetch(`/api/v1/auth/login?code=${encodeURIComponent(code)}`, { method: 'POST' })
const data = await res.json()
if (data.code === 0 && data.data) {
const info = data.data
localStorage.setItem('staff_token', info.token)
localStorage.setItem('staff_user_id', info.userId)
localStorage.setItem('staff_user_name', info.userName || '')
localStorage.setItem('staff_avatar', info.avatar || '')
localStorage.setItem('staff_role', info.role || 'SEAT')
setUserInfo(info)
setAuthState('authed')
console.log('OAuth 登录成功:', info.userId, info.userName)
return true
}
console.error('OAuth login failed:', data.message)
} catch (e) {
console.error('Code login error:', e)
}
return false
}
// 跳转企微 OAuth 授权页
const redirectToWeComOAuth = () => {
const oauthUrl = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${CORPID}&redirect_uri=${REDIRECT_URI}&response_type=code&scope=snsapi_base&agentid=${AGENTID}&state=sidebar#wechat_redirect`
console.log('跳转企微 OAuth:', oauthUrl)
window.location.href = oauthUrl
}
// ========== JS-SDK 初始化(认证完成后调用)==========
const initWxConfig = async () => {
const wx = (window as any).wx
if (!wx || !wx.config) {
console.log('非企微环境,跳过JS-SDK初始化')
console.log('非企微环境,跳过 JS-SDK')
return
}
try {
const currentUrl = window.location.href.split('#')[0]
const currentUrl = window.location.href.split('#')[0]
// 1. 企业级 wx.config
const corpRes = await fetch(`/api/v1/auth/signature?url=${encodeURIComponent(currentUrl)}`)
try {
const corpRes = await noCacheFetch(`/api/v1/auth/signature?url=${encodeURIComponent(currentUrl)}`)
const corpData = await corpRes.json()
if (corpData.code !== 0 || !corpData.data) {
console.error('获取JS-SDK企业签名失败')
return
}
if (corpData.code !== 0 || !corpData.data) return
const cfg = corpData.data
wx.config({
@ -94,13 +137,9 @@ function App() {
})
wx.ready(async () => {
console.log('JS-SDK wx.config 成功')
// 2. 尝试应用级 agentConfig(获取客户联系权限)
try {
const agentRes = await fetch(`/api/v1/auth/signature?type=agent&agentId=1000021&url=${encodeURIComponent(currentUrl)}`)
const agentRes = await noCacheFetch(`/api/v1/auth/signature?type=agent&agentId=${AGENTID}&url=${encodeURIComponent(currentUrl)}`)
const agentData = await agentRes.json()
if (agentData.code === 0 && agentData.data) {
const ac = agentData.data
wx.agentConfig({
@ -111,78 +150,140 @@ function App() {
signature: ac.signature,
jsApiList: ['sendChatMessage', 'getContext', 'getCurExternalContact'],
success: () => {
console.log('✅ agentConfig 成功,已获取客户联系权限')
// agentConfig 成功后,获取当前客户ID
wx.invoke('getContext', {}, (ctx: any) => {
if (ctx.err_msg === 'getContext:ok') {
setChatContext({ chatType: ctx.chatType, chatId: ctx.chatId })
if (ctx.chatType === 'single' && ctx.chatId) {
localStorage.setItem('current_customer_id', ctx.chatId)
setCurrentCustomerId(ctx.chatId)
}
}
})
wx.invoke('getCurExternalContact', {}, (res: any) => {
if (res.err_msg === 'getCurExternalContact:ok' && res.userId) {
console.log('✅ 获取到客户ID:', res.userId)
localStorage.setItem('current_customer_id', res.userId)
setCurrentCustomerId(res.userId)
} else {
console.warn('getCurExternalContact 失败:', res.err_msg)
fallbackToCachedId()
}
})
},
fail: (err: any) => {
console.warn('⚠️ agentConfig 失败(无客户联系权限):', err.err_msg || err)
fallbackToCachedId()
}
fail: (err: any) => console.warn('agentConfig fail:', err)
})
} else {
console.warn('⚠️ 获取应用签名失败:', agentData.message)
fallbackToCachedId()
}
} catch (e) {
console.warn('⚠️ agentConfig 流程异常:', e)
fallbackToCachedId()
console.error('agentConfig error:', e)
}
})
wx.error((err: any) => {
console.error('JS-SDK wx.config 失败:', err)
fallbackToCachedId()
})
wx.error((err: any) => console.error('wx.config error:', err))
} catch (e) {
console.error('初始化JS-SDK异常:', e)
fallbackToCachedId()
console.error('initWxConfig error:', e)
}
}
const fallbackToCachedId = () => {
const cachedId = localStorage.getItem('current_customer_id')
if (cachedId && cachedId !== '${userId}') {
setCurrentCustomerId(cachedId)
}
}
useEffect(() => {
if (authState === 'authed') {
initWxConfig()
}
}, [authState])
if (authState === 'loading') {
return (
<div className="app" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh' }}>
<div style={{ textAlign: 'center', color: '#999' }}>
<div style={{ fontSize: 16, marginBottom: 8 }}>加载中...</div>
<div style={{ fontSize: 13 }}>正在连接企微</div>
<div style={{ fontSize: 13 }}>正在识别员工身份...</div>
</div>
</div>
)
}
if (authState === 'needLogin') {
return (
<div className="app" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh', padding: 20 }}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 18, marginBottom: 8, color: '#333', fontWeight: 500 }}>AI 坐席助手</div>
<div style={{ fontSize: 13, color: '#999', marginBottom: 24 }}>请登录以获取员工身份</div>
<button
onClick={redirectToWeComOAuth}
style={{
padding: '10px 32px',
background: '#1677ff',
color: '#fff',
border: 'none',
borderRadius: 6,
fontSize: 15,
cursor: 'pointer',
fontWeight: 500
}}
>
企微一键登录
</button>
<div style={{ fontSize: 12, color: '#bbb', marginTop: 16 }}>
登录后即可使用智能推荐和对话分析
</div>
</div>
</div>
)
}
if (authState === 'error') {
return (
<div className="app" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh', padding: 20 }}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 16, marginBottom: 8, color: '#cf1322' }}>⚠️ 登录失败</div>
<div style={{ fontSize: 13, color: '#999', marginBottom: 16 }}>请关闭侧边栏后重新打开</div>
<button
onClick={() => { localStorage.clear(); window.location.reload() }}
style={{ padding: '8px 16px', background: '#1677ff', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
>
清除缓存重试
</button>
</div>
</div>
)
}
// ========== 主界面(认证成功)==========
return (
<div className="app">
<Tabs activeKey={activeTab} onChange={setActiveTab}>
<Tabs.Tab title="智能推荐" key="recommend">
<ScriptRecommendStream userInfo={userInfo} customerId={currentCustomerId} />
</Tabs.Tab>
<Tabs.Tab title="对话分析" key="analysis">
<ConversationAnalysis userInfo={userInfo} />
</Tabs.Tab>
</Tabs>
{/* 顶部员工信息栏 */}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 12px',
background: '#fff',
borderBottom: '1px solid #f0f0f0',
position: 'sticky',
top: 0,
zIndex: 10
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{userInfo?.avatar ? (
<img
src={userInfo.avatar}
alt="avatar"
style={{ width: 28, height: 28, borderRadius: '50%', objectFit: 'cover' }}
/>
) : (
<div style={{
width: 28, height: 28, borderRadius: '50%',
background: '#1677ff', color: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 12, fontWeight: 'bold'
}}>
{(userInfo?.userName || userInfo?.userId || '?').charAt(0)}
</div>
)}
<div>
<div style={{ fontSize: 14, fontWeight: 500, color: '#333', lineHeight: '20px' }}>
{userInfo?.userName || userInfo?.userId || '未知员工'}
</div>
<div style={{ fontSize: 11, color: '#999', lineHeight: '16px' }}>
{userInfo?.userId}
</div>
</div>
</div>
<div style={{ fontSize: 13, color: '#1677ff', fontWeight: 500 }}>
AI 坐席助手
</div>
</div>
<ScriptRecommendStream userInfo={userInfo} customerId={currentCustomerId} chatContext={chatContext} />
</div>
)
}

View File

@ -19,6 +19,7 @@ interface UtteranceItem {
interface Props {
userInfo?: any
customerId?: string
chatContext?: { chatType?: string; chatId?: string }
}
const STYLES: StyleConfig[] = [
@ -27,21 +28,24 @@ const STYLES: StyleConfig[] = [
{ id: 'urgent', name: '促单风格', desc: '制造紧迫感,促成决策', color: '#fa8c16', bgColor: '#fff7e6' }
]
export default function ScriptRecommendStream({ userInfo, customerId: propCustomerId }: Props) {
export default function ScriptRecommendStream({ userInfo, customerId: propCustomerId, chatContext }: Props) {
const [loading, setLoading] = useState(true)
const [generating, setGenerating] = useState(false)
const [customerMsg, setCustomerMsg] = useState('')
const [profileTags, setProfileTags] = useState<string[]>([])
const [utterances, setUtterances] = useState<UtteranceItem[]>([])
const [progress, setProgress] = useState(0)
const [error, setError] = useState('')
const sseRef = useRef<EventSource | null>(null)
const sessionId = localStorage.getItem('current_session_id') || `session_${Date.now()}`
const customerId = propCustomerId || localStorage.getItem('current_customer_id') || 'wx_001'
const staffId = userInfo?.userId || 'staff_001'
const customerId = propCustomerId || localStorage.getItem('current_customer_id') || ''
const staffId = userInfo?.userId || ''
const corpId = userInfo?.corpId || 'wwd483c2fba24ae30a'
const chatType = chatContext?.chatType
const chatId = chatContext?.chatId
const isGroupChat = chatType === 'group'
const isSingleChat = chatType === 'single'
const closeSse = useCallback(() => {
if (sseRef.current) {
sseRef.current.close()
@ -77,102 +81,68 @@ export default function ScriptRecommendStream({ userInfo, customerId: propCustom
const handleRegenerate = () => {
closeSse()
setUtterances([])
setProgress(0)
setError('')
startGeneration()
}
// 判断是否可以开始生成
const canStartGeneration = useCallback(() => {
if (isGroupChat && chatId) return true
if (isSingleChat && customerId && staffId) return true
// 兼容:没有 chatContext 时(非企微/测试环境)
if (!chatType && customerId && staffId) return true
return false
}, [isGroupChat, isSingleChat, chatId, customerId, staffId, chatType])
// 核心:开始流式生成
const startGeneration = useCallback(async () => {
if (!customerMsg.trim()) {
setError('暂无学员消息')
if (!canStartGeneration()) {
setError('未获取到聊天信息')
setLoading(false)
return
}
setGenerating(true)
setLoading(false)
setProgress(10)
try {
const [profileRes, contextRes, recommendRes] = await Promise.all([
fetch(`/api/v1/intent/profile/${encodeURIComponent(customerId)}?corpId=${corpId}`),
fetch(`/api/v1/conversations/${sessionId}/context`).catch(() => null),
fetch(`/api/v1/recommend/get?${new URLSearchParams({
sessionId, customerId, staffId, corpId,
intentCode: 'INT-COURSE-01',
stage: 'STAGE-03',
studentType: '零基础',
message: customerMsg
}).toString()}`, { method: 'POST' }).catch(() => null)
])
let customerProfileStr = ''
let tags: string[] = []
const profileData = await profileRes.json()
if (profileData.code === 0 && profileData.data) {
const p = profileData.data
if (p.currentOccupation) { tags.push(p.currentOccupation) }
if (p.studentType) { tags.push(p.studentType) }
if (p.skillLevel) { tags.push(p.skillLevel) }
if (p.intentLevel) { tags.push(p.intentLevel) }
if (p.concernFocus) { tags.push(p.concernFocus) }
if (p.decisionStage) { tags.push(p.decisionStage) }
customerProfileStr = [
p.currentOccupation ? `身份/职业:${p.currentOccupation}` : '',
p.studentType ? `学员类型:${p.studentType}` : '',
p.skillLevel ? `基础水平:${p.skillLevel}` : '',
p.intentLevel ? `意向度:${p.intentLevel}(${p.intentScore != null ? Math.round(p.intentScore) : 0}分)` : '',
p.concernFocus ? `关注方向:${p.concernFocus}` : '',
p.decisionStage ? `决策阶段:${p.decisionStage}` : ''
].filter(Boolean).join('\n')
}
setProfileTags(tags)
setProgress(30)
let conversationContextStr = ''
if (contextRes) {
const contextData = await contextRes.json()
if (contextData.code === 0 && contextData.data?.messages) {
const msgs = contextData.data.messages as Array<{ role: string; content: string }>
conversationContextStr = msgs.slice(-3).map(m => {
const roleName = m.role === 'student' ? '学员' : '顾问'
return `${roleName}:${m.content}`
}).join('\n')
// 单聊才获取画像标签
if (!isGroupChat && customerId) {
const profileRes = await fetch(`/api/v1/intent/profile/${encodeURIComponent(customerId)}?corpId=${corpId}`)
const profileData = await profileRes.json()
if (profileData.code === 0 && profileData.data) {
const p = profileData.data
const tags: string[] = []
if (p.currentOccupation) tags.push(p.currentOccupation)
if (p.studentType) tags.push(p.studentType)
if (p.skillLevel) tags.push(p.skillLevel)
if (p.intentLevel) tags.push(p.intentLevel)
if (p.concernFocus) tags.push(p.concernFocus)
if (p.decisionStage) tags.push(p.decisionStage)
setProfileTags(tags)
}
}
let referenceUtterancesStr = ''
if (recommendRes) {
const recData = await recommendRes.json()
if (recData.code === 0 && recData.data) {
const top3 = recData.data.slice(0, 3)
referenceUtterancesStr = top3.map((s: any, i: number) => `${i + 1}. "${s.contentText || s.content || s.title}"`).join('\n')
}
// 构建 SSE URL:群聊传 roomid,单聊传 fromUser+toUser
const params = new URLSearchParams()
if (isGroupChat && chatId) {
params.append('roomid', chatId)
} else if (customerId && staffId) {
params.append('fromUser', customerId)
params.append('toUser', staffId)
}
setProgress(50)
const params = new URLSearchParams({
message: customerMsg,
intent: 'INT-COURSE-01',
stage: 'STAGE-03',
stageLabel: '方案呈现',
intentLabel: '课程咨询'
})
if (customerProfileStr) params.append('customerProfile', customerProfileStr)
if (conversationContextStr) params.append('conversationContext', conversationContextStr)
if (referenceUtterancesStr) params.append('referenceUtterances', referenceUtterancesStr)
const url = `/api/v1/generation/utterance-stream?${params.toString()}`
console.log('SSE connect:', url)
const es = new EventSource(url)
sseRef.current = es
setUtterances(STYLES.map(s => ({ styleId: s.id, text: '', isComplete: false, isTyping: false })))
// ===== 关键修复:用 addEventListener 接收命名事件 =====
es.addEventListener('start', (event: MessageEvent) => {
try {
const data = JSON.parse(event.data)
if (data.type === 'start') setProgress(60)
if (data.type === 'start') { /* 可处理 styles 信息 */ }
} catch (e) { console.error('SSE start error:', e) }
})
@ -214,7 +184,6 @@ export default function ScriptRecommendStream({ userInfo, customerId: propCustom
const data = JSON.parse(event.data)
if (data.type === 'done') {
setGenerating(false)
setProgress(100)
es.close()
}
} catch (e) { console.error('SSE done error:', e) }
@ -236,48 +205,49 @@ export default function ScriptRecommendStream({ userInfo, customerId: propCustom
setError('网络错误,请重试')
setGenerating(false)
}
}, [customerMsg, customerId, corpId, sessionId, staffId])
}, [canStartGeneration, isGroupChat, customerId, staffId, chatId, corpId])
useEffect(() => {
const init = async () => {
setLoading(true)
try {
const res = await fetch(`/api/v1/conversations/last-message?customerId=${customerId}&corpId=${corpId}`)
const data = await res.json()
if (data.code === 0 && data.data?.content) {
setCustomerMsg(data.data.content)
} else {
setLoading(false)
setError('暂无学员最新消息')
// 单聊获取最后一条消息用于展示(限定当前销售与学员的对话,避免串消息)
if (!isGroupChat && customerId && staffId) {
const res = await fetch(`/api/v1/conversations/last-message?customerId=${customerId}&staffId=${staffId}&corpId=${corpId}`)
const data = await res.json()
if (data.code === 0 && data.data?.content) {
setCustomerMsg(data.data.content)
}
}
} catch (e) {
console.error('初始化失败:', e)
console.error('获取学员消息失败:', e)
}
if (canStartGeneration()) {
startGeneration()
} else {
setLoading(false)
setError('获取学员消息失败')
setError('未获取到聊天上下文')
}
}
init()
return () => { closeSse() }
}, [customerId, corpId, closeSse])
useEffect(() => {
if (customerMsg.trim() && !generating && utterances.length === 0) {
startGeneration()
}
}, [customerMsg, generating, utterances.length, startGeneration])
}, [customerId, staffId, corpId, chatContext, isGroupChat, closeSse, startGeneration, canStartGeneration])
if (loading && !generating && utterances.length === 0) {
return (
<div style={{ padding: 60, textAlign: 'center' }}>
<SpinLoading style={{ '--size': '32px' } as any} />
<div style={{ marginTop: 16, color: '#999', fontSize: 14 }}>正在分析学员消息...</div>
<div style={{ marginTop: 16, color: '#999', fontSize: 14 }}>
{isGroupChat ? '正在分析群聊消息...' : '正在分析学员消息...'}
</div>
</div>
)
}
return (
<div style={{ padding: 12, background: '#f5f5f5', minHeight: '100vh' }}>
{/* 学员消息 + 标签 */}
{/* 学员/群聊消息 + 标签 */}
<div style={{
background: '#fff',
borderRadius: 12,
@ -286,7 +256,9 @@ export default function ScriptRecommendStream({ userInfo, customerId: propCustom
boxShadow: '0 1px 4px rgba(0,0,0,0.06)'
}}>
<div style={{ fontSize: 13, color: '#666', marginBottom: 8 }}>
💬 <span style={{ color: '#999' }}>学员消息:</span>
💬 <span style={{ color: '#999' }}>
{isGroupChat ? '群聊消息' : '学员消息'}:
</span>
<span style={{ color: '#333', fontWeight: 500 }}>{customerMsg || '暂无'}</span>
</div>
{profileTags.length > 0 && (
@ -298,8 +270,8 @@ export default function ScriptRecommendStream({ userInfo, customerId: propCustom
)}
</div>
{/* 生成进度 */}
{generating && progress < 100 && (
{/* 生成中状态 */}
{generating && utterances.every(u => !u.isComplete) && (
<div style={{
background: '#fff',
borderRadius: 12,
@ -310,14 +282,7 @@ export default function ScriptRecommendStream({ userInfo, customerId: propCustom
gap: 12
}}>
<SpinLoading style={{ '--size': '20px' } as any} />
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, color: '#666' }}>
{progress < 60 ? '正在分析学员画像和对话历史...' : 'AI 正在生成推荐话术...'}
</div>
<div style={{ height: 4, background: '#e8e8e8', borderRadius: 2, marginTop: 6, overflow: 'hidden' }}>
<div style={{ width: `${progress}%`, height: '100%', background: '#1677ff', borderRadius: 2, transition: 'width 0.3s' }} />
</div>
</div>
<div style={{ fontSize: 13, color: '#666' }}>AI 正在生成推荐话术...</div>
</div>
)}