feat: 新增多风格话术流式生成功能,优化对话查询逻辑
1. 新增多风格话术生成SSE接口,支持亲和/专业/促单风格话术流式输出 2. 重构对话查询逻辑,增加staffId参数避免消息串扰,新增自动推断员工身份接口 3. 优化前端侧边栏,适配企微聊天上下文,新增员工信息展示和登录流程 4. 补充相关Mapper和Service代码,完善归档消息查询能力 5. 更新部署文档,优化前端缓存处理
This commit is contained in:
parent
5e33db1ad3
commit
5606a56777
40
AGENTS.md
40
AGENTS.md
@ -20,17 +20,36 @@
|
|||||||
# 1. 本地构建
|
# 1. 本地构建
|
||||||
cd frontend/sidebar && npm run build
|
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/
|
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'
|
ssh -o StrictHostKeyChecking=no -i root.pem root@8.133.162.25 << 'EOF'
|
||||||
cd /www/wwwroot/deploy-package/frontend/sidebar
|
cd /www/wwwroot/deploy-package/frontend/sidebar/dist
|
||||||
docker build -t deploy-package-sidebar .
|
# 更新 index.html 中的 JS/CSS 引用,带上版本戳防缓存
|
||||||
docker stop ai-assistant-sidebar
|
JS_FILE=$(ls assets/index-*.js | head -1)
|
||||||
docker rm ai-assistant-sidebar
|
CSS_FILE=$(ls assets/index-*.css | head -1)
|
||||||
cd /www/wwwroot/deploy-package
|
cat > index.html << HTML
|
||||||
docker-compose up -d sidebar
|
<!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
|
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/
|
scp -o StrictHostKeyChecking=no -i root.pem target/*.jar root@8.133.162.25:/www/wwwroot/deploy-package/backend/auth-service/
|
||||||
|
|
||||||
# 3. SSH 登录服务器,重启对应容器
|
# 3. SSH 登录服务器,重启对应容器
|
||||||
|
# ⚠️ 注意:Dockerfile 中 COPY 的是 app.jar,必须先把新 jar 复制为 app.jar
|
||||||
ssh -o StrictHostKeyChecking=no -i root.pem root@8.133.162.25 << 'EOF'
|
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
|
cd /www/wwwroot/deploy-package
|
||||||
docker-compose up -d --build auth-service
|
docker-compose up -d --build --no-cache auth-service
|
||||||
EOF
|
EOF
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -65,15 +65,17 @@ public class ConversationController {
|
|||||||
@GetMapping("/last-message")
|
@GetMapping("/last-message")
|
||||||
public Result<Map<String, Object>> getLastMessage(
|
public Result<Map<String, Object>> getLastMessage(
|
||||||
@NotBlank(message = "customerId不能为空") @RequestParam("customerId") String customerId,
|
@NotBlank(message = "customerId不能为空") @RequestParam("customerId") String customerId,
|
||||||
|
@NotBlank(message = "staffId不能为空") @RequestParam("staffId") String staffId,
|
||||||
@NotBlank(message = "corpId不能为空") @RequestParam("corpId") String corpId) {
|
@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<>();
|
Map<String, Object> result = new HashMap<>();
|
||||||
if (message != null) {
|
if (message != null) {
|
||||||
result.put("content", message.getContent());
|
result.put("content", message.getContent());
|
||||||
result.put("msgTime", message.getMsgtime());
|
result.put("msgTime", message.getMsgtime());
|
||||||
result.put("msgType", message.getMsgtype());
|
result.put("msgType", message.getMsgtype());
|
||||||
result.put("fromUser", message.getFromUser());
|
result.put("fromUser", message.getFromUser());
|
||||||
|
result.put("fromRole", message.getFromRole());
|
||||||
}
|
}
|
||||||
return Result.success(result);
|
return Result.success(result);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,11 +9,18 @@ import org.apache.ibatis.annotations.Select;
|
|||||||
@Mapper
|
@Mapper
|
||||||
public interface ArchiveMessageMapper extends BaseMapper<ArchiveMessage> {
|
public interface ArchiveMessageMapper extends BaseMapper<ArchiveMessage> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定学员在指定对话中的最后一条消息
|
||||||
|
* 通过 staffId 限定只查当前销售与学员的对话,避免学员同时和多个销售聊天时串消息
|
||||||
|
*/
|
||||||
@Select("SELECT * FROM archive_messages " +
|
@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 corp_id = #{corpId} " +
|
||||||
|
"AND from_role = 'EXTERNAL' " +
|
||||||
"AND content IS NOT NULL AND content != '' " +
|
"AND content IS NOT NULL AND content != '' " +
|
||||||
"ORDER BY msgtime DESC LIMIT 1")
|
"ORDER BY msgtime DESC LIMIT 1")
|
||||||
ArchiveMessage selectLastMessage(@Param("userId") String userId,
|
ArchiveMessage selectLastMessage(@Param("userId") String userId,
|
||||||
|
@Param("staffId") String staffId,
|
||||||
@Param("corpId") String corpId);
|
@Param("corpId") String corpId);
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -9,13 +9,19 @@ import com.artedu.generation.service.PromptEngine;
|
|||||||
import com.artedu.generation.service.QianwenClient;
|
import com.artedu.generation.service.QianwenClient;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.validation.annotation.Validated;
|
import org.springframework.validation.annotation.Validated;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
|
||||||
import javax.validation.constraints.NotBlank;
|
import javax.validation.constraints.NotBlank;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Validated
|
@Validated
|
||||||
@ -35,6 +41,73 @@ public class GenerationController {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private ArchiveMessageMapper archiveMessageMapper;
|
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")
|
@PostMapping("/utterance")
|
||||||
public Result<Map<String, Object>> generateUtterance(
|
public Result<Map<String, Object>> generateUtterance(
|
||||||
@RequestParam(value = "fromUser", required = false) String fromUser,
|
@RequestParam(value = "fromUser", required = false) String fromUser,
|
||||||
@ -62,18 +135,25 @@ public class GenerationController {
|
|||||||
List<ArchiveMessage> messages;
|
List<ArchiveMessage> messages;
|
||||||
boolean isGroupChat = roomid != null && !roomid.isEmpty();
|
boolean isGroupChat = roomid != null && !roomid.isEmpty();
|
||||||
|
|
||||||
long startTime = getTodayStartTime();
|
// 查最近 7 天,避免今天没聊天就找不到记录
|
||||||
|
long startTime = get7DaysAgoStartTime();
|
||||||
long endTime = getTodayEndTime();
|
long endTime = getTodayEndTime();
|
||||||
|
|
||||||
if (isGroupChat) {
|
if (isGroupChat) {
|
||||||
messages = archiveMessageMapper.selectGroupChatMessages(roomid, startTime, endTime);
|
messages = archiveMessageMapper.selectGroupChatMessages(roomid, startTime, endTime);
|
||||||
} else {
|
} else {
|
||||||
if (fromUser == null || fromUser.isEmpty() || toUser == null || toUser.isEmpty()) {
|
if (fromUser == null || fromUser.isEmpty()) {
|
||||||
log.warn("单聊模式需要fromUser和toUser参数");
|
log.warn("单聊模式需要fromUser参数");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
toUser = "[\""+toUser+"\"]";
|
// toUser 为空或无效时,用兜底查询(根据 fromUser 查所有相关记录)
|
||||||
messages = archiveMessageMapper.selectSingleChatMessages(fromUser, toUser, startTime, endTime);
|
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()) {
|
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;
|
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")
|
@PostMapping("/summary")
|
||||||
public Result<Map<String, Object>> generateSummary(
|
public Result<Map<String, Object>> generateSummary(
|
||||||
@NotBlank(message = "context不能为空") @RequestParam("context") String context) {
|
@NotBlank(message = "context不能为空") @RequestParam("context") String context) {
|
||||||
|
|||||||
@ -36,4 +36,26 @@ public interface ArchiveMessageMapper extends BaseMapper<ArchiveMessage> {
|
|||||||
List<ArchiveMessage> selectGroupChatMessages(@Param("roomid") String roomid,
|
List<ArchiveMessage> selectGroupChatMessages(@Param("roomid") String roomid,
|
||||||
@Param("startTime") Long startTime,
|
@Param("startTime") Long startTime,
|
||||||
@Param("endTime") Long endTime);
|
@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);
|
||||||
}
|
}
|
||||||
@ -129,6 +129,30 @@ public class PromptEngine {
|
|||||||
return sb.toString();
|
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) {
|
public String buildSummaryPrompt(String context) {
|
||||||
return "请对以下对话进行简要总结(50字以内),提炼学员核心需求和当前阶段:\n\n" + context;
|
return "请对以下对话进行简要总结(50字以内),提炼学员核心需求和当前阶段:\n\n" + context;
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,7 +1,9 @@
|
|||||||
com\artedu\generation\service\QianwenClient.class
|
com\artedu\generation\service\QianwenClient.class
|
||||||
com\artedu\generation\controller\GenerateRequest.class
|
com\artedu\generation\controller\GenerateRequest.class
|
||||||
|
com\artedu\generation\entity\ArchiveMessage.class
|
||||||
com\artedu\generation\service\LlmModelService.class
|
com\artedu\generation\service\LlmModelService.class
|
||||||
com\artedu\generation\client\DoubaoClient.class
|
com\artedu\generation\client\DoubaoClient.class
|
||||||
|
com\artedu\generation\mapper\ArchiveMessageMapper.class
|
||||||
com\artedu\generation\client\LLMClientFactory.class
|
com\artedu\generation\client\LLMClientFactory.class
|
||||||
com\artedu\generation\controller\LlmModelController.class
|
com\artedu\generation\controller\LlmModelController.class
|
||||||
com\artedu\generation\mapper\LlmModelMapper.class
|
com\artedu\generation\mapper\LlmModelMapper.class
|
||||||
|
|||||||
@ -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\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\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\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\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\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\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\LlmModelService.java
|
||||||
D:\www\agent_9art\backend\generation-service\src\main\java\com\artedu\generation\service\PromptEngine.java
|
D:\www\agent_9art\backend\generation-service\src\main\java\com\artedu\generation\service\PromptEngine.java
|
||||||
|
|||||||
@ -1,86 +1,129 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import { Tabs, Toast } from 'antd-mobile'
|
|
||||||
import ScriptRecommendStream from './components/ScriptRecommendStream'
|
import ScriptRecommendStream from './components/ScriptRecommendStream'
|
||||||
import ConversationAnalysis from './components/ConversationAnalysis'
|
|
||||||
import './styles.css'
|
import './styles.css'
|
||||||
|
|
||||||
|
const CORPID = 'wwd483c2fba24ae30a'
|
||||||
|
const AGENTID = '1000021'
|
||||||
|
const REDIRECT_URI = encodeURIComponent('https://ai.9artedu.com/sidebar/')
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [activeTab, setActiveTab] = useState('recommend')
|
const [authState, setAuthState] = useState<'loading' | 'authed' | 'needLogin' | 'error'>('loading')
|
||||||
const [authState, setAuthState] = useState<'loading' | 'authed' | 'error'>('loading')
|
|
||||||
const [userInfo, setUserInfo] = useState<any>(null)
|
const [userInfo, setUserInfo] = useState<any>(null)
|
||||||
const [currentCustomerId, setCurrentCustomerId] = useState<string>('')
|
const [currentCustomerId, setCurrentCustomerId] = useState<string>('')
|
||||||
|
const [chatContext, setChatContext] = useState<{ chatType?: string; chatId?: string }>({})
|
||||||
|
|
||||||
useEffect(() => {
|
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 {
|
try {
|
||||||
const urlParams = new URLSearchParams(window.location.search)
|
const urlParams = new URLSearchParams(window.location.search)
|
||||||
const code = urlParams.get('code')
|
const code = urlParams.get('code')
|
||||||
|
|
||||||
|
// 1. URL 中有 code → 移除并登录
|
||||||
if (code) {
|
if (code) {
|
||||||
const res = await fetch(`/api/v1/auth/login?code=${encodeURIComponent(code)}`, { method: 'POST' })
|
const cleanUrl = window.location.pathname + window.location.hash
|
||||||
const data = await res.json()
|
window.history.replaceState({}, document.title, cleanUrl)
|
||||||
if (data.code === 0 && data.data) {
|
|
||||||
localStorage.setItem('staff_token', data.data.token)
|
const ok = await tryCodeLogin(code)
|
||||||
setUserInfo(data.data)
|
if (ok) return
|
||||||
setAuthState('authed')
|
// login 失败 → 可能是 code 过期/已被使用,显示登录按钮让用户手动重试
|
||||||
} else {
|
setAuthState('needLogin')
|
||||||
setAuthState('error')
|
return
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
} catch (e) {
|
||||||
|
console.error('initAuth error:', e)
|
||||||
setAuthState('error')
|
setAuthState('error')
|
||||||
Toast.show({ content: '初始化失败', icon: 'fail' })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const redirectToOAuth = () => {
|
// 用 code 调后端 login 接口
|
||||||
setAuthState('authed')
|
const tryCodeLogin = async (code: string): Promise<boolean> => {
|
||||||
setUserInfo({
|
try {
|
||||||
userId: 'staff_001',
|
const res = await noCacheFetch(`/api/v1/auth/login?code=${encodeURIComponent(code)}`, { method: 'POST' })
|
||||||
userName: '测试顾问',
|
const data = await res.json()
|
||||||
corpId: 'wwd483c2fba24ae30a',
|
if (data.code === 0 && data.data) {
|
||||||
role: 'SEAT'
|
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 initWxConfig = async () => {
|
||||||
const wx = (window as any).wx
|
const wx = (window as any).wx
|
||||||
if (!wx || !wx.config) {
|
if (!wx || !wx.config) {
|
||||||
console.log('非企微环境,跳过JS-SDK初始化')
|
console.log('非企微环境,跳过 JS-SDK')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const currentUrl = window.location.href.split('#')[0]
|
||||||
const currentUrl = window.location.href.split('#')[0]
|
|
||||||
|
|
||||||
// 1. 企业级 wx.config
|
try {
|
||||||
const corpRes = await fetch(`/api/v1/auth/signature?url=${encodeURIComponent(currentUrl)}`)
|
const corpRes = await noCacheFetch(`/api/v1/auth/signature?url=${encodeURIComponent(currentUrl)}`)
|
||||||
const corpData = await corpRes.json()
|
const corpData = await corpRes.json()
|
||||||
if (corpData.code !== 0 || !corpData.data) {
|
if (corpData.code !== 0 || !corpData.data) return
|
||||||
console.error('获取JS-SDK企业签名失败')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const cfg = corpData.data
|
const cfg = corpData.data
|
||||||
wx.config({
|
wx.config({
|
||||||
@ -94,13 +137,9 @@ function App() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
wx.ready(async () => {
|
wx.ready(async () => {
|
||||||
console.log('JS-SDK wx.config 成功')
|
|
||||||
|
|
||||||
// 2. 尝试应用级 agentConfig(获取客户联系权限)
|
|
||||||
try {
|
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()
|
const agentData = await agentRes.json()
|
||||||
|
|
||||||
if (agentData.code === 0 && agentData.data) {
|
if (agentData.code === 0 && agentData.data) {
|
||||||
const ac = agentData.data
|
const ac = agentData.data
|
||||||
wx.agentConfig({
|
wx.agentConfig({
|
||||||
@ -111,78 +150,140 @@ function App() {
|
|||||||
signature: ac.signature,
|
signature: ac.signature,
|
||||||
jsApiList: ['sendChatMessage', 'getContext', 'getCurExternalContact'],
|
jsApiList: ['sendChatMessage', 'getContext', 'getCurExternalContact'],
|
||||||
success: () => {
|
success: () => {
|
||||||
console.log('✅ agentConfig 成功,已获取客户联系权限')
|
wx.invoke('getContext', {}, (ctx: any) => {
|
||||||
// agentConfig 成功后,获取当前客户ID
|
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) => {
|
wx.invoke('getCurExternalContact', {}, (res: any) => {
|
||||||
if (res.err_msg === 'getCurExternalContact:ok' && res.userId) {
|
if (res.err_msg === 'getCurExternalContact:ok' && res.userId) {
|
||||||
console.log('✅ 获取到客户ID:', res.userId)
|
|
||||||
localStorage.setItem('current_customer_id', res.userId)
|
localStorage.setItem('current_customer_id', res.userId)
|
||||||
setCurrentCustomerId(res.userId)
|
setCurrentCustomerId(res.userId)
|
||||||
} else {
|
|
||||||
console.warn('getCurExternalContact 失败:', res.err_msg)
|
|
||||||
fallbackToCachedId()
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
fail: (err: any) => {
|
fail: (err: any) => console.warn('agentConfig fail:', err)
|
||||||
console.warn('⚠️ agentConfig 失败(无客户联系权限):', err.err_msg || err)
|
|
||||||
fallbackToCachedId()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
} else {
|
|
||||||
console.warn('⚠️ 获取应用签名失败:', agentData.message)
|
|
||||||
fallbackToCachedId()
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('⚠️ agentConfig 流程异常:', e)
|
console.error('agentConfig error:', e)
|
||||||
fallbackToCachedId()
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
wx.error((err: any) => {
|
wx.error((err: any) => console.error('wx.config error:', err))
|
||||||
console.error('JS-SDK wx.config 失败:', err)
|
|
||||||
fallbackToCachedId()
|
|
||||||
})
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('初始化JS-SDK异常:', e)
|
console.error('initWxConfig error:', e)
|
||||||
fallbackToCachedId()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallbackToCachedId = () => {
|
|
||||||
const cachedId = localStorage.getItem('current_customer_id')
|
|
||||||
if (cachedId && cachedId !== '${userId}') {
|
|
||||||
setCurrentCustomerId(cachedId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (authState === 'authed') {
|
|
||||||
initWxConfig()
|
|
||||||
}
|
|
||||||
}, [authState])
|
|
||||||
|
|
||||||
if (authState === 'loading') {
|
if (authState === 'loading') {
|
||||||
return (
|
return (
|
||||||
<div className="app" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh' }}>
|
<div className="app" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh' }}>
|
||||||
<div style={{ textAlign: 'center', color: '#999' }}>
|
<div style={{ textAlign: 'center', color: '#999' }}>
|
||||||
<div style={{ fontSize: 16, marginBottom: 8 }}>加载中...</div>
|
<div style={{ fontSize: 16, marginBottom: 8 }}>加载中...</div>
|
||||||
<div style={{ fontSize: 13 }}>正在连接企微</div>
|
<div style={{ fontSize: 13 }}>正在识别员工身份...</div>
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<Tabs activeKey={activeTab} onChange={setActiveTab}>
|
{/* 顶部员工信息栏 */}
|
||||||
<Tabs.Tab title="智能推荐" key="recommend">
|
<div style={{
|
||||||
<ScriptRecommendStream userInfo={userInfo} customerId={currentCustomerId} />
|
display: 'flex',
|
||||||
</Tabs.Tab>
|
alignItems: 'center',
|
||||||
<Tabs.Tab title="对话分析" key="analysis">
|
justifyContent: 'space-between',
|
||||||
<ConversationAnalysis userInfo={userInfo} />
|
padding: '8px 12px',
|
||||||
</Tabs.Tab>
|
background: '#fff',
|
||||||
</Tabs>
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,7 @@ interface UtteranceItem {
|
|||||||
interface Props {
|
interface Props {
|
||||||
userInfo?: any
|
userInfo?: any
|
||||||
customerId?: string
|
customerId?: string
|
||||||
|
chatContext?: { chatType?: string; chatId?: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
const STYLES: StyleConfig[] = [
|
const STYLES: StyleConfig[] = [
|
||||||
@ -27,21 +28,24 @@ const STYLES: StyleConfig[] = [
|
|||||||
{ id: 'urgent', name: '促单风格', desc: '制造紧迫感,促成决策', color: '#fa8c16', bgColor: '#fff7e6' }
|
{ 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 [loading, setLoading] = useState(true)
|
||||||
const [generating, setGenerating] = useState(false)
|
const [generating, setGenerating] = useState(false)
|
||||||
const [customerMsg, setCustomerMsg] = useState('')
|
const [customerMsg, setCustomerMsg] = useState('')
|
||||||
const [profileTags, setProfileTags] = useState<string[]>([])
|
const [profileTags, setProfileTags] = useState<string[]>([])
|
||||||
const [utterances, setUtterances] = useState<UtteranceItem[]>([])
|
const [utterances, setUtterances] = useState<UtteranceItem[]>([])
|
||||||
const [progress, setProgress] = useState(0)
|
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const sseRef = useRef<EventSource | null>(null)
|
const sseRef = useRef<EventSource | null>(null)
|
||||||
|
|
||||||
const sessionId = localStorage.getItem('current_session_id') || `session_${Date.now()}`
|
const customerId = propCustomerId || localStorage.getItem('current_customer_id') || ''
|
||||||
const customerId = propCustomerId || localStorage.getItem('current_customer_id') || 'wx_001'
|
const staffId = userInfo?.userId || ''
|
||||||
const staffId = userInfo?.userId || 'staff_001'
|
|
||||||
const corpId = userInfo?.corpId || 'wwd483c2fba24ae30a'
|
const corpId = userInfo?.corpId || 'wwd483c2fba24ae30a'
|
||||||
|
|
||||||
|
const chatType = chatContext?.chatType
|
||||||
|
const chatId = chatContext?.chatId
|
||||||
|
const isGroupChat = chatType === 'group'
|
||||||
|
const isSingleChat = chatType === 'single'
|
||||||
|
|
||||||
const closeSse = useCallback(() => {
|
const closeSse = useCallback(() => {
|
||||||
if (sseRef.current) {
|
if (sseRef.current) {
|
||||||
sseRef.current.close()
|
sseRef.current.close()
|
||||||
@ -77,102 +81,68 @@ export default function ScriptRecommendStream({ userInfo, customerId: propCustom
|
|||||||
const handleRegenerate = () => {
|
const handleRegenerate = () => {
|
||||||
closeSse()
|
closeSse()
|
||||||
setUtterances([])
|
setUtterances([])
|
||||||
setProgress(0)
|
|
||||||
setError('')
|
setError('')
|
||||||
startGeneration()
|
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 () => {
|
const startGeneration = useCallback(async () => {
|
||||||
if (!customerMsg.trim()) {
|
if (!canStartGeneration()) {
|
||||||
setError('暂无学员消息')
|
setError('未获取到聊天信息')
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setGenerating(true)
|
setGenerating(true)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
setProgress(10)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [profileRes, contextRes, recommendRes] = await Promise.all([
|
// 单聊才获取画像标签
|
||||||
fetch(`/api/v1/intent/profile/${encodeURIComponent(customerId)}?corpId=${corpId}`),
|
if (!isGroupChat && customerId) {
|
||||||
fetch(`/api/v1/conversations/${sessionId}/context`).catch(() => null),
|
const profileRes = await fetch(`/api/v1/intent/profile/${encodeURIComponent(customerId)}?corpId=${corpId}`)
|
||||||
fetch(`/api/v1/recommend/get?${new URLSearchParams({
|
const profileData = await profileRes.json()
|
||||||
sessionId, customerId, staffId, corpId,
|
if (profileData.code === 0 && profileData.data) {
|
||||||
intentCode: 'INT-COURSE-01',
|
const p = profileData.data
|
||||||
stage: 'STAGE-03',
|
const tags: string[] = []
|
||||||
studentType: '零基础',
|
if (p.currentOccupation) tags.push(p.currentOccupation)
|
||||||
message: customerMsg
|
if (p.studentType) tags.push(p.studentType)
|
||||||
}).toString()}`, { method: 'POST' }).catch(() => null)
|
if (p.skillLevel) tags.push(p.skillLevel)
|
||||||
])
|
if (p.intentLevel) tags.push(p.intentLevel)
|
||||||
|
if (p.concernFocus) tags.push(p.concernFocus)
|
||||||
let customerProfileStr = ''
|
if (p.decisionStage) tags.push(p.decisionStage)
|
||||||
let tags: string[] = []
|
setProfileTags(tags)
|
||||||
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')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let referenceUtterancesStr = ''
|
// 构建 SSE URL:群聊传 roomid,单聊传 fromUser+toUser
|
||||||
if (recommendRes) {
|
const params = new URLSearchParams()
|
||||||
const recData = await recommendRes.json()
|
if (isGroupChat && chatId) {
|
||||||
if (recData.code === 0 && recData.data) {
|
params.append('roomid', chatId)
|
||||||
const top3 = recData.data.slice(0, 3)
|
} else if (customerId && staffId) {
|
||||||
referenceUtterancesStr = top3.map((s: any, i: number) => `${i + 1}. "${s.contentText || s.content || s.title}"`).join('\n')
|
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()}`
|
const url = `/api/v1/generation/utterance-stream?${params.toString()}`
|
||||||
|
console.log('SSE connect:', url)
|
||||||
const es = new EventSource(url)
|
const es = new EventSource(url)
|
||||||
sseRef.current = es
|
sseRef.current = es
|
||||||
|
|
||||||
setUtterances(STYLES.map(s => ({ styleId: s.id, text: '', isComplete: false, isTyping: false })))
|
setUtterances(STYLES.map(s => ({ styleId: s.id, text: '', isComplete: false, isTyping: false })))
|
||||||
|
|
||||||
// ===== 关键修复:用 addEventListener 接收命名事件 =====
|
|
||||||
es.addEventListener('start', (event: MessageEvent) => {
|
es.addEventListener('start', (event: MessageEvent) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data)
|
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) }
|
} 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)
|
const data = JSON.parse(event.data)
|
||||||
if (data.type === 'done') {
|
if (data.type === 'done') {
|
||||||
setGenerating(false)
|
setGenerating(false)
|
||||||
setProgress(100)
|
|
||||||
es.close()
|
es.close()
|
||||||
}
|
}
|
||||||
} catch (e) { console.error('SSE done error:', e) }
|
} catch (e) { console.error('SSE done error:', e) }
|
||||||
@ -236,48 +205,49 @@ export default function ScriptRecommendStream({ userInfo, customerId: propCustom
|
|||||||
setError('网络错误,请重试')
|
setError('网络错误,请重试')
|
||||||
setGenerating(false)
|
setGenerating(false)
|
||||||
}
|
}
|
||||||
}, [customerMsg, customerId, corpId, sessionId, staffId])
|
}, [canStartGeneration, isGroupChat, customerId, staffId, chatId, corpId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/v1/conversations/last-message?customerId=${customerId}&corpId=${corpId}`)
|
// 单聊获取最后一条消息用于展示(限定当前销售与学员的对话,避免串消息)
|
||||||
const data = await res.json()
|
if (!isGroupChat && customerId && staffId) {
|
||||||
if (data.code === 0 && data.data?.content) {
|
const res = await fetch(`/api/v1/conversations/last-message?customerId=${customerId}&staffId=${staffId}&corpId=${corpId}`)
|
||||||
setCustomerMsg(data.data.content)
|
const data = await res.json()
|
||||||
} else {
|
if (data.code === 0 && data.data?.content) {
|
||||||
setLoading(false)
|
setCustomerMsg(data.data.content)
|
||||||
setError('暂无学员最新消息')
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('初始化失败:', e)
|
console.error('获取学员消息失败:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canStartGeneration()) {
|
||||||
|
startGeneration()
|
||||||
|
} else {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
setError('获取学员消息失败')
|
setError('未获取到聊天上下文')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
init()
|
init()
|
||||||
return () => { closeSse() }
|
return () => { closeSse() }
|
||||||
}, [customerId, corpId, closeSse])
|
}, [customerId, staffId, corpId, chatContext, isGroupChat, closeSse, startGeneration, canStartGeneration])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (customerMsg.trim() && !generating && utterances.length === 0) {
|
|
||||||
startGeneration()
|
|
||||||
}
|
|
||||||
}, [customerMsg, generating, utterances.length, startGeneration])
|
|
||||||
|
|
||||||
if (loading && !generating && utterances.length === 0) {
|
if (loading && !generating && utterances.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 60, textAlign: 'center' }}>
|
<div style={{ padding: 60, textAlign: 'center' }}>
|
||||||
<SpinLoading style={{ '--size': '32px' } as any} />
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 12, background: '#f5f5f5', minHeight: '100vh' }}>
|
<div style={{ padding: 12, background: '#f5f5f5', minHeight: '100vh' }}>
|
||||||
{/* 学员消息 + 标签 */}
|
{/* 学员/群聊消息 + 标签 */}
|
||||||
<div style={{
|
<div style={{
|
||||||
background: '#fff',
|
background: '#fff',
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
@ -286,7 +256,9 @@ export default function ScriptRecommendStream({ userInfo, customerId: propCustom
|
|||||||
boxShadow: '0 1px 4px rgba(0,0,0,0.06)'
|
boxShadow: '0 1px 4px rgba(0,0,0,0.06)'
|
||||||
}}>
|
}}>
|
||||||
<div style={{ fontSize: 13, color: '#666', marginBottom: 8 }}>
|
<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>
|
<span style={{ color: '#333', fontWeight: 500 }}>{customerMsg || '暂无'}</span>
|
||||||
</div>
|
</div>
|
||||||
{profileTags.length > 0 && (
|
{profileTags.length > 0 && (
|
||||||
@ -298,8 +270,8 @@ export default function ScriptRecommendStream({ userInfo, customerId: propCustom
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 生成进度 */}
|
{/* 生成中状态 */}
|
||||||
{generating && progress < 100 && (
|
{generating && utterances.every(u => !u.isComplete) && (
|
||||||
<div style={{
|
<div style={{
|
||||||
background: '#fff',
|
background: '#fff',
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
@ -310,14 +282,7 @@ export default function ScriptRecommendStream({ userInfo, customerId: propCustom
|
|||||||
gap: 12
|
gap: 12
|
||||||
}}>
|
}}>
|
||||||
<SpinLoading style={{ '--size': '20px' } as any} />
|
<SpinLoading style={{ '--size': '20px' } as any} />
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ fontSize: 13, color: '#666' }}>AI 正在生成推荐话术...</div>
|
||||||
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user