feat: add multi-style utterance stream generation feature
1. 后端新增多风格话术流式生成SSE接口,支持生成亲和/专业/促单三种风格回复 2. 前端替换旧话术推荐组件为流式推荐组件,支持打字机效果与一键发送/复制 3. 新增`buildMultiStyleUserPrompt`方法构建多风格生成提示词 4. 清理前端调试模式的手动客户ID输入功能与旧导航栏
This commit is contained in:
parent
bd9920ca24
commit
efed845b6f
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.
@ -7,13 +7,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
|
||||||
@ -30,6 +36,14 @@ public class GenerationController {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private LlmModelService llmModelService;
|
private LlmModelService llmModelService;
|
||||||
|
|
||||||
|
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", "制造紧迫感,促成决策")
|
||||||
|
);
|
||||||
|
|
||||||
@PostMapping("/utterance")
|
@PostMapping("/utterance")
|
||||||
public Result<Map<String, Object>> generateUtterance(
|
public Result<Map<String, Object>> generateUtterance(
|
||||||
@NotBlank(message = "message不能为空") @RequestParam("message") String message,
|
@NotBlank(message = "message不能为空") @RequestParam("message") String message,
|
||||||
@ -71,6 +85,131 @@ public class GenerationController {
|
|||||||
return Result.success(result);
|
return Result.success(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 流式生成多风格话术(SSE)
|
||||||
|
* 页面打开即自动推荐 3 条不同风格话术
|
||||||
|
*/
|
||||||
|
@GetMapping(value = "/utterance-stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||||
|
public SseEmitter streamUtterance(
|
||||||
|
@NotBlank(message = "message不能为空") @RequestParam("message") String message,
|
||||||
|
@RequestParam(value = "context", required = false) String context,
|
||||||
|
@RequestParam(value = "customerProfile", required = false) String customerProfile,
|
||||||
|
@RequestParam(value = "conversationContext", required = false) String conversationContext,
|
||||||
|
@RequestParam(value = "referenceUtterances", required = false) String referenceUtterances,
|
||||||
|
@NotBlank(message = "intent不能为空") @RequestParam("intent") String intent,
|
||||||
|
@NotBlank(message = "stage不能为空") @RequestParam("stage") String stage,
|
||||||
|
@RequestParam(value = "stageLabel", required = false) String stageLabel,
|
||||||
|
@RequestParam(value = "intentLabel", required = false) String intentLabel) {
|
||||||
|
|
||||||
|
SseEmitter emitter = new SseEmitter(120000L); // 2分钟超时
|
||||||
|
|
||||||
|
sseExecutor.execute(() -> {
|
||||||
|
try {
|
||||||
|
// 1. 发送开始事件,告知前端3种风格
|
||||||
|
Map<String, Object> startEvent = new HashMap<>();
|
||||||
|
startEvent.put("type", "start");
|
||||||
|
startEvent.put("styles", STYLES);
|
||||||
|
emitter.send(SseEmitter.event().name("start").data(startEvent));
|
||||||
|
|
||||||
|
// 2. 构建 system + user prompt
|
||||||
|
String systemPrompt = promptEngine.buildSystemPrompt(stageLabel, intentLabel);
|
||||||
|
String userPrompt = promptEngine.buildMultiStyleUserPrompt(
|
||||||
|
message, context, customerProfile, conversationContext,
|
||||||
|
referenceUtterances, intent, stage, stageLabel, intentLabel);
|
||||||
|
|
||||||
|
// 3. 调用 LLM 生成(非流式,后台一次完成)
|
||||||
|
long startTime = System.currentTimeMillis();
|
||||||
|
String generated = qianwenClient.generate(systemPrompt, userPrompt, 1200);
|
||||||
|
log.info("多风格话术生成完成, elapsed={}ms", System.currentTimeMillis() - startTime);
|
||||||
|
|
||||||
|
// 4. 解析出3条话术
|
||||||
|
List<String> utterances = parseMultiStyle(generated);
|
||||||
|
if (utterances.size() < 3) {
|
||||||
|
// 如果解析失败,用回退方案:把整个内容当一条
|
||||||
|
while (utterances.size() < 3) {
|
||||||
|
utterances.add(generated != null ? generated : "生成失败,请稍后重试");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 逐条逐字流式推送给前端(伪流式,打字机效果)
|
||||||
|
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));
|
||||||
|
|
||||||
|
// 逐字推送(每20-40ms推送一个字,模拟打字速度)
|
||||||
|
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); // 25ms/字,约 40字/秒
|
||||||
|
}
|
||||||
|
|
||||||
|
// 该条话术完成
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 全部完成
|
||||||
|
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;
|
||||||
|
|
||||||
|
// 去掉常见的AI输出前缀
|
||||||
|
String cleaned = text.replaceAll("^(风格一[::]?|风格二[::]?|风格三[::]?|亲和风格[::]?|专业风格[::]?|促单风格[::]?)", "").trim();
|
||||||
|
|
||||||
|
// 按 ||| 分隔
|
||||||
|
String[] parts = cleaned.split("\\|\\|\\|");
|
||||||
|
for (String part : parts) {
|
||||||
|
String trimmed = part.trim();
|
||||||
|
if (!trimmed.isEmpty() && trimmed.length() > 5) {
|
||||||
|
// 去掉可能的风格标签前缀
|
||||||
|
trimmed = trimmed.replaceAll("^(风格[一二三四五][::]?|【.*?】[::]?)", "").trim();
|
||||||
|
result.add(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通用文本生成接口(供其他服务调用)
|
* 通用文本生成接口(供其他服务调用)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -128,6 +128,59 @@ public class PromptEngine {
|
|||||||
return sb.toString();
|
return sb.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建多风格话术的用户提示词
|
||||||
|
* 一次生成 3 条不同风格的话术,用特定分隔符
|
||||||
|
*/
|
||||||
|
public String buildMultiStyleUserPrompt(String message, String context,
|
||||||
|
String customerProfile, String conversationContext,
|
||||||
|
String referenceUtterances,
|
||||||
|
String intent, String stage,
|
||||||
|
String stageLabel, String intentLabel) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
|
||||||
|
if (customerProfile != null && !customerProfile.isEmpty()) {
|
||||||
|
sb.append("【学员画像】\n").append(customerProfile).append("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
String history = null;
|
||||||
|
if (conversationContext != null && !conversationContext.isEmpty()) {
|
||||||
|
history = conversationContext;
|
||||||
|
} else if (context != null && !context.isEmpty()) {
|
||||||
|
history = context;
|
||||||
|
}
|
||||||
|
if (history != null && !history.isEmpty()) {
|
||||||
|
sb.append("【最近对话】\n").append(history).append("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
String stageDisplay = (stageLabel != null && !stageLabel.isEmpty()) ? stageLabel : stage;
|
||||||
|
String intentDisplay = (intentLabel != null && !intentLabel.isEmpty()) ? intentLabel : intent;
|
||||||
|
sb.append("【当前阶段】").append(stageDisplay).append("\n");
|
||||||
|
sb.append("【学员意图】").append(intentDisplay).append("\n\n");
|
||||||
|
|
||||||
|
if (referenceUtterances != null && !referenceUtterances.isEmpty()) {
|
||||||
|
sb.append("【参考话术风格】\n").append(referenceUtterances).append("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.append("学员最新消息:\"").append(message).append("\"\n\n");
|
||||||
|
sb.append("请为这条学员消息生成3种不同风格的回复话术,每种风格完全不同,让学员有更多选择:\n\n");
|
||||||
|
sb.append("风格一【亲和风格】:语气温暖、亲切、像朋友聊天,拉近距离,适合建立好感\n");
|
||||||
|
sb.append("风格二【专业风格】:用数据、案例、专业分析增强说服力,适合建立信任\n");
|
||||||
|
sb.append("风格三【促单风格】:制造紧迫感,强调限时优惠或名额有限,适合促成决策\n\n");
|
||||||
|
sb.append("要求:\n");
|
||||||
|
sb.append("- 每条话术150-250字\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.
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.
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.
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
frontend/.gitignore
vendored
Normal file
1
frontend/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
sidebar-backup
|
||||||
@ -1,7 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { NavBar, Tabs, Toast } from 'antd-mobile'
|
import { Tabs, Toast } from 'antd-mobile'
|
||||||
import ProfileCard from './components/ProfileCard'
|
import ScriptRecommendStream from './components/ScriptRecommendStream'
|
||||||
import ScriptRecommend from './components/ScriptRecommend'
|
|
||||||
import ConversationAnalysis from './components/ConversationAnalysis'
|
import ConversationAnalysis from './components/ConversationAnalysis'
|
||||||
import './styles.css'
|
import './styles.css'
|
||||||
|
|
||||||
@ -10,7 +9,7 @@ function App() {
|
|||||||
const [authState, setAuthState] = useState<'loading' | 'authed' | '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 [manualCustomerId, setManualCustomerId] = useState('')
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initWeCom()
|
initWeCom()
|
||||||
@ -163,15 +162,6 @@ function App() {
|
|||||||
}
|
}
|
||||||
}, [authState])
|
}, [authState])
|
||||||
|
|
||||||
const handleManualCustomer = () => {
|
|
||||||
if (manualCustomerId.trim()) {
|
|
||||||
localStorage.setItem('current_customer_id', manualCustomerId.trim())
|
|
||||||
setCurrentCustomerId(manualCustomerId.trim())
|
|
||||||
setManualCustomerId('')
|
|
||||||
Toast.show({ content: '已加载客户: ' + manualCustomerId.trim(), icon: 'success' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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' }}>
|
||||||
@ -185,37 +175,9 @@ function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<NavBar back={null} style={{ background: '#1677ff', color: '#fff' }}>
|
|
||||||
AI坐席助手
|
|
||||||
</NavBar>
|
|
||||||
<ProfileCard userInfo={userInfo} customerId={currentCustomerId} />
|
|
||||||
|
|
||||||
{/* 手动输入客户ID(调试/降级) */}
|
|
||||||
<div style={{ padding: '8px 12px', background: '#fffbe6', borderBottom: '1px solid #ffe58f' }}>
|
|
||||||
<div style={{ fontSize: 12, color: '#d48806', marginBottom: 4 }}>
|
|
||||||
💡 调试模式:可手动输入客户ID加载画像
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="输入客户ID,如: test_customer_001"
|
|
||||||
value={manualCustomerId}
|
|
||||||
onChange={(e) => setManualCustomerId(e.target.value)}
|
|
||||||
style={{ flex: 1, padding: '6px 10px', border: '1px solid #d9d9d9', borderRadius: 4, fontSize: 13 }}
|
|
||||||
onKeyPress={(e) => e.key === 'Enter' && handleManualCustomer()}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleManualCustomer}
|
|
||||||
style={{ padding: '6px 12px', background: '#1677ff', color: '#fff', border: 'none', borderRadius: 4, fontSize: 13, cursor: 'pointer' }}
|
|
||||||
>
|
|
||||||
加载画像
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tabs activeKey={activeTab} onChange={setActiveTab}>
|
<Tabs activeKey={activeTab} onChange={setActiveTab}>
|
||||||
<Tabs.Tab title="话术推荐" key="recommend">
|
<Tabs.Tab title="智能推荐" key="recommend">
|
||||||
<ScriptRecommend userInfo={userInfo} customerId={currentCustomerId} />
|
<ScriptRecommendStream userInfo={userInfo} customerId={currentCustomerId} />
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
<Tabs.Tab title="对话分析" key="analysis">
|
<Tabs.Tab title="对话分析" key="analysis">
|
||||||
<ConversationAnalysis userInfo={userInfo} />
|
<ConversationAnalysis userInfo={userInfo} />
|
||||||
|
|||||||
403
frontend/sidebar/src/components/ScriptRecommendStream.tsx
Normal file
403
frontend/sidebar/src/components/ScriptRecommendStream.tsx
Normal file
@ -0,0 +1,403 @@
|
|||||||
|
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||||
|
import { Button, Toast, SpinLoading, Tag, Space } from 'antd-mobile'
|
||||||
|
|
||||||
|
interface StyleConfig {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
desc: string
|
||||||
|
color: string
|
||||||
|
bgColor: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UtteranceItem {
|
||||||
|
styleId: string
|
||||||
|
text: string
|
||||||
|
isComplete: boolean
|
||||||
|
isTyping: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
userInfo?: any
|
||||||
|
customerId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const STYLES: StyleConfig[] = [
|
||||||
|
{ id: 'friendly', name: '亲和风格', desc: '温暖亲切,像朋友聊天', color: '#1677ff', bgColor: '#e6f4ff' },
|
||||||
|
{ id: 'professional', name: '专业风格', desc: '数据驱动,专业权威', color: '#52c41a', bgColor: '#f6ffed' },
|
||||||
|
{ id: 'urgent', name: '促单风格', desc: '制造紧迫感,促成决策', color: '#fa8c16', bgColor: '#fff7e6' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function ScriptRecommendStream({ userInfo, customerId: propCustomerId }: 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 corpId = userInfo?.corpId || 'wwd483c2fba24ae30a'
|
||||||
|
|
||||||
|
const closeSse = useCallback(() => {
|
||||||
|
if (sseRef.current) {
|
||||||
|
sseRef.current.close()
|
||||||
|
sseRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSend = (content: string) => {
|
||||||
|
const wx = (window as any).wx
|
||||||
|
if (wx && wx.invoke) {
|
||||||
|
wx.invoke('sendChatMessage', {
|
||||||
|
msgtype: 'text',
|
||||||
|
text: { content }
|
||||||
|
}, (res: any) => {
|
||||||
|
if (res?.err_msg === 'sendChatMessage:ok') {
|
||||||
|
Toast.show({ content: '发送成功', icon: 'success' })
|
||||||
|
} else {
|
||||||
|
navigator.clipboard.writeText(content)
|
||||||
|
Toast.show({ content: '已复制(企微SDK未就绪)', icon: 'success' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
navigator.clipboard.writeText(content)
|
||||||
|
Toast.show({ content: '已复制到剪贴板', icon: 'success' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopy = (content: string) => {
|
||||||
|
navigator.clipboard.writeText(content)
|
||||||
|
Toast.show({ content: '已复制', icon: 'success' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRegenerate = () => {
|
||||||
|
closeSse()
|
||||||
|
setUtterances([])
|
||||||
|
setProgress(0)
|
||||||
|
setError('')
|
||||||
|
startGeneration()
|
||||||
|
}
|
||||||
|
|
||||||
|
const startGeneration = useCallback(async () => {
|
||||||
|
if (!customerMsg.trim()) {
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 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)
|
||||||
|
} catch (e) { console.error('SSE start error:', e) }
|
||||||
|
})
|
||||||
|
|
||||||
|
es.addEventListener('item-start', (event: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data)
|
||||||
|
if (data.type === 'item-start') {
|
||||||
|
setUtterances(prev => prev.map(u =>
|
||||||
|
u.styleId === data.styleId ? { ...u, isTyping: true } : u
|
||||||
|
))
|
||||||
|
}
|
||||||
|
} catch (e) { console.error('SSE item-start error:', e) }
|
||||||
|
})
|
||||||
|
|
||||||
|
es.addEventListener('chunk', (event: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data)
|
||||||
|
if (data.type === 'chunk') {
|
||||||
|
setUtterances(prev => prev.map(u =>
|
||||||
|
u.styleId === data.styleId ? { ...u, text: u.text + data.text } : u
|
||||||
|
))
|
||||||
|
}
|
||||||
|
} catch (e) { console.error('SSE chunk error:', e) }
|
||||||
|
})
|
||||||
|
|
||||||
|
es.addEventListener('complete', (event: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data)
|
||||||
|
if (data.type === 'complete') {
|
||||||
|
setUtterances(prev => prev.map(u =>
|
||||||
|
u.styleId === data.styleId ? { ...u, text: data.fullText, isComplete: true, isTyping: false } : u
|
||||||
|
))
|
||||||
|
}
|
||||||
|
} catch (e) { console.error('SSE complete error:', e) }
|
||||||
|
})
|
||||||
|
|
||||||
|
es.addEventListener('done', (event: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data)
|
||||||
|
if (data.type === 'done') {
|
||||||
|
setGenerating(false)
|
||||||
|
setProgress(100)
|
||||||
|
es.close()
|
||||||
|
}
|
||||||
|
} catch (e) { console.error('SSE done error:', e) }
|
||||||
|
})
|
||||||
|
|
||||||
|
es.addEventListener('error', (event: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data)
|
||||||
|
setError(data.message || '生成失败')
|
||||||
|
} catch (e) {
|
||||||
|
setError('连接中断')
|
||||||
|
}
|
||||||
|
setGenerating(false)
|
||||||
|
es.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error('生成失败:', e)
|
||||||
|
setError('网络错误,请重试')
|
||||||
|
setGenerating(false)
|
||||||
|
}
|
||||||
|
}, [customerMsg, customerId, corpId, sessionId, staffId])
|
||||||
|
|
||||||
|
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('暂无学员最新消息')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('初始化失败:', e)
|
||||||
|
setLoading(false)
|
||||||
|
setError('获取学员消息失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
init()
|
||||||
|
return () => { closeSse() }
|
||||||
|
}, [customerId, corpId, closeSse])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (customerMsg.trim() && !generating && utterances.length === 0) {
|
||||||
|
startGeneration()
|
||||||
|
}
|
||||||
|
}, [customerMsg, generating, utterances.length, startGeneration])
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 12, background: '#f5f5f5', minHeight: '100vh' }}>
|
||||||
|
{/* 学员消息 + 标签 */}
|
||||||
|
<div style={{
|
||||||
|
background: '#fff',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: '12px 16px',
|
||||||
|
marginBottom: 12,
|
||||||
|
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: '#333', fontWeight: 500 }}>{customerMsg || '暂无'}</span>
|
||||||
|
</div>
|
||||||
|
{profileTags.length > 0 && (
|
||||||
|
<Space wrap>
|
||||||
|
{profileTags.map(tag => (
|
||||||
|
<Tag key={tag} color='primary' fill='outline' style={{ fontSize: 12 }}>{tag}</Tag>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 生成进度 */}
|
||||||
|
{generating && progress < 100 && (
|
||||||
|
<div style={{
|
||||||
|
background: '#fff',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: '12px 16px',
|
||||||
|
marginBottom: 12,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 错误 */}
|
||||||
|
{error && (
|
||||||
|
<div style={{
|
||||||
|
background: '#fff2f0', border: '1px solid #ffccc7', borderRadius: 12,
|
||||||
|
padding: '12px 16px', marginBottom: 12, color: '#cf1322', fontSize: 13
|
||||||
|
}}>
|
||||||
|
⚠️ {error}
|
||||||
|
<Button size='mini' fill='outline' style={{ marginLeft: 8 }} onClick={handleRegenerate}>重试</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 三条话术卡片 */}
|
||||||
|
{utterances.map((item, index) => {
|
||||||
|
const style = STYLES[index]
|
||||||
|
return (
|
||||||
|
<div key={style.id} style={{
|
||||||
|
background: '#fff', borderRadius: 12, padding: '14px 16px',
|
||||||
|
marginBottom: 12, boxShadow: '0 1px 4px rgba(0,0,0,0.06)',
|
||||||
|
borderLeft: `4px solid ${style.color}`
|
||||||
|
}}>
|
||||||
|
{/* 风格标签 */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||||
|
background: style.bgColor, color: style.color,
|
||||||
|
fontSize: 12, fontWeight: 600, padding: '3px 10px', borderRadius: 10
|
||||||
|
}}>{style.name}</span>
|
||||||
|
<span style={{ fontSize: 11, color: '#999' }}>{style.desc}</span>
|
||||||
|
{item.isTyping && (
|
||||||
|
<span style={{ fontSize: 11, color: '#1677ff', marginLeft: 'auto' }}>生成中...</span>
|
||||||
|
)}
|
||||||
|
{item.isComplete && (
|
||||||
|
<span style={{ fontSize: 11, color: '#52c41a', marginLeft: 'auto' }}>✓ 已完成</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 话术内容 */}
|
||||||
|
<div style={{ fontSize: 14, lineHeight: 1.7, color: '#333', minHeight: 40, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
||||||
|
{item.text}
|
||||||
|
{item.isTyping && <span className="typing-cursor">|</span>}
|
||||||
|
{!item.text && !item.isTyping && (
|
||||||
|
<span style={{ color: '#ccc' }}>等待生成...</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
{item.isComplete && (
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
||||||
|
<Button color='primary' size='small' onClick={() => handleSend(item.text)} style={{ flex: 1 }}>
|
||||||
|
一键发送
|
||||||
|
</Button>
|
||||||
|
<Button size='small' fill='outline' onClick={() => handleCopy(item.text)}>
|
||||||
|
复制
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* 重新生成 */}
|
||||||
|
{!generating && utterances.some(u => u.isComplete) && (
|
||||||
|
<div style={{ textAlign: 'center', padding: '8px 0 20px' }}>
|
||||||
|
<Button size='small' fill='outline' onClick={handleRegenerate}>🔄 重新生成</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.typing-cursor {
|
||||||
|
animation: blink 1s step-end infinite;
|
||||||
|
color: #1677ff;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
@keyframes blink {
|
||||||
|
50% { opacity: 0; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user