@tfdesign/b-end 1.0.16 → 1.0.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,17 @@
1
- import { useState } from 'react';
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import Button from '../components/Button';
3
3
  import Tabs from '../components/Tabs';
4
4
  import ChatInput from '../components/ChatInput';
5
5
  import Switch from '../components/Switch';
6
6
  import Icon from '../components/Icon';
7
+ import ChatMessage, {
8
+ DEFAULT_CHAT_FORM_CONFIRM,
9
+ DEFAULT_CHAT_RESULT,
10
+ DEFAULT_CHAT_RESULT_ARTIFACTS,
11
+ DEFAULT_CHAT_TASK_GROUPS,
12
+ DEFAULT_CHAT_THINKING,
13
+ useStreamingTaskGroups,
14
+ } from '../components/ChatMessage';
7
15
  import catcatSvg from '../components/file-type-assets/catcat.svg';
8
16
 
9
17
  /**
@@ -35,8 +43,305 @@ const COPILOT_SUGGESTIONS = [
35
43
  '基于批测报告分析优化方向和问题',
36
44
  ];
37
45
 
46
+ const STREAM_INTERVAL = 600;
47
+ const AUTO_FLOW_TIMINGS = {
48
+ thinkingDone: 900,
49
+ confirmShow: 1400,
50
+ };
51
+
52
+ const DEFAULT_CONVERSATION_CONTEXT = {
53
+ account: '抖音社区-社交-私信',
54
+ environment: '服务策略 / 线上策略 V8',
55
+ mock: '近 7 天私信会话样本 186 条',
56
+ conclusion: '上一轮已沉淀私信场景的服务策略问题清单,可继续追问优化方向。',
57
+ artifacts: DEFAULT_CHAT_RESULT_ARTIFACTS.map((item) => item.title),
58
+ };
59
+
60
+ const TASK_KEYWORDS = ['整理', '分析', '生成', '梳理', '输出', '汇总', '优化'];
61
+ const STOP_KEYWORDS = ['停止', '取消'];
62
+ const GENERIC_REPLIES = [
63
+ '收到,我会结合当前策略上下文继续处理。',
64
+ '明白了,我先按这个方向帮你梳理。',
65
+ '可以,我会基于当前私信场景继续补充分析。',
66
+ '没问题,我先整理成可执行的建议给你。',
67
+ ];
68
+
69
+ const FOLLOW_UP_REPLIES = {
70
+ 查看风险样本: {
71
+ resultText:
72
+ '已筛出 12 条高风险私信样本,主要集中在「用户情绪升级但意图识别偏弱」和「跨轮追问时上下文衔接不足」两类问题。建议优先补强情绪识别权重、补齐意图转人工闸门,并针对高投诉场景增加策略兜底。',
73
+ resultArtifacts: [
74
+ { id: 'risk-samples', type: 'table', title: '高风险私信样本明细', meta: '表格 · 12 条' },
75
+ { id: 'risk-summary', type: 'document', title: '问题样本归因摘要', meta: '文档 · 2 页' },
76
+ ],
77
+ followUps: ['生成优化建议', '补充验证方案', '导出给运营复盘'],
78
+ },
79
+ 生成优化建议: {
80
+ resultText:
81
+ '已生成一版服务策略优化建议:针对私信挽回、情绪安抚、转人工兜底和促成成交 4 条链路补充了策略节点,建议先在高风险词命中场景灰度 10% 验证一次答复命中率和人工接管率,再逐步放量。',
82
+ resultArtifacts: [
83
+ { id: 'strategy-revamp', type: 'strategy', title: '服务策略优化建议', meta: '策略 · 4 条主链路' },
84
+ { id: 'validation-plan', type: 'document', title: '灰度验证计划', meta: '文档 · 3 项指标' },
85
+ ],
86
+ followUps: ['生成 AB 实验方案', '补充灰度指标', '同步到飞书文档'],
87
+ },
88
+ 补充验证方案: {
89
+ resultText:
90
+ '我补充了一版验证方案:建议按照新客转化、老客挽回、风险升级 3 类会话拆分样本,分别观察策略命中率、追问完成率和转人工率,并补一组高情绪用户样本做极端场景回放。',
91
+ resultArtifacts: [
92
+ { id: 'validation-sheet', type: 'table', title: '验证样本拆分矩阵', meta: '表格 · 3 类场景' },
93
+ { id: 'replay-plan', type: 'batch-report', title: '极端场景回放计划', meta: '回放 · 28 条样本' },
94
+ ],
95
+ followUps: ['生成批测任务', '查看高情绪样本', '补充策略兜底'],
96
+ },
97
+ };
98
+
99
+ function nowHHmm() {
100
+ const d = new Date();
101
+ return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
102
+ }
103
+
104
+ function routeReply(text) {
105
+ if (FOLLOW_UP_REPLIES[text]) {
106
+ return { type: 'follow-up-result', ...FOLLOW_UP_REPLIES[text] };
107
+ }
108
+ if (STOP_KEYWORDS.some((keyword) => text.includes(keyword))) {
109
+ return { type: 'short', text: '已停止当前任务,需要时再叫我。' };
110
+ }
111
+ if (TASK_KEYWORDS.some((keyword) => text.includes(keyword))) {
112
+ return { type: 'task' };
113
+ }
114
+ return { type: 'short', text: GENERIC_REPLIES[Math.floor(Math.random() * GENERIC_REPLIES.length)] };
115
+ }
116
+
117
+ function buildThinkingContent(text, context) {
118
+ const artifacts = context.artifacts?.length ? context.artifacts.join('、') : '暂无历史产物';
119
+ return [
120
+ `用户本轮输入:${text}`,
121
+ `系统自动带入当前业务场景:${context.account}`,
122
+ `系统自动带入当前环境:${context.environment}`,
123
+ `系统自动带入样本范围:${context.mock}`,
124
+ `系统自动带入上一轮结论:${context.conclusion || '暂无历史结论'}`,
125
+ `系统自动带入上一轮产物:${artifacts}`,
126
+ '先完成上下文理解与执行口径确认,再进入自动执行流。',
127
+ ].join('\n');
128
+ }
129
+
130
+ function buildClarificationConfirms(context) {
131
+ return DEFAULT_CHAT_FORM_CONFIRM.map((confirm) => ({
132
+ ...confirm,
133
+ id: 'copilot-clarify-confirm',
134
+ title: '人工澄清确认',
135
+ primaryActionLabel: '确认并继续',
136
+ secondaryActionLabel: '',
137
+ formItems: [
138
+ {
139
+ id: 'scene',
140
+ label: '分析目标',
141
+ type: 'select',
142
+ placeholder: '请选择分析目标',
143
+ defaultValue: 'strategy-analysis',
144
+ options: [
145
+ { value: 'strategy-analysis', label: '服务策略分析' },
146
+ { value: 'conversation-review', label: '私信会话复盘' },
147
+ { value: 'ab-plan', label: 'AB 实验方案' },
148
+ ],
149
+ fullWidth: true,
150
+ },
151
+ {
152
+ id: 'channel',
153
+ label: '上下文来源',
154
+ type: 'select',
155
+ placeholder: '请选择上下文来源',
156
+ defaultValue: 'current-context',
157
+ options: [
158
+ {
159
+ value: 'current-context',
160
+ label: `使用当前页面上下文、${context.environment} 与 ${context.mock}`,
161
+ },
162
+ { value: 'refresh-mock', label: '刷新样本后继续分析' },
163
+ { value: 'manual-review', label: '先人工复核关键信息' },
164
+ ],
165
+ fullWidth: true,
166
+ },
167
+ {
168
+ id: 'remark',
169
+ label: '补充说明',
170
+ type: 'input',
171
+ placeholder: '请输入补充说明(可选)',
172
+ fullWidth: true,
173
+ },
174
+ ],
175
+ }));
176
+ }
177
+
178
+ function buildAutoFlowPayload(text, reply, context) {
179
+ if (reply.type === 'follow-up-result') {
180
+ return {
181
+ resultText: `${reply.resultText}\n\n本轮追问已自动继承当前业务场景、页面环境、样本范围和上一轮结论,无需重新补充上下文。`,
182
+ resultArtifacts: reply.resultArtifacts,
183
+ followUps: reply.followUps,
184
+ };
185
+ }
186
+
187
+ if (reply.type === 'task') {
188
+ return {
189
+ resultText:
190
+ `${DEFAULT_CHAT_RESULT}\n\n我已结合当前页面的「${context.account}」上下文继续执行,并会优先围绕服务策略、流程节点和批测风险给出建议。`,
191
+ resultArtifacts: DEFAULT_CHAT_RESULT_ARTIFACTS,
192
+ followUps: ['查看风险样本', '生成优化建议', '补充验证方案'],
193
+ };
194
+ }
195
+
196
+ return {
197
+ resultText:
198
+ `${reply.text} 已结合当前页面上下文继续处理「${text}」:当前场景为 ${context.account},环境为 ${context.environment},样本范围为 ${context.mock}。如果你需要更深入的结论,可以继续选择下方追问。`,
199
+ resultArtifacts: DEFAULT_CHAT_RESULT_ARTIFACTS.slice(0, 2),
200
+ followUps: ['继续细化结论', '补充一轮验证', '生成可交付文档'],
201
+ };
202
+ }
203
+
204
+ function getFlowTotalMs() {
205
+ const totalSteps = DEFAULT_CHAT_TASK_GROUPS.reduce(
206
+ (sum, group) => sum + (group.steps?.length ?? 0),
207
+ 0,
208
+ );
209
+ return (totalSteps + 1) * STREAM_INTERVAL;
210
+ }
211
+
212
+ function hasActionablePrimaryButton(label) {
213
+ return typeof label === 'string' && label.trim().length > 0;
214
+ }
215
+
216
+ function confirmsRequireHumanGate(confirms) {
217
+ return Array.isArray(confirms) && confirms.some((confirm) => hasActionablePrimaryButton(confirm?.primaryActionLabel));
218
+ }
219
+
220
+ function messageToChatProps(msg, isLatest, handlers = {}) {
221
+ const {
222
+ onFollowUpSelect,
223
+ onConfirmPrimary,
224
+ onConfirmSecondary,
225
+ onConfirmOptionChange,
226
+ onConfirmFormChange,
227
+ } = handlers;
228
+ const baseActions = {
229
+ showCopy: true,
230
+ showQuote: true,
231
+ showLike: true,
232
+ showDislike: true,
233
+ historyMode: !isLatest,
234
+ };
235
+ const baseProps = { className: msg.className || '' };
236
+
237
+ const wrapFollowUps = (raw) => {
238
+ if (!raw) return null;
239
+ const items = Array.isArray(raw) ? raw : raw.items;
240
+ if (!Array.isArray(items) || items.length === 0) return null;
241
+ return {
242
+ items,
243
+ onSelect: (item) => {
244
+ const label = typeof item === 'string' ? item : item?.label;
245
+ if (label && onFollowUpSelect) onFollowUpSelect(label);
246
+ },
247
+ };
248
+ };
249
+
250
+ if (msg.kind === 'user') {
251
+ return {
252
+ ...baseProps,
253
+ role: 'user',
254
+ timestamp: msg.timestamp,
255
+ userContent: msg.userContent,
256
+ actions: baseActions,
257
+ };
258
+ }
259
+
260
+ if (msg.kind === 'ai-text') {
261
+ return {
262
+ ...baseProps,
263
+ role: 'ai',
264
+ header: true,
265
+ title: '',
266
+ steps: null,
267
+ resultText: msg.resultText,
268
+ timestamp: msg.timestamp,
269
+ actions: baseActions,
270
+ };
271
+ }
272
+
273
+ if (msg.kind === 'ai-thinking') {
274
+ return {
275
+ ...baseProps,
276
+ role: 'ai',
277
+ header: true,
278
+ title: '',
279
+ steps: null,
280
+ thinking: msg.thinking,
281
+ resultText: msg.resultText,
282
+ timestamp: msg.timestamp,
283
+ actions: msg.thinking?.state === 'thinking' ? null : baseActions,
284
+ };
285
+ }
286
+
287
+ if (msg.kind === 'ai-confirm') {
288
+ return {
289
+ ...baseProps,
290
+ role: 'ai',
291
+ header: true,
292
+ title: '',
293
+ steps: null,
294
+ leadText: msg.leadText,
295
+ confirms: Array.isArray(msg.confirms)
296
+ ? msg.confirms.map((confirm) => ({
297
+ ...confirm,
298
+ onOptionChange: (value, option) => onConfirmOptionChange && onConfirmOptionChange(
299
+ msg.id,
300
+ confirm.id,
301
+ value,
302
+ option,
303
+ ),
304
+ onFormChange: (formValues, meta) => onConfirmFormChange && onConfirmFormChange(
305
+ msg.id,
306
+ confirm.id,
307
+ formValues,
308
+ meta,
309
+ ),
310
+ onPrimaryAction: (payload) => onConfirmPrimary && onConfirmPrimary(msg.id, confirm.id, {
311
+ ...payload,
312
+ primaryActionLabel: confirm.primaryActionLabel,
313
+ }),
314
+ onSecondaryAction: () => onConfirmSecondary && onConfirmSecondary(msg.id, confirm.id),
315
+ }))
316
+ : msg.confirms,
317
+ timestamp: msg.timestamp,
318
+ actions: baseActions,
319
+ };
320
+ }
321
+
322
+ return {
323
+ ...baseProps,
324
+ role: 'ai',
325
+ header: true,
326
+ title: '',
327
+ steps: null,
328
+ taskGroups: msg.taskGroups,
329
+ resultText: msg.resultText,
330
+ resultArtifacts: msg.resultArtifacts,
331
+ followUps: wrapFollowUps(msg.followUps),
332
+ taskBadge: msg.taskBadge,
333
+ timestamp: msg.timestamp,
334
+ actions: msg.stream === true ? null : baseActions,
335
+ };
336
+ }
337
+
338
+ function StreamingChatMessage({ taskGroups, ...rest }) {
339
+ const streamed = useStreamingTaskGroups(taskGroups, { intervalMs: STREAM_INTERVAL });
340
+ return <ChatMessage {...rest} taskGroups={streamed} />;
341
+ }
342
+
38
343
  export default function CopilotPagePattern() {
39
- const [copilotOpen, setCopilotOpen] = useState(false);
344
+ const [copilotOpen, setCopilotOpen] = useState(true);
40
345
 
41
346
  return (
42
347
  <div
@@ -63,24 +368,24 @@ export default function CopilotPagePattern() {
63
368
  /* ────────────────────────────────────────────
64
369
  顶导栏
65
370
  左:返回 + 标题 + OLA AI 触发按钮
66
- 中:居中胶囊 Tab(绝对定位使其真正居中)
371
+ 中:一级页面 Tab(宽度不足时整组换到第二行左对齐)
67
372
  右:版本 + 更多 + 次操作 + 主操作
68
373
  ──────────────────────────────────────────── */
69
374
  function TopBar({ copilotOpen, onToggleCopilot }) {
70
375
  return (
71
376
  <div className="shrink-0 px-4 py-4">
72
- <div className="relative flex min-h-[36px] items-center">
73
- <div className="flex min-w-0 flex-1 items-center gap-3 pr-4">
377
+ <div className="relative flex min-h-[36px] min-w-0 flex-wrap items-center gap-x-4 gap-y-3">
378
+ <div className="order-1 flex min-w-[280px] flex-1 items-center gap-3 xl:order-none">
74
379
  <TopBarLead copilotOpen={copilotOpen} onToggleCopilot={onToggleCopilot} />
75
380
  </div>
76
381
 
77
- <div className="pointer-events-none absolute inset-x-0 flex justify-center">
78
- <div className="pointer-events-auto flex min-w-0 items-center">
382
+ <div className="order-3 flex w-full min-w-0 justify-start xl:pointer-events-none xl:absolute xl:inset-x-0 xl:order-none xl:w-auto xl:justify-center">
383
+ <div className="xl:pointer-events-auto">
79
384
  <TopBarTabs />
80
385
  </div>
81
386
  </div>
82
387
 
83
- <div className="ml-auto flex shrink-0 items-center gap-4 pl-4">
388
+ <div className="order-2 ml-auto flex shrink-0 items-center gap-4 xl:order-none">
84
389
  <TopBarActions />
85
390
  </div>
86
391
  </div>
@@ -91,27 +396,25 @@ function TopBar({ copilotOpen, onToggleCopilot }) {
91
396
  function TopBarLead({ copilotOpen, onToggleCopilot }) {
92
397
  return (
93
398
  <>
94
- <Button variant="outline-black" iconOnly icon={<Icon name="arrow-left-stroked" />} tooltip="返回" aria-label="返回" />
95
-
96
399
  <span
97
- className="font-semibold text-base leading-[22px] whitespace-nowrap"
400
+ className="min-w-0 truncate font-semibold [font-weight:var(--font-semibold)] text-base leading-[22px] whitespace-nowrap"
98
401
  style={{ color: 'var(--foreground, #0F1C35)' }}
99
402
  >
100
- 主策略:抖音社区-社交-私信
403
+ 抖音社区-社交-私信
101
404
  </span>
102
405
 
103
406
  {!copilotOpen && (
104
- <div
407
+ <Button
408
+ variant="outline-black"
409
+ radius="full"
105
410
  onClick={onToggleCopilot}
106
- className="shrink-0 cursor-pointer"
411
+ className="shrink-0 border-transparent bg-[image:var(--gradient-ai-fill-2)] p-[1.5px] hover:bg-[image:var(--gradient-ai-fill-2)] active:bg-[image:var(--gradient-ai-fill-2)] focus-visible:outline-[var(--color-brand-200)]"
107
412
  style={{
108
- padding: '1.5px',
109
- borderRadius: '999px',
110
- background: 'linear-gradient(-45deg, rgba(255, 153, 248, 0.4) 0%, rgba(181, 131, 255, 0.4) 25%, rgba(114, 156, 255, 0.4) 48%, rgba(117, 218, 231, 0.4) 83%, rgba(115, 230, 204, 0.4) 100%)',
413
+ boxShadow: '0 0 0 1px rgba(255,255,255,0.6) inset',
111
414
  }}
112
415
  >
113
- <div
114
- className="inline-flex items-center gap-2"
416
+ <span
417
+ className="inline-flex items-center gap-2 rounded-full"
115
418
  style={{
116
419
  height: '34px',
117
420
  padding: '0 14px',
@@ -126,8 +429,8 @@ function TopBarLead({ copilotOpen, onToggleCopilot }) {
126
429
  >
127
430
  <img src={catcatSvg} alt="OLA AI" style={{ width: '20px', height: '20px', display: 'block' }} />
128
431
  <span>OLA AI</span>
129
- </div>
130
- </div>
432
+ </span>
433
+ </Button>
131
434
  )}
132
435
  </>
133
436
  );
@@ -140,23 +443,6 @@ function TopBarTabs() {
140
443
  function TopBarActions() {
141
444
  return (
142
445
  <>
143
- <div
144
- className="inline-flex items-center gap-1 shrink-0"
145
- style={{
146
- height: '36px',
147
- padding: '0 16px',
148
- borderRadius: '999px',
149
- background: 'var(--color-brand-50, #f0fdfa)',
150
- border: '1px solid var(--color-brand-500, #5eead4)',
151
- fontSize: '14px',
152
- fontWeight: 600,
153
- color: 'var(--color-brand-950, #065f46)',
154
- }}
155
- >
156
- <span>V8 线上</span>
157
- <Icon name="chevron-selector-vertical-stroked" size={16} />
158
- </div>
159
-
160
446
  <div className="flex items-center gap-2">
161
447
  <Button variant="ghost-black" iconOnly icon={<Icon name="dots-horizontal-stroked" />} tooltip="更多" aria-label="更多" />
162
448
  <Button variant="outline-black">次操作</Button>
@@ -173,57 +459,478 @@ function TopBarActions() {
173
459
  下:ChatInput
174
460
  ──────────────────────────────────────────── */
175
461
  function CopilotPanel({ onClose }) {
462
+ const [phase, setPhase] = useState('welcome');
463
+ const [messages, setMessages] = useState([]);
464
+ const [inputView, setInputView] = useState('default');
465
+ const replyTimerRef = useRef(null);
466
+ const busyTimerRef = useRef(null);
467
+ const flowTimersRef = useRef([]);
468
+ const pendingFlowResumeRef = useRef(null);
469
+ const scrollRef = useRef(null);
470
+ const latestAnchorRef = useRef(null);
471
+ const idSeedRef = useRef(100);
472
+ const conversationContextRef = useRef({ ...DEFAULT_CONVERSATION_CONTEXT });
473
+ const nextId = useCallback((prefix = 'm') => `${prefix}${++idSeedRef.current}`, []);
474
+
475
+ const clearFlowTimers = useCallback(() => {
476
+ flowTimersRef.current.forEach((timerId) => clearTimeout(timerId));
477
+ flowTimersRef.current = [];
478
+ }, []);
479
+
480
+ const scheduleFlowTimer = useCallback((callback, delay) => {
481
+ const timerId = setTimeout(() => {
482
+ flowTimersRef.current = flowTimersRef.current.filter((id) => id !== timerId);
483
+ callback();
484
+ }, delay);
485
+ flowTimersRef.current.push(timerId);
486
+ }, []);
487
+
488
+ const clearPendingFlowResume = useCallback(() => {
489
+ pendingFlowResumeRef.current = null;
490
+ }, []);
491
+
492
+ const pauseForHumanGate = useCallback((resume) => {
493
+ pendingFlowResumeRef.current = resume;
494
+ }, []);
495
+
496
+ const resumePendingFlow = useCallback(() => {
497
+ const resume = pendingFlowResumeRef.current;
498
+ pendingFlowResumeRef.current = null;
499
+ if (typeof resume === 'function') resume();
500
+ }, []);
501
+
502
+ const scrollToLatest = useCallback((behavior = 'smooth') => {
503
+ if (latestAnchorRef.current?.scrollIntoView) {
504
+ latestAnchorRef.current.scrollIntoView({ block: 'end', behavior });
505
+ return;
506
+ }
507
+ const el = scrollRef.current;
508
+ if (el) el.scrollTo({ top: el.scrollHeight, behavior });
509
+ }, []);
510
+
511
+ useEffect(() => () => {
512
+ if (replyTimerRef.current) clearTimeout(replyTimerRef.current);
513
+ if (busyTimerRef.current) clearTimeout(busyTimerRef.current);
514
+ clearFlowTimers();
515
+ }, [clearFlowTimers]);
516
+
517
+ useEffect(() => {
518
+ if (phase !== 'chat' || messages.length === 0) return undefined;
519
+ const el = scrollRef.current;
520
+ if (!el) return undefined;
521
+ const inner = el.firstElementChild;
522
+ if (!inner) return undefined;
523
+ const ro = new ResizeObserver(() => {
524
+ scrollToLatest();
525
+ });
526
+ ro.observe(inner);
527
+ return () => ro.disconnect();
528
+ }, [phase, messages.length, scrollToLatest]);
529
+
530
+ const handleNewSession = useCallback(() => {
531
+ if (replyTimerRef.current) clearTimeout(replyTimerRef.current);
532
+ if (busyTimerRef.current) clearTimeout(busyTimerRef.current);
533
+ clearFlowTimers();
534
+ clearPendingFlowResume();
535
+ conversationContextRef.current = { ...DEFAULT_CONVERSATION_CONTEXT };
536
+ setMessages([]);
537
+ setPhase('welcome');
538
+ setInputView('default');
539
+ }, [clearFlowTimers, clearPendingFlowResume]);
540
+
541
+ const handleConfirmPrimary = useCallback(
542
+ (messageId, confirmId, payload = {}) => {
543
+ const isActionable = hasActionablePrimaryButton(payload.primaryActionLabel);
544
+ const selectedLabel = payload.option?.label ?? '';
545
+ setMessages((prev) =>
546
+ prev.map((message) =>
547
+ message.id === messageId
548
+ ? {
549
+ ...message,
550
+ confirms: Array.isArray(message.confirms)
551
+ ? message.confirms.map((confirm) => (
552
+ confirm.id === confirmId
553
+ ? {
554
+ ...confirm,
555
+ defaultConfirmed: true,
556
+ selectedValue: payload.value ?? confirm.selectedValue,
557
+ defaultSelectedValue: payload.value ?? confirm.defaultSelectedValue,
558
+ selectedLabel: selectedLabel || confirm.selectedLabel,
559
+ formValues: payload.formValues ?? confirm.formValues,
560
+ }
561
+ : confirm
562
+ ))
563
+ : message.confirms,
564
+ leadText: isActionable ? '已确认澄清信息,继续执行后续流程。' : message.leadText,
565
+ timestamp: nowHHmm(),
566
+ }
567
+ : message,
568
+ ),
569
+ );
570
+ if (isActionable) resumePendingFlow();
571
+ },
572
+ [resumePendingFlow],
573
+ );
574
+
575
+ const handleConfirmOptionChange = useCallback((messageId, confirmId, value, option) => {
576
+ setMessages((prev) =>
577
+ prev.map((message) =>
578
+ message.id === messageId
579
+ ? {
580
+ ...message,
581
+ confirms: Array.isArray(message.confirms)
582
+ ? message.confirms.map((confirm) => (
583
+ confirm.id === confirmId
584
+ ? {
585
+ ...confirm,
586
+ selectedValue: value,
587
+ defaultSelectedValue: value,
588
+ selectedLabel: option?.label ?? confirm.selectedLabel,
589
+ }
590
+ : confirm
591
+ ))
592
+ : message.confirms,
593
+ }
594
+ : message,
595
+ ),
596
+ );
597
+ }, []);
598
+
599
+ const handleConfirmFormChange = useCallback((messageId, confirmId, formValues, meta = {}) => {
600
+ setMessages((prev) =>
601
+ prev.map((message) =>
602
+ message.id === messageId
603
+ ? {
604
+ ...message,
605
+ confirms: Array.isArray(message.confirms)
606
+ ? message.confirms.map((confirm) => (
607
+ confirm.id === confirmId
608
+ ? {
609
+ ...confirm,
610
+ formValues,
611
+ lastChangedFieldId: meta.fieldId,
612
+ }
613
+ : confirm
614
+ ))
615
+ : message.confirms,
616
+ }
617
+ : message,
618
+ ),
619
+ );
620
+ }, []);
621
+
622
+ const handleConfirmSecondary = useCallback(
623
+ (messageId, confirmId) => {
624
+ setMessages((prev) =>
625
+ prev.map((message) =>
626
+ message.id === messageId
627
+ ? {
628
+ ...message,
629
+ confirms: Array.isArray(message.confirms)
630
+ ? message.confirms.map((confirm) => (
631
+ confirm.id === confirmId ? { ...confirm, defaultConfirmed: true } : confirm
632
+ ))
633
+ : message.confirms,
634
+ leadText: '已取消当前确认,流程暂停。',
635
+ timestamp: nowHHmm(),
636
+ }
637
+ : message,
638
+ ),
639
+ );
640
+ clearPendingFlowResume();
641
+ setInputView('default');
642
+ },
643
+ [clearPendingFlowResume],
644
+ );
645
+
646
+ const startAutoOrchestratedFlow = useCallback(
647
+ (text, reply, ctx = {}) => {
648
+ const contextSnapshot = { ...conversationContextRef.current };
649
+ const thinkingMsgId = nextId('think');
650
+ const confirmMsgId = nextId('confirm');
651
+ const flowMsgId = nextId('flow');
652
+ const payload = buildAutoFlowPayload(text, reply, contextSnapshot);
653
+ const clarificationConfirms = buildClarificationConfirms(contextSnapshot);
654
+
655
+ const finishExecutionFlow = () => {
656
+ setMessages((prev) =>
657
+ prev.map((message) =>
658
+ message.id === flowMsgId
659
+ ? {
660
+ ...message,
661
+ stream: false,
662
+ taskGroups: DEFAULT_CHAT_TASK_GROUPS.map((group) => ({
663
+ ...group,
664
+ status: 'completed',
665
+ })),
666
+ resultText: payload.resultText,
667
+ resultArtifacts: payload.resultArtifacts,
668
+ followUps: payload.followUps,
669
+ timestamp: nowHHmm(),
670
+ }
671
+ : message,
672
+ ),
673
+ );
674
+ conversationContextRef.current = {
675
+ ...contextSnapshot,
676
+ conclusion: payload.resultText,
677
+ artifacts: payload.resultArtifacts.map((artifact) => artifact.title),
678
+ lastUserIntent: text,
679
+ };
680
+ setInputView('default');
681
+ };
682
+
683
+ const startExecutionFlow = () => {
684
+ setInputView('busy');
685
+ setMessages((prev) => [
686
+ ...prev,
687
+ {
688
+ id: flowMsgId,
689
+ kind: 'ai-flow',
690
+ timestamp: nowHHmm(),
691
+ taskGroups: DEFAULT_CHAT_TASK_GROUPS,
692
+ stream: true,
693
+ },
694
+ ]);
695
+ scheduleFlowTimer(finishExecutionFlow, getFlowTotalMs());
696
+ };
697
+
698
+ const continueAfterConfirm = () => {
699
+ setMessages((prev) =>
700
+ prev.map((message) =>
701
+ message.id === confirmMsgId
702
+ ? {
703
+ ...message,
704
+ confirms: Array.isArray(message.confirms)
705
+ ? message.confirms.map((confirm) => ({
706
+ ...confirm,
707
+ defaultConfirmed: true,
708
+ }))
709
+ : message.confirms,
710
+ leadText: '已确认澄清信息,开始自动执行。',
711
+ timestamp: nowHHmm(),
712
+ }
713
+ : message,
714
+ ),
715
+ );
716
+ scheduleFlowTimer(startExecutionFlow, 320);
717
+ };
718
+
719
+ setMessages((prev) => [
720
+ ...prev,
721
+ {
722
+ id: thinkingMsgId,
723
+ kind: 'ai-thinking',
724
+ timestamp: nowHHmm(),
725
+ thinking: {
726
+ ...DEFAULT_CHAT_THINKING,
727
+ state: 'thinking',
728
+ inProgressLabel: '深度思考中 ...',
729
+ defaultExpanded: false,
730
+ },
731
+ className: ctx.source === 'follow-up' ? 'tfds-followup-ai-pop' : '',
732
+ },
733
+ ]);
734
+
735
+ scheduleFlowTimer(() => {
736
+ setMessages((prev) =>
737
+ prev.map((message) =>
738
+ message.id === thinkingMsgId
739
+ ? {
740
+ ...message,
741
+ thinking: {
742
+ ...DEFAULT_CHAT_THINKING,
743
+ state: 'completed',
744
+ durationLabel: '深度思考(用时 3.20 秒)',
745
+ content: buildThinkingContent(text, contextSnapshot),
746
+ defaultExpanded: false,
747
+ },
748
+ resultText: '已完成上下文理解,接下来需要先确认本轮执行口径。',
749
+ timestamp: nowHHmm(),
750
+ }
751
+ : message,
752
+ ),
753
+ );
754
+ }, AUTO_FLOW_TIMINGS.thinkingDone);
755
+
756
+ scheduleFlowTimer(() => {
757
+ setMessages((prev) => [
758
+ ...prev,
759
+ {
760
+ id: confirmMsgId,
761
+ kind: 'ai-confirm',
762
+ timestamp: nowHHmm(),
763
+ leadText: '请确认以下人工澄清信息;确认后我会继续自动执行策略分析和结果整理。',
764
+ confirms: clarificationConfirms,
765
+ },
766
+ ]);
767
+
768
+ if (confirmsRequireHumanGate(clarificationConfirms)) {
769
+ pauseForHumanGate(continueAfterConfirm);
770
+ return;
771
+ }
772
+
773
+ scheduleFlowTimer(continueAfterConfirm, 320);
774
+ }, AUTO_FLOW_TIMINGS.confirmShow);
775
+ },
776
+ [nextId, pauseForHumanGate, scheduleFlowTimer],
777
+ );
778
+
779
+ const handleSend = useCallback(
780
+ (text, ctx = {}) => {
781
+ const trimmed = (text || '').trim();
782
+ if (!trimmed) return;
783
+
784
+ if (phase === 'welcome') setPhase('chat');
785
+
786
+ const userContent = Array.isArray(ctx.segments) && ctx.segments.length > 0
787
+ ? ctx.segments
788
+ : [{ type: 'text', value: trimmed }];
789
+
790
+ setMessages((prev) => [
791
+ ...prev,
792
+ {
793
+ id: nextId('u'),
794
+ kind: 'user',
795
+ timestamp: nowHHmm(),
796
+ userContent,
797
+ className: ctx.source === 'follow-up' ? 'tfds-followup-user-pop' : '',
798
+ },
799
+ ]);
800
+ setInputView('replying');
801
+
802
+ const reply = routeReply(trimmed);
803
+ if (replyTimerRef.current) clearTimeout(replyTimerRef.current);
804
+ if (busyTimerRef.current) clearTimeout(busyTimerRef.current);
805
+ clearFlowTimers();
806
+ clearPendingFlowResume();
807
+
808
+ if (reply.type === 'short') {
809
+ replyTimerRef.current = setTimeout(() => {
810
+ setMessages((prev) => [
811
+ ...prev,
812
+ {
813
+ id: nextId('a'),
814
+ kind: 'ai-text',
815
+ timestamp: nowHHmm(),
816
+ resultText: reply.text,
817
+ className: ctx.source === 'follow-up' ? 'tfds-followup-ai-pop' : '',
818
+ },
819
+ ]);
820
+ setInputView('default');
821
+ scrollToLatest();
822
+ }, 600);
823
+ return;
824
+ }
825
+
826
+ startAutoOrchestratedFlow(trimmed, reply, ctx);
827
+ scrollToLatest();
828
+ },
829
+ [phase, nextId, clearFlowTimers, clearPendingFlowResume, scrollToLatest, startAutoOrchestratedFlow],
830
+ );
831
+
832
+ const handleFollowUpSend = useCallback(
833
+ (text) => {
834
+ handleSend(text, { source: 'follow-up' });
835
+ },
836
+ [handleSend],
837
+ );
838
+
839
+ const handleStop = useCallback(() => {
840
+ if (replyTimerRef.current) {
841
+ clearTimeout(replyTimerRef.current);
842
+ replyTimerRef.current = null;
843
+ }
844
+ if (busyTimerRef.current) {
845
+ clearTimeout(busyTimerRef.current);
846
+ busyTimerRef.current = null;
847
+ }
848
+ clearFlowTimers();
849
+ clearPendingFlowResume();
850
+ setInputView('default');
851
+ setMessages((prev) => [
852
+ ...prev,
853
+ {
854
+ id: nextId('a'),
855
+ kind: 'ai-text',
856
+ timestamp: nowHHmm(),
857
+ resultText: '已停止当前任务,需要时再叫我。',
858
+ },
859
+ ]);
860
+ }, [nextId, clearFlowTimers, clearPendingFlowResume]);
861
+
862
+ const lastIdx = messages.length - 1;
863
+
176
864
  return (
177
865
  <div
178
- className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden"
866
+ className="flex h-full min-h-0 min-w-0 flex-col"
179
867
  style={{
180
868
  width: 'min(450px, 100%)',
181
869
  }}
182
870
  >
183
871
  {/* 标题栏 */}
184
872
  <div className="flex items-center shrink-0" style={{ height: '48px', paddingRight: '4px' }}>
185
- <div className="flex items-center flex-1 min-w-0 gap-1">
186
- <span
187
- className="text-sm leading-5 pl-1"
188
- style={{ color: 'var(--foreground-muted, rgba(15,28,53,0.6))' }}
189
- >
190
- 新会话
191
- </span>
192
- </div>
873
+ <div className="flex items-center flex-1 min-w-0 gap-1" />
193
874
  <div className="flex items-center gap-0.5 shrink-0">
194
- <Button variant="ghost-black" iconOnly icon={<Icon name="message-plus-square-stroked" />} tooltip="新建会话" aria-label="新建会话" />
875
+ <Button variant="ghost-black" iconOnly icon={<Icon name="message-plus-square-stroked" />} tooltip="新建会话" aria-label="新建会话" onClick={handleNewSession} />
195
876
  <Button variant="ghost-black" iconOnly icon={<Icon name="clock-stroked" />} tooltip="历史记录" aria-label="历史记录" />
196
877
  <Button variant="ghost-black" iconOnly icon={<Icon name="layout-left-stroked" />} tooltip="收起面板" aria-label="收起面板" onClick={onClose} />
197
878
  </div>
198
879
  </div>
199
880
 
200
- {/* 对话流:欢迎态 */}
201
- <div className="flex flex-1 min-h-0 flex-col items-center justify-center gap-6" style={{ padding: '0 80px' }}>
202
- {/* AI 头像 + 标题 */}
881
+ {phase === 'welcome' ? (
882
+ <CopilotWelcomePhase onSend={handleSend} inputView={inputView} onStop={handleStop} />
883
+ ) : (
884
+ <CopilotChatPhase
885
+ scrollRef={scrollRef}
886
+ latestAnchorRef={latestAnchorRef}
887
+ messages={messages}
888
+ lastIdx={lastIdx}
889
+ inputView={inputView}
890
+ onSend={handleSend}
891
+ onStop={handleStop}
892
+ handlers={{
893
+ onFollowUpSelect: handleFollowUpSend,
894
+ onConfirmPrimary: handleConfirmPrimary,
895
+ onConfirmSecondary: handleConfirmSecondary,
896
+ onConfirmOptionChange: handleConfirmOptionChange,
897
+ onConfirmFormChange: handleConfirmFormChange,
898
+ }}
899
+ />
900
+ )}
901
+ </div>
902
+ );
903
+ }
904
+
905
+ function CopilotWelcomePhase({ onSend, onStop, inputView }) {
906
+ return (
907
+ <>
908
+ <div className="flex flex-1 min-h-0 flex-col items-center justify-center gap-6 overflow-y-auto" style={{ padding: '0 28px' }}>
203
909
  <div className="flex flex-col items-center gap-3">
204
- {/* 头像:渐变描边圆 + 蓝色投影 + CATCAT */}
205
910
  <div style={{ position: 'relative', width: '66px', height: '66px', flexShrink: 0 }}>
206
- {/* 渐变描边圆(外层渐变 0.5px + 内层白底) */}
207
- <div style={{
208
- position: 'absolute',
209
- inset: '0',
210
- borderRadius: '50%',
211
- padding: '0.5px',
212
- background: 'linear-gradient(135deg, rgba(63,226,213,0.6) 0%, rgba(64,147,224,0.6) 35%, rgba(122,97,250,0.6) 65%, rgba(214,130,235,0.6) 100%)',
213
- }}>
214
- <div style={{
215
- width: '100%',
216
- height: '100%',
911
+ <div
912
+ style={{
913
+ position: 'absolute',
914
+ inset: '0',
217
915
  borderRadius: '50%',
218
- background: [
219
- 'linear-gradient(42deg, #FFF 11.61%, rgba(255,255,255,0.00) 37.84%)',
220
- 'linear-gradient(74deg, rgba(63,226,213,0.15) 12.18%, rgba(64,147,224,0.15) 39.9%, rgba(122,97,250,0.15) 63.86%, rgba(214,130,235,0.15) 86.38%)',
221
- '#FFF',
222
- ].join(', '),
223
- boxShadow: '0px 8px 15px rgba(180,218,244,0.50)',
224
- }} />
916
+ padding: '0.5px',
917
+ background: 'linear-gradient(135deg, rgba(63,226,213,0.6) 0%, rgba(64,147,224,0.6) 35%, rgba(122,97,250,0.6) 65%, rgba(214,130,235,0.6) 100%)',
918
+ }}
919
+ >
920
+ <div
921
+ style={{
922
+ width: '100%',
923
+ height: '100%',
924
+ borderRadius: '50%',
925
+ background: [
926
+ 'linear-gradient(42deg, #FFF 11.61%, rgba(255,255,255,0.00) 37.84%)',
927
+ 'linear-gradient(74deg, rgba(63,226,213,0.15) 12.18%, rgba(64,147,224,0.15) 39.9%, rgba(122,97,250,0.15) 63.86%, rgba(214,130,235,0.15) 86.38%)',
928
+ '#FFF',
929
+ ].join(', '),
930
+ boxShadow: '0px 8px 15px rgba(180,218,244,0.50)',
931
+ }}
932
+ />
225
933
  </div>
226
- {/* CATCAT 图标 */}
227
934
  <img
228
935
  src={catcatSvg}
229
936
  alt="OLA AI"
@@ -239,28 +946,26 @@ function CopilotPanel({ onClose }) {
239
946
  />
240
947
  </div>
241
948
  <span
242
- className="font-semibold whitespace-nowrap"
949
+ className="whitespace-nowrap [font-weight:var(--font-semibold)]"
243
950
  style={{ fontSize: '18px', lineHeight: '24px', letterSpacing: '-0.03em', color: 'var(--foreground, #0F1C35)' }}
244
951
  >
245
952
  OLA AI
246
953
  </span>
247
954
  </div>
248
955
 
249
- {/* 欢迎语 */}
250
956
  <p
251
- className="text-sm text-center leading-5 m-0"
957
+ className="m-0 text-center text-sm leading-5"
252
958
  style={{ color: 'var(--foreground-muted, rgba(15,28,53,0.6))', width: '100%' }}
253
959
  >
254
- 您好!我是您的智能小助手 ✨ ~ 我能帮您归因分析、生成策略、Prompt、知识、工具等方案~
960
+ 您好!我是您的智能小助手,我可以帮您分析私信会话问题、生成服务策略、补充验证方案和沉淀可交付结论。
255
961
  </p>
256
962
 
257
- {/* 快捷建议 chips */}
258
- <div className="flex flex-col gap-2 w-full">
963
+ <div className="flex w-full flex-col gap-2">
259
964
  {COPILOT_SUGGESTIONS.map((text) => (
260
965
  <button
261
966
  key={text}
262
967
  type="button"
263
- className="flex items-center justify-between w-full cursor-pointer border-0 text-left"
968
+ className="flex w-full cursor-pointer items-center justify-between border-0 text-left"
264
969
  style={{
265
970
  padding: '10px 12px',
266
971
  borderRadius: '8px',
@@ -272,8 +977,9 @@ function CopilotPanel({ onClose }) {
272
977
  fontFamily: 'inherit',
273
978
  transition: 'background 150ms ease',
274
979
  }}
275
- onMouseEnter={e => { e.currentTarget.style.background = 'rgba(255,255,255,0.85)'; }}
276
- onMouseLeave={e => { e.currentTarget.style.background = 'rgba(255,255,255,0.6)'; }}
980
+ onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.85)'; }}
981
+ onMouseLeave={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.6)'; }}
982
+ onClick={() => onSend(text, { source: 'suggestion' })}
277
983
  >
278
984
  <span>{text}</span>
279
985
  <Icon name="arrow-narrow-right-stroked" size={12} />
@@ -282,11 +988,104 @@ function CopilotPanel({ onClose }) {
282
988
  </div>
283
989
  </div>
284
990
 
285
- {/* ChatInput:父级 CopilotPanel 不裁剪,让 -bottom-2 氛围背景与炫彩投影能向外溢出 */}
286
991
  <div className="shrink-0 pt-2">
287
- <ChatInput variant="default" placeholder="需要我为你做什么" />
992
+ <ChatInput
993
+ variant={inputView}
994
+ autoReplyOnSend={false}
995
+ placeholder="需要我为你做什么"
996
+ onSend={onSend}
997
+ onStop={onStop}
998
+ />
288
999
  </div>
289
- </div>
1000
+ </>
1001
+ );
1002
+ }
1003
+
1004
+ function CopilotChatPhase({
1005
+ scrollRef,
1006
+ latestAnchorRef,
1007
+ messages,
1008
+ lastIdx,
1009
+ handlers,
1010
+ onSend,
1011
+ onStop,
1012
+ inputView,
1013
+ }) {
1014
+ const [atTop, setAtTop] = useState(true);
1015
+
1016
+ useEffect(() => {
1017
+ const el = scrollRef.current;
1018
+ if (!el) return undefined;
1019
+ const update = () => setAtTop(el.scrollTop <= 4);
1020
+ update();
1021
+ el.addEventListener('scroll', update, { passive: true });
1022
+ return () => el.removeEventListener('scroll', update);
1023
+ }, [scrollRef, messages.length]);
1024
+
1025
+ const FADE_MASK = atTop
1026
+ ? 'linear-gradient(to bottom, #000 0, #000 calc(100% - 32px), transparent 100%)'
1027
+ : 'linear-gradient(to bottom, transparent 0, #000 32px, #000 calc(100% - 32px), transparent 100%)';
1028
+
1029
+ return (
1030
+ <>
1031
+ <style>
1032
+ {`
1033
+ @keyframes tfds-followup-user-pop {
1034
+ 0% { opacity: 0; transform: translate(28px, -8px) scale(0.98); }
1035
+ 100% { opacity: 1; transform: translate(0, 0) scale(1); }
1036
+ }
1037
+ @keyframes tfds-followup-ai-pop {
1038
+ 0% { opacity: 0; transform: translateY(8px); }
1039
+ 100% { opacity: 1; transform: translateY(0); }
1040
+ }
1041
+ .tfds-followup-user-pop {
1042
+ animation: tfds-followup-user-pop 240ms cubic-bezier(0.22, 1, 0.36, 1) both;
1043
+ transform-origin: right center;
1044
+ }
1045
+ .tfds-followup-ai-pop {
1046
+ animation: tfds-followup-ai-pop 260ms cubic-bezier(0.22, 1, 0.36, 1) both;
1047
+ }
1048
+ @media (prefers-reduced-motion: reduce) {
1049
+ .tfds-followup-user-pop,
1050
+ .tfds-followup-ai-pop {
1051
+ animation: none;
1052
+ }
1053
+ }
1054
+ `}
1055
+ </style>
1056
+
1057
+ <div
1058
+ ref={scrollRef}
1059
+ className="w-full flex-1 min-h-0 overflow-y-auto"
1060
+ style={{
1061
+ padding: '8px 0',
1062
+ maskImage: FADE_MASK,
1063
+ WebkitMaskImage: FADE_MASK,
1064
+ }}
1065
+ >
1066
+ <div className="mx-auto flex w-full flex-col gap-2" style={{ maxWidth: '100%', padding: '0 4px 72px' }}>
1067
+ {messages.map((message, idx) => {
1068
+ const isLatest = idx === lastIdx;
1069
+ const props = messageToChatProps(message, isLatest, handlers);
1070
+ if (message.kind === 'ai-flow' && message.stream === true) {
1071
+ return <StreamingChatMessage key={message.id} {...props} taskGroups={message.taskGroups} />;
1072
+ }
1073
+ return <ChatMessage key={message.id} {...props} />;
1074
+ })}
1075
+ <div ref={latestAnchorRef} aria-hidden="true" style={{ height: '1px' }} />
1076
+ </div>
1077
+ </div>
1078
+
1079
+ <div className="w-full shrink-0 pt-2">
1080
+ <ChatInput
1081
+ variant={inputView}
1082
+ autoReplyOnSend={false}
1083
+ placeholder="继续追问,或输入分析/生成/整理"
1084
+ onSend={onSend}
1085
+ onStop={onStop}
1086
+ />
1087
+ </div>
1088
+ </>
290
1089
  );
291
1090
  }
292
1091