feat: add multi-style utterance stream generation feature

1. 后端新增多风格话术流式生成SSE接口,支持生成亲和/专业/促单三种风格回复
2. 前端替换旧话术推荐组件为流式推荐组件,支持打字机效果与一键发送/复制
3. 新增`buildMultiStyleUserPrompt`方法构建多风格生成提示词
4. 清理前端调试模式的手动客户ID输入功能与旧导航栏
This commit is contained in:
jiao 2026-06-10 17:33:06 +08:00
parent bd9920ca24
commit efed845b6f
68 changed files with 601 additions and 43 deletions

View File

@ -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;
}
/** /**
* 通用文本生成接口(供其他服务调用) * 通用文本生成接口(供其他服务调用)
*/ */

View File

@ -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;
} }

1
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
sidebar-backup

View File

@ -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} />

View 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>
)
}