@tfdesign/b-end 1.0.4
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.
- package/AI_READ_FIRST.md +131 -0
- package/LICENSE +21 -0
- package/README.md +353 -0
- package/package.json +67 -0
- package/scripts/check-tfds-contract.mjs +334 -0
- package/scripts/check-tfds-integration.mjs +263 -0
- package/scripts/postinstall-cursor-skill.mjs +382 -0
- package/scripts/setup.mjs +520 -0
- package/skills/tfds/CHECKLIST.md +205 -0
- package/skills/tfds/COMMON_FAILURES.md +238 -0
- package/skills/tfds/DESIGN_PRINCIPLES.md +477 -0
- package/skills/tfds/GLOBAL_DESIGN_RULES.md +636 -0
- package/skills/tfds/LAYOUT_RECIPES.md +140 -0
- package/skills/tfds/LAYOUT_RULES.md +1355 -0
- package/skills/tfds/PAGE_ARCHETYPES.md +201 -0
- package/skills/tfds/SKILL.md +188 -0
- package/skills/tfds/components.index.json +7305 -0
- package/skills/tfds/components.summary.json +1809 -0
- package/src/_b_end_runtime/components/AiSuggestionShared.jsx +166 -0
- package/src/_b_end_runtime/components/Avatar.jsx +325 -0
- package/src/_b_end_runtime/components/Avatar.tokens.js +76 -0
- package/src/_b_end_runtime/components/AvatarGridPreview.jsx +56 -0
- package/src/_b_end_runtime/components/AvatarGroup.jsx +80 -0
- package/src/_b_end_runtime/components/AvatarGroup.tokens.js +28 -0
- package/src/_b_end_runtime/components/Button.jsx +144 -0
- package/src/_b_end_runtime/components/Button.tokens.js +90 -0
- package/src/_b_end_runtime/components/Card.jsx +460 -0
- package/src/_b_end_runtime/components/Card.tokens.js +124 -0
- package/src/_b_end_runtime/components/CardPreview.jsx +51 -0
- package/src/_b_end_runtime/components/ChatBubble.jsx +384 -0
- package/src/_b_end_runtime/components/ChatBubble.tokens.js +60 -0
- package/src/_b_end_runtime/components/ChatBubblePreview.jsx +129 -0
- package/src/_b_end_runtime/components/ChatInput.jsx +1399 -0
- package/src/_b_end_runtime/components/ChatInput.tokens.js +75 -0
- package/src/_b_end_runtime/components/ChatMessage.jsx +2215 -0
- package/src/_b_end_runtime/components/ChatMessage.tokens.js +257 -0
- package/src/_b_end_runtime/components/ChatMessagePreview.jsx +388 -0
- package/src/_b_end_runtime/components/Checkbox.jsx +317 -0
- package/src/_b_end_runtime/components/Checkbox.tokens.js +59 -0
- package/src/_b_end_runtime/components/ConversationList.jsx +1264 -0
- package/src/_b_end_runtime/components/ConversationList.tokens.js +135 -0
- package/src/_b_end_runtime/components/ConversationListPreview.jsx +108 -0
- package/src/_b_end_runtime/components/CustomerServiceWorkspaceFrame.jsx +324 -0
- package/src/_b_end_runtime/components/CustomerServiceWorkspaceFrame.tokens.js +69 -0
- package/src/_b_end_runtime/components/DatePicker.jsx +739 -0
- package/src/_b_end_runtime/components/DatePicker.tokens.js +99 -0
- package/src/_b_end_runtime/components/Empty.jsx +141 -0
- package/src/_b_end_runtime/components/Empty.tokens.js +40 -0
- package/src/_b_end_runtime/components/Form.jsx +609 -0
- package/src/_b_end_runtime/components/Form.tokens.js +77 -0
- package/src/_b_end_runtime/components/FormFieldStack.jsx +123 -0
- package/src/_b_end_runtime/components/FormFieldStack.tokens.js +12 -0
- package/src/_b_end_runtime/components/FormTitle.jsx +119 -0
- package/src/_b_end_runtime/components/FormTitle.tokens.js +87 -0
- package/src/_b_end_runtime/components/FullScreenPage.jsx +97 -0
- package/src/_b_end_runtime/components/FullScreenPage.tokens.js +19 -0
- package/src/_b_end_runtime/components/Icon.jsx +172 -0
- package/src/_b_end_runtime/components/Icon.tokens.js +26 -0
- package/src/_b_end_runtime/components/IconGridPreview.jsx +277 -0
- package/src/_b_end_runtime/components/InfoDisplayPanel.jsx +620 -0
- package/src/_b_end_runtime/components/InfoDisplayPanel.tokens.js +71 -0
- package/src/_b_end_runtime/components/InfoDisplayPanelPreview.jsx +133 -0
- package/src/_b_end_runtime/components/Input.jsx +258 -0
- package/src/_b_end_runtime/components/Input.tokens.js +68 -0
- package/src/_b_end_runtime/components/InputNumber.jsx +242 -0
- package/src/_b_end_runtime/components/InputNumber.tokens.js +55 -0
- package/src/_b_end_runtime/components/Modal.jsx +155 -0
- package/src/_b_end_runtime/components/Modal.tokens.js +73 -0
- package/src/_b_end_runtime/components/NavBar.jsx +842 -0
- package/src/_b_end_runtime/components/NavBar.tokens.js +97 -0
- package/src/_b_end_runtime/components/NavBarPreview.jsx +11 -0
- package/src/_b_end_runtime/components/Radio.jsx +227 -0
- package/src/_b_end_runtime/components/Radio.tokens.js +59 -0
- package/src/_b_end_runtime/components/Select.jsx +766 -0
- package/src/_b_end_runtime/components/Select.tokens.js +99 -0
- package/src/_b_end_runtime/components/Sheet.jsx +132 -0
- package/src/_b_end_runtime/components/Sheet.tokens.js +61 -0
- package/src/_b_end_runtime/components/Slider.jsx +346 -0
- package/src/_b_end_runtime/components/Slider.tokens.js +47 -0
- package/src/_b_end_runtime/components/Switch.jsx +124 -0
- package/src/_b_end_runtime/components/Switch.tokens.js +38 -0
- package/src/_b_end_runtime/components/Table.jsx +1338 -0
- package/src/_b_end_runtime/components/Table.tokens.js +147 -0
- package/src/_b_end_runtime/components/TablePreview.jsx +599 -0
- package/src/_b_end_runtime/components/Tabs.jsx +149 -0
- package/src/_b_end_runtime/components/Tabs.tokens.js +102 -0
- package/src/_b_end_runtime/components/Tag.jsx +199 -0
- package/src/_b_end_runtime/components/Tag.tokens.js +171 -0
- package/src/_b_end_runtime/components/TagBar.jsx +1134 -0
- package/src/_b_end_runtime/components/TagBar.tokens.js +75 -0
- package/src/_b_end_runtime/components/TagGridPreview.jsx +23 -0
- package/src/_b_end_runtime/components/TagInput.jsx +382 -0
- package/src/_b_end_runtime/components/TagInput.tokens.js +52 -0
- package/src/_b_end_runtime/components/TextArea.jsx +363 -0
- package/src/_b_end_runtime/components/TextArea.tokens.js +65 -0
- package/src/_b_end_runtime/components/TimePicker.jsx +444 -0
- package/src/_b_end_runtime/components/TimePicker.tokens.js +77 -0
- package/src/_b_end_runtime/components/Toast.jsx +120 -0
- package/src/_b_end_runtime/components/Toast.tokens.js +146 -0
- package/src/_b_end_runtime/components/Tooltip.jsx +282 -0
- package/src/_b_end_runtime/components/Tooltip.tokens.js +48 -0
- package/src/_b_end_runtime/components/TooltipPreview.jsx +50 -0
- package/src/_b_end_runtime/components/Upload.jsx +455 -0
- package/src/_b_end_runtime/components/Upload.tokens.js +47 -0
- package/src/_b_end_runtime/components/avatar-assets/avatar-default.png +0 -0
- package/src/_b_end_runtime/components/avatar-group-assets/avatar-group-1.png +0 -0
- package/src/_b_end_runtime/components/avatar-group-assets/avatar-group-2.png +0 -0
- package/src/_b_end_runtime/components/avatar-group-assets/avatar-group-3.png +0 -0
- package/src/_b_end_runtime/components/avatar-group-assets/avatar-group-4.png +0 -0
- package/src/_b_end_runtime/components/avatar-group-assets/avatar-group-5.png +0 -0
- package/src/_b_end_runtime/components/empty-assets/administrator-1.svg +40 -0
- package/src/_b_end_runtime/components/empty-assets/administrator-2.svg +33 -0
- package/src/_b_end_runtime/components/empty-assets/construction.svg +33 -0
- package/src/_b_end_runtime/components/empty-assets/failure.svg +49 -0
- package/src/_b_end_runtime/components/empty-assets/idle.svg +34 -0
- package/src/_b_end_runtime/components/empty-assets/no-access.svg +36 -0
- package/src/_b_end_runtime/components/empty-assets/no-content.svg +77 -0
- package/src/_b_end_runtime/components/empty-assets/no-result.svg +61 -0
- package/src/_b_end_runtime/components/empty-assets/not-found.svg +46 -0
- package/src/_b_end_runtime/components/empty-assets/success.svg +38 -0
- package/src/_b_end_runtime/components/file-type-assets/batch-report.png +0 -0
- package/src/_b_end_runtime/components/file-type-assets/catcat.svg +21 -0
- package/src/_b_end_runtime/components/file-type-assets/code.png +0 -0
- package/src/_b_end_runtime/components/file-type-assets/conversation.png +0 -0
- package/src/_b_end_runtime/components/file-type-assets/document.png +0 -0
- package/src/_b_end_runtime/components/file-type-assets/feishu-card.png +0 -0
- package/src/_b_end_runtime/components/file-type-assets/feishu-sheet.png +0 -0
- package/src/_b_end_runtime/components/file-type-assets/feishu.png +0 -0
- package/src/_b_end_runtime/components/file-type-assets/image.png +0 -0
- package/src/_b_end_runtime/components/file-type-assets/index.js +105 -0
- package/src/_b_end_runtime/components/file-type-assets/knowledge.png +0 -0
- package/src/_b_end_runtime/components/file-type-assets/pdf.png +0 -0
- package/src/_b_end_runtime/components/file-type-assets/pe.png +0 -0
- package/src/_b_end_runtime/components/file-type-assets/strategy.png +0 -0
- package/src/_b_end_runtime/components/file-type-assets/table.png +0 -0
- package/src/_b_end_runtime/components/file-type-assets/webpage.png +0 -0
- package/src/_b_end_runtime/components/file-type-assets/xmind.png +0 -0
- package/src/_b_end_runtime/components/icons/icon-data.js +12496 -0
- package/src/_b_end_runtime/components/nav-bar-assets/bytehi-logo-mark.svg +21 -0
- package/src/_b_end_runtime/components/table-assets/avatar.png +0 -0
- package/src/_b_end_runtime/components/table-assets/button.png +0 -0
- package/src/_b_end_runtime/components/table-assets/icon-chevron-down.png +0 -0
- package/src/_b_end_runtime/components/table-cell-assets/avatar.png +0 -0
- package/src/_b_end_runtime/components/table-cell-assets/button.png +0 -0
- package/src/_b_end_runtime/components/table-cell-assets/checkbox.png +0 -0
- package/src/_b_end_runtime/components/table-cell-assets/icon-chevron-right.png +0 -0
- package/src/_b_end_runtime/components/table-cell-assets/icon.png +0 -0
- package/src/_b_end_runtime/components/table-cell-assets/semi-icons-handle.png +0 -0
- package/src/_b_end_runtime/components/table-cell-assets/semi-icons-tree-triangle-right.png +0 -0
- package/src/_b_end_runtime/components/table-cell-assets/switch.png +0 -0
- package/src/_b_end_runtime/components/tagShared.js +3 -0
- package/src/_b_end_runtime/components/team-avatar-assets/chengcheng-murphy.png +0 -0
- package/src/_b_end_runtime/components/team-avatar-assets/duan-ran.png +0 -0
- package/src/_b_end_runtime/components/team-avatar-assets/guo-zhezhi.png +0 -0
- package/src/_b_end_runtime/components/team-avatar-assets/li-siru.png +0 -0
- package/src/_b_end_runtime/components/team-avatar-assets/liu-delin.png +0 -0
- package/src/_b_end_runtime/components.js +3499 -0
- package/src/_b_end_runtime/index.js +9 -0
- package/src/_b_end_runtime/page-patterns/BasePageFramePattern.jsx +395 -0
- package/src/_b_end_runtime/page-patterns/ChatConversationPattern.jsx +989 -0
- package/src/_b_end_runtime/page-patterns/ChatHomePagePattern.jsx +281 -0
- package/src/_b_end_runtime/page-patterns/CopilotPagePattern.jsx +380 -0
- package/src/_b_end_runtime/page-patterns/CustomerServiceWorkspaceFramePattern.jsx +392 -0
- package/src/_b_end_runtime/page-patterns/IMConversationPattern.jsx +590 -0
- package/src/_b_end_runtime/page-patterns/McpManagementPage.jsx +237 -0
- package/src/_b_end_runtime/page-patterns/StrategyListPage.jsx +189 -0
- package/src/_b_end_runtime/page-patterns/TabTopBarListPage.jsx +594 -0
- package/src/_b_end_runtime/page-patterns/VariableManagementPage.jsx +87 -0
- package/src/_b_end_runtime/page-patterns/pageListShared.jsx +177 -0
- package/src/_b_end_runtime/patterns.js +428 -0
- package/src/_b_end_runtime/preview-registry.jsx +4719 -0
- package/src/_b_end_runtime/teamMembers.js +56 -0
- package/src/_b_end_runtime/tokens.js +500 -0
- package/src/index.d.ts +1073 -0
- package/src/index.js +52 -0
- package/theme.css +350 -0
|
@@ -0,0 +1,1399 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChatInput — B端设计系统 · 业务组件 (Tailwind 内联)
|
|
3
|
+
*
|
|
4
|
+
* AI 对话输入框主体,三层结构:底层氛围背景 → 输入区域 → 猫条。
|
|
5
|
+
* 内置默认工具栏(@Agent 36px Select 风 + @/指令/附件 36×36 图标钮)。
|
|
6
|
+
* 内置三态发送按钮:disabled / active / running。
|
|
7
|
+
* 所有图标统一走 b-end Icon 组件,颜色对齐 token。
|
|
8
|
+
* variant 决定初始视图,组件内部自管理交互流转。
|
|
9
|
+
*
|
|
10
|
+
* 变体(6 个,按场景归类):
|
|
11
|
+
* default-sm — 收起态,点击展开为 default
|
|
12
|
+
* default — 可编辑态,输入文字 → 发送 → 内部进入 replying
|
|
13
|
+
* im-basic — 纯 IM 基础输入框:无猫条与 AI 语境;四周留白一致;左下按钮支持声明式自定义;默认「图片 / 表情」两钮;发送后不进入 replying(不受 autoReplyOnSend 影响)
|
|
14
|
+
* replying — AI 正在回复(短任务):状态文案 + 36×36 停止按钮
|
|
15
|
+
* busy — AI 忙碌:执行中 / 排队中。渐变背景 + 状态文案 + 36×36 停止按钮
|
|
16
|
+
* readonly — 无编辑权限:任务分享只读 / 抢单引导。白底 + 状态文案 + 36px 文字按钮
|
|
17
|
+
*
|
|
18
|
+
* 内置交互(default 态):
|
|
19
|
+
* - Agent 按钮:点击弹下拉,单选切换当前 Agent
|
|
20
|
+
* - @ 按钮 :点击弹下拉,添加 Mention chip(可重复添加,X 删除)
|
|
21
|
+
* - / 按钮 :点击弹下拉,添加 Command chip(可重复添加,X 删除)
|
|
22
|
+
* - 附件按钮:唤起本地文件选择器,多选;选中后以文件卡形式展示,X 删除
|
|
23
|
+
* - 已选 Mention/Command 以浅青色 chip 行显示在 textarea 上方
|
|
24
|
+
* - 已选附件以 180px 文件卡(多列折行)显示在 chip 行上方
|
|
25
|
+
* - 列表数据通过 props 注入(agentOptions/mentionOptions/commandOptions),不传则用内置示例数据
|
|
26
|
+
*
|
|
27
|
+
* Props:
|
|
28
|
+
* variant — 'default-sm'|'default'|'im-basic'|'replying'|'busy'|'readonly'(受控,外部切换 variant 会同步内部 view 并清空编辑器;im-basic 内部固定为可编辑 default 视图)
|
|
29
|
+
* agentName — @Agent 按钮初始文字,默认 'Auto'
|
|
30
|
+
* agentOptions — Agent 选项,[{ id, label, desc? }]
|
|
31
|
+
* mentionOptions — @ 选项,[{ id, label, icon? }]
|
|
32
|
+
* commandOptions — / 选项,[{ id, label, icon? }]
|
|
33
|
+
* acceptFiles — 文件选择器 accept 属性,默认 '*'
|
|
34
|
+
* multipleFiles — 是否允许多选,默认 true
|
|
35
|
+
* catBarText — 猫条提示文字(不传则用各 variant 的默认文案)
|
|
36
|
+
* catBarIcon — ReactNode(猫条左侧图标插槽,不传则显示内置猫咪头像)
|
|
37
|
+
* placeholder — 输入占位文字,默认 '需要我为你做什么'
|
|
38
|
+
* statusText — replying/busy/readonly 三态下的提示说明文字
|
|
39
|
+
* actionText — readonly 态按钮文案(默认"继续对话")
|
|
40
|
+
* onSend — 发送回调 (text, ctx) => void;ctx = { agent, segments, mentions, commands, attachments }
|
|
41
|
+
* · segments:按 DOM 顺序排好的 token 列表(text + entity 混排),可直接对接 ChatMessage.userContent
|
|
42
|
+
* · text:纯文本拼接(chip label 也算进去),兼容旧调用方
|
|
43
|
+
* onStop — replying/busy 停止回调
|
|
44
|
+
* onAction — readonly 操作按钮回调
|
|
45
|
+
* onAgentChange — Agent 切换回调 (agent) => void
|
|
46
|
+
* onMentionsChange/ onCommandsChange / onAttachmentsChange — 选中态变化回调
|
|
47
|
+
* toolbar — ReactNode(自定义工具栏,传入则覆盖内置默认工具栏,附带浮层/文件选择器也将一并禁用)
|
|
48
|
+
* children — ReactNode(覆盖内置 textarea)
|
|
49
|
+
* className — 额外类名
|
|
50
|
+
* style — 内联样式
|
|
51
|
+
* autoReplyOnSend — 默认 true。true 时发送后内部自动 setView('replying')(im-basic 恒不进入 replying);外部完全受控状态机时传 false 让外部 variant 决定
|
|
52
|
+
* imActions — 仅 im-basic 生效。声明式左下角按钮数组,单项形如 { id, icon, ariaLabel, onClick? };
|
|
53
|
+
* 默认 [{ id:'image', icon:'image-01-stroked', ariaLabel:'图片' }, { id:'emoji', icon:'face-smile-stroked', ariaLabel:'表情' }]
|
|
54
|
+
* 最多展示 5 个;传 [] 可隐藏左下角按钮区
|
|
55
|
+
* onImImageClick — 可选。variant=im-basic 时覆盖左下「图片」钮默认行为(默认等同附件:触发隐藏 file input)
|
|
56
|
+
* onImEmojiClick — 可选。variant=im-basic 时点击「表情」钮回调(未传则无操作,由业务接表情面板)
|
|
57
|
+
* prefillText — 外部回填到编辑器的文本(搭配 prefillSeed 使用)
|
|
58
|
+
* prefillSeed — 数字 seed,每次变化时把 prefillText 覆盖塞进编辑器并聚焦(用于推荐 chip / 追问 chip 回填草稿)
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
import { useState, useRef, useCallback, useEffect } from 'react';
|
|
62
|
+
import { createRoot } from 'react-dom/client';
|
|
63
|
+
import Button from './Button';
|
|
64
|
+
import Icon from './Icon';
|
|
65
|
+
/* 文件类型图标统一从 ./file-type-assets 共享 lookup 取,
|
|
66
|
+
* 与 ChatMessage(用户消息附件 / 产物卡)保持视觉一致 */
|
|
67
|
+
import { getFileTypeIcon } from './file-type-assets';
|
|
68
|
+
|
|
69
|
+
/* ── 注入猫头动画(全局幂等,仅首次挂载写入) ── */
|
|
70
|
+
function injectCatAnim() {
|
|
71
|
+
const id = 'chatinput-cat-anim-kf';
|
|
72
|
+
if (typeof document === 'undefined' || document.getElementById(id)) return;
|
|
73
|
+
const s = document.createElement('style');
|
|
74
|
+
s.id = id;
|
|
75
|
+
s.textContent = `
|
|
76
|
+
@keyframes cat-blink {
|
|
77
|
+
0%, 90%, 100% { transform: scaleY(0); }
|
|
78
|
+
94% { transform: scaleY(1); }
|
|
79
|
+
98% { transform: scaleY(0); }
|
|
80
|
+
}
|
|
81
|
+
.cat-eyelid {
|
|
82
|
+
transform: scaleY(0);
|
|
83
|
+
animation: cat-blink 3.6s ease-in-out infinite;
|
|
84
|
+
}
|
|
85
|
+
.cat-eyelid--right { transform-origin: 17.5px 8.6px; }
|
|
86
|
+
.cat-eyelid--left { transform-origin: 7.5px 8.6px; }
|
|
87
|
+
`;
|
|
88
|
+
document.head.appendChild(s);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/* ── 外层容器 ── */
|
|
92
|
+
const WRAPPER = [
|
|
93
|
+
'tfds-chat-input',
|
|
94
|
+
'flex flex-col isolate items-start justify-end',
|
|
95
|
+
'relative w-full pt-2 px-2',
|
|
96
|
+
].join(' ');
|
|
97
|
+
|
|
98
|
+
/* im-basic:无猫条,外层四边留白一致(与默认输入白卡视觉对齐) */
|
|
99
|
+
const WRAPPER_IM_BASIC = [
|
|
100
|
+
'tfds-chat-input',
|
|
101
|
+
'flex flex-col isolate items-stretch justify-end',
|
|
102
|
+
'relative w-full p-2',
|
|
103
|
+
].join(' ');
|
|
104
|
+
|
|
105
|
+
/* ── 底层氛围背景 ── */
|
|
106
|
+
const BACKDROP = [
|
|
107
|
+
'absolute inset-0 -bottom-2 rounded-[20px] z-[1]',
|
|
108
|
+
'bg-[rgba(80,99,168,0.05)] border border-white',
|
|
109
|
+
].join(' ');
|
|
110
|
+
|
|
111
|
+
/* im-basic:无猫条时底层背景不再向下外扩,避免上下不对称 */
|
|
112
|
+
const BACKDROP_SYMMETRIC = [
|
|
113
|
+
'absolute inset-0 rounded-[20px] z-[1]',
|
|
114
|
+
'bg-[rgba(80,99,168,0.05)] border border-white',
|
|
115
|
+
].join(' ');
|
|
116
|
+
|
|
117
|
+
/* ── 猫条 ── */
|
|
118
|
+
const CAT_BAR = [
|
|
119
|
+
'flex items-start gap-5 shrink-0 w-full',
|
|
120
|
+
'h-[36px] pt-1 px-4',
|
|
121
|
+
'relative z-[3]',
|
|
122
|
+
].join(' ');
|
|
123
|
+
|
|
124
|
+
const CAT_BAR_TEXT = [
|
|
125
|
+
'flex flex-1 min-w-0 h-6 items-center',
|
|
126
|
+
'text-xs leading-4 font-normal text-foreground-secondary',
|
|
127
|
+
].join(' ');
|
|
128
|
+
|
|
129
|
+
/* ── 输入区域(默认态多行 py-14;其它单行态 py-12,让纵向更紧凑)
|
|
130
|
+
* 不加 overflow-hidden:让 Agent/@/指令 浮层可以从工具栏向上延伸到输入区外部
|
|
131
|
+
* z 高于猫条(z-[3]):弹窗向上弹时不会被猫条遮挡
|
|
132
|
+
*/
|
|
133
|
+
const INPUT_AREA_BASE = [
|
|
134
|
+
'relative shrink-0 w-full z-[4]',
|
|
135
|
+
'rounded-xl bg-surface',
|
|
136
|
+
'px-[14px]',
|
|
137
|
+
].join(' ');
|
|
138
|
+
const INPUT_PAD_LG = 'py-[14px]';
|
|
139
|
+
const INPUT_PAD_SM = 'py-[12px]';
|
|
140
|
+
|
|
141
|
+
/* 青紫光晕(共用阴影值)
|
|
142
|
+
* ⚠️ Tailwind v4 是纯字符串扫描器,类名必须以**完整字面量**出现在源码里才能生成 CSS。
|
|
143
|
+
* 焦点态光晕不能写成 `focus-within:${GLOW_SHADOW}` 拼接 —— 那样扫描器看不到 `focus-within:shadow-[...]`。
|
|
144
|
+
* 所以下面把焦点变体写成独立字面量字符串。两条阴影值需保持同步。
|
|
145
|
+
*/
|
|
146
|
+
const GLOW_SHADOW = 'shadow-[-12px_20px_30px_-27px_rgba(46,202,180,0.8),12px_20px_30px_-27px_rgba(184,46,202,0.8)]';
|
|
147
|
+
|
|
148
|
+
/* 焦点光晕:default 态 focus-within 时出现 */
|
|
149
|
+
const FOCUS_SHADOW = 'transition-shadow duration-200 focus-within:shadow-[-12px_20px_30px_-27px_rgba(46,202,180,0.8),12px_20px_30px_-27px_rgba(184,46,202,0.8)]';
|
|
150
|
+
|
|
151
|
+
/* 忙碌态:透明 1px 边 + 双层 background-clip = 淡彩底 + 柔彩描边(贴合圆角) */
|
|
152
|
+
const BUSY_BORDER = 'border border-transparent';
|
|
153
|
+
const BUSY_BG = [
|
|
154
|
+
'var(--gradient-ai-fill-1) padding-box',
|
|
155
|
+
'linear-gradient(#fff, #fff) padding-box',
|
|
156
|
+
'linear-gradient(67deg, #C5F6F3 0%, #C6DEF6 37%, #D5D2FD 70%, #F3DAF9 100%) border-box',
|
|
157
|
+
].join(', ');
|
|
158
|
+
|
|
159
|
+
/* ── 工具栏图标按钮(@ / 指令 / 附件 共用) ── */
|
|
160
|
+
const TOOL_ICON_BTN = [
|
|
161
|
+
'inline-flex items-center justify-center shrink-0',
|
|
162
|
+
'w-[36px] h-[36px] rounded-lg border border-border-default',
|
|
163
|
+
'bg-surface text-foreground-muted',
|
|
164
|
+
'cursor-pointer select-none transition-colors duration-150',
|
|
165
|
+
'hover:bg-blueGrey-50 hover:border-border-strong active:bg-blueGrey-100',
|
|
166
|
+
'focus-visible:[outline:2px_solid] focus-visible:outline-brand-200 focus-visible:outline-offset-2',
|
|
167
|
+
].join(' ');
|
|
168
|
+
|
|
169
|
+
/* ── Agent 选择器(高度对齐 Select md,圆角对齐工具栏 IconButton)
|
|
170
|
+
* AGENT_BTN → 默认态(Auto / 第一项):白底 + 灰边 + 灰图标
|
|
171
|
+
* AGENT_BTN_ACTIVE → 选中具体模型:浅青底 + 浅青边 + brand-500 图标与文字
|
|
172
|
+
*/
|
|
173
|
+
const AGENT_BTN = [
|
|
174
|
+
'inline-flex items-center shrink-0',
|
|
175
|
+
'h-[36px] gap-2 px-3 rounded-lg border border-border-default',
|
|
176
|
+
'bg-surface text-sm text-foreground',
|
|
177
|
+
'cursor-pointer select-none transition-colors duration-150',
|
|
178
|
+
'hover:border-border-strong',
|
|
179
|
+
'focus-visible:[outline:2px_solid] focus-visible:outline-brand-200 focus-visible:outline-offset-2',
|
|
180
|
+
].join(' ');
|
|
181
|
+
|
|
182
|
+
const AGENT_BTN_ACTIVE = [
|
|
183
|
+
'inline-flex items-center shrink-0',
|
|
184
|
+
'h-[36px] gap-2 px-3 rounded-lg border border-brand-100',
|
|
185
|
+
'bg-brand-50 text-sm text-brand-500',
|
|
186
|
+
'cursor-pointer select-none transition-colors duration-150',
|
|
187
|
+
'hover:border-brand-200 hover:bg-brand-100',
|
|
188
|
+
'focus-visible:[outline:2px_solid] focus-visible:outline-brand-200 focus-visible:outline-offset-2',
|
|
189
|
+
].join(' ');
|
|
190
|
+
|
|
191
|
+
/* ── 发送按钮(三态:disabled / active / running) ──
|
|
192
|
+
* disabled → 视觉对齐工具栏 IconButton(白底、border、muted 图标)
|
|
193
|
+
* active → 视觉对齐 Button primary(黑底胶囊 + 文字 + 标准 hover/active)
|
|
194
|
+
* running → 视觉对齐 Button primary(黑底方钮 + 停止图标)
|
|
195
|
+
*/
|
|
196
|
+
const SEND_BTN_BASE = [
|
|
197
|
+
'inline-flex items-center justify-center shrink-0',
|
|
198
|
+
'h-[36px] rounded-lg border cursor-pointer select-none',
|
|
199
|
+
'transition-all duration-150',
|
|
200
|
+
'[outline:2px_solid_transparent] outline-offset-2',
|
|
201
|
+
].join(' ');
|
|
202
|
+
|
|
203
|
+
/* 无内容:与工具栏 IconButton 同款(白底、border、muted 图标),不可点击 */
|
|
204
|
+
const SEND_DISABLED = [
|
|
205
|
+
'w-[36px] bg-surface border-border-default',
|
|
206
|
+
'text-foreground-muted',
|
|
207
|
+
'cursor-not-allowed opacity-60',
|
|
208
|
+
].join(' ');
|
|
209
|
+
|
|
210
|
+
/* 有内容:primary 胶囊 + 文字 */
|
|
211
|
+
const SEND_ACTIVE = [
|
|
212
|
+
'gap-1.5 px-3 bg-grey-950 text-white border-transparent',
|
|
213
|
+
'hover:bg-grey-900',
|
|
214
|
+
'active:bg-grey-800 active:scale-[0.97]',
|
|
215
|
+
'focus-visible:outline-grey-400',
|
|
216
|
+
].join(' ');
|
|
217
|
+
|
|
218
|
+
/* 运行中:primary 方钮 + 停止图标 */
|
|
219
|
+
const SEND_RUNNING = [
|
|
220
|
+
'w-[36px] bg-grey-950 text-white border-transparent',
|
|
221
|
+
'hover:bg-grey-900',
|
|
222
|
+
'active:bg-grey-800 active:scale-[0.97]',
|
|
223
|
+
'focus-visible:outline-grey-400',
|
|
224
|
+
].join(' ');
|
|
225
|
+
|
|
226
|
+
/* ── 收起单行布局 ── */
|
|
227
|
+
const COLLAPSED = 'flex items-center gap-3';
|
|
228
|
+
|
|
229
|
+
/* ── 展开多行布局 ── */
|
|
230
|
+
const EXPANDED = 'flex flex-col items-start gap-3';
|
|
231
|
+
|
|
232
|
+
/* ── 操作行 ── */
|
|
233
|
+
const TOOLBAR_ROW = 'flex items-center gap-3 w-full';
|
|
234
|
+
|
|
235
|
+
/* ── 占位文字 ── */
|
|
236
|
+
const PLACEHOLDER_CLS = [
|
|
237
|
+
'flex-1 min-w-0 m-0 px-[2px]',
|
|
238
|
+
'text-sm leading-[26px] font-normal',
|
|
239
|
+
'text-foreground-disabled select-none',
|
|
240
|
+
].join(' ');
|
|
241
|
+
|
|
242
|
+
/* ── 富文本编辑区(contentEditable):支持 chip 与文字混排
|
|
243
|
+
* - text-sm 14px
|
|
244
|
+
* - leading-[30px] = 26 文字行高 + 4 行间距,与 chip h-26 配合得到行间距 4px
|
|
245
|
+
* - max-h-[300px] + overflow-y-auto:超长文本内部滚动
|
|
246
|
+
* - whitespace-pre-wrap:保留换行 + 自动折行
|
|
247
|
+
*/
|
|
248
|
+
const EDITOR_CLS = [
|
|
249
|
+
'w-full outline-none border-0 bg-transparent',
|
|
250
|
+
'text-sm leading-[30px] font-normal text-foreground',
|
|
251
|
+
'min-h-[26px] max-h-[300px] overflow-y-auto',
|
|
252
|
+
'px-[2px] [font-family:inherit] whitespace-pre-wrap break-words',
|
|
253
|
+
].join(' ');
|
|
254
|
+
|
|
255
|
+
/* 占位符:编辑器为空时悬浮显示,不参与点击 */
|
|
256
|
+
const PLACEHOLDER_OVERLAY = [
|
|
257
|
+
'absolute left-[2px] top-0 pointer-events-none select-none',
|
|
258
|
+
'text-sm leading-[30px] font-normal text-foreground-disabled',
|
|
259
|
+
].join(' ');
|
|
260
|
+
|
|
261
|
+
/* ── 单行文本插槽(回复中等态使用,高度与 default 编辑器空态对齐:30px = leading-[30px]) ── */
|
|
262
|
+
const SINGLE_LINE_SLOT = 'w-full min-h-[30px] flex items-center px-[2px]';
|
|
263
|
+
|
|
264
|
+
/* ── 状态提示文字 ── */
|
|
265
|
+
const STATUS_TEXT_CLS = [
|
|
266
|
+
'flex-1 min-w-0 m-0',
|
|
267
|
+
'text-sm leading-6 font-normal text-foreground',
|
|
268
|
+
].join(' ');
|
|
269
|
+
|
|
270
|
+
/* ── 内置示例数据:用户未传 props 时使用,便于演示 ── */
|
|
271
|
+
const DEFAULT_AGENT_OPTIONS = [
|
|
272
|
+
{ id: 'auto', label: 'Auto', desc: '自动选择最合适的模型' },
|
|
273
|
+
{ id: 'gpt-4o', label: 'GPT-4o', desc: '通用强项,速度快' },
|
|
274
|
+
{ id: 'claude-sonnet', label: 'Claude Sonnet 4', desc: '推理与代码能力强' },
|
|
275
|
+
{ id: 'gemini-2-5', label: 'Gemini 2.5', desc: '长上下文场景' },
|
|
276
|
+
{ id: 'doubao', label: '豆包 Pro', desc: '中文场景优化' },
|
|
277
|
+
];
|
|
278
|
+
|
|
279
|
+
const DEFAULT_MENTION_OPTIONS = [
|
|
280
|
+
{ id: 'm1', label: '节点:如何取消实名认证Agent', icon: 'git-branch-01-stroked' },
|
|
281
|
+
{ id: 'm2', label: '节点:意图分流', icon: 'git-branch-01-stroked' },
|
|
282
|
+
{ id: 'm3', label: '知识:社交-私信', icon: 'book-open-01-stroked' },
|
|
283
|
+
{ id: 'm4', label: '会话:社交-私信冲人工 v8', icon: 'message-chat-square-stroked' },
|
|
284
|
+
{ id: 'm5', label: '报告:近 7 天指标异常分析', icon: 'file-01-stroked' },
|
|
285
|
+
];
|
|
286
|
+
|
|
287
|
+
const DEFAULT_COMMAND_OPTIONS = [
|
|
288
|
+
{ id: 'c1', label: '选择服务标签', icon: 'tag-03-stroked' },
|
|
289
|
+
{ id: 'c2', label: '输入需求', icon: 'brackets-check-stroked' },
|
|
290
|
+
{ id: 'c3', label: '总结对话', icon: 'ai-divider' },
|
|
291
|
+
{ id: 'c4', label: '生成报告', icon: 'file-01-stroked' },
|
|
292
|
+
];
|
|
293
|
+
|
|
294
|
+
const DEFAULT_IM_ACTIONS = [
|
|
295
|
+
{ id: 'image', icon: 'image-01-stroked', ariaLabel: '图片' },
|
|
296
|
+
{ id: 'emoji', icon: 'face-smile-stroked', ariaLabel: '表情' },
|
|
297
|
+
];
|
|
298
|
+
|
|
299
|
+
/* ── Chip:已选 Mention / Command 的展示样式(参考 Figma:浅青底 + 浅青边)
|
|
300
|
+
* 布局对齐:
|
|
301
|
+
* - align-middle:baseline + x-height/2 居中(浏览器原生算法,与文字几何中心几乎重合)
|
|
302
|
+
* ⚠️ 不使用 position:relative + top 或正向 vertical-align —— 它们会在 contentEditable
|
|
303
|
+
* 中引起 chip 行起始 X 缩进、或 chip 顶部超出 line-box
|
|
304
|
+
* - 不加外部 margin:chip 起始位置严格与纯文本行对齐;chip 与文字间距由 chip 自身 px-2
|
|
305
|
+
* 的 8px 内边距提供视觉呼吸(足够清晰,不会显得粘连)
|
|
306
|
+
* - gap-1:chip 内部 icon-文字-X 间距 4px
|
|
307
|
+
* - leading-[26px]:chip 内文字行高与 chip 同高,内容自然垂直居中
|
|
308
|
+
* - rounded-md:圆角 8px(b-end token: md=8 / lg=12,Figma 要的是 8)
|
|
309
|
+
*/
|
|
310
|
+
const ENTITY_CHIP = [
|
|
311
|
+
'inline-flex items-center gap-1 shrink-0 align-middle',
|
|
312
|
+
'h-[26px] px-2 rounded-md',
|
|
313
|
+
'bg-brand-50 border border-brand-100',
|
|
314
|
+
'text-sm leading-[26px] text-foreground',
|
|
315
|
+
].join(' ');
|
|
316
|
+
|
|
317
|
+
/* ── 附件卡:参考 Figma 180px 文件卡
|
|
318
|
+
* group:让内部按钮根据卡片 hover 切换显示
|
|
319
|
+
* hover 整卡背景加深;同时左侧文件图标变成"删除 X"
|
|
320
|
+
*/
|
|
321
|
+
const ATTACH_CARD = [
|
|
322
|
+
'group inline-flex items-center gap-2 shrink-0',
|
|
323
|
+
'w-[180px] max-w-[180px] p-2 rounded-lg',
|
|
324
|
+
'bg-fill border border-border-default transition-colors duration-150',
|
|
325
|
+
'hover:bg-blueGrey-100',
|
|
326
|
+
].join(' ');
|
|
327
|
+
|
|
328
|
+
/* ── 浮层菜单容器 ── */
|
|
329
|
+
const POPOVER_PANEL = [
|
|
330
|
+
'absolute bottom-full left-0 mb-2 z-50',
|
|
331
|
+
'min-w-[240px] max-w-[320px] py-1',
|
|
332
|
+
'bg-surface border border-border-default rounded-lg',
|
|
333
|
+
'shadow-[0_8px_24px_-4px_rgba(16,24,40,0.12),0_2px_6px_-2px_rgba(16,24,40,0.06)]',
|
|
334
|
+
].join(' ');
|
|
335
|
+
|
|
336
|
+
const POPOVER_ITEM = [
|
|
337
|
+
'flex items-start gap-2 w-full px-3 py-2',
|
|
338
|
+
'text-sm leading-5 text-foreground text-left',
|
|
339
|
+
'cursor-pointer select-none transition-colors duration-100',
|
|
340
|
+
'hover:bg-blueGrey-50',
|
|
341
|
+
].join(' ');
|
|
342
|
+
|
|
343
|
+
/* ── 文件大小格式化 ── */
|
|
344
|
+
function formatFileSize(bytes) {
|
|
345
|
+
if (bytes == null || isNaN(bytes)) return '';
|
|
346
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
347
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
348
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
349
|
+
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/* ── 猫条默认文案映射 ── */
|
|
353
|
+
const DEFAULT_CAT_TEXT = {
|
|
354
|
+
'default-sm': 'Hi~ 我是 AI 小助手,快来试试和我交流吧~',
|
|
355
|
+
'default': 'Hi~ 我是 AI 小助手,快来试试和我交流吧~',
|
|
356
|
+
'replying': '回复中...',
|
|
357
|
+
'busy': '正在查询数据接口「execute_agent/API/interface/Function call Name」',
|
|
358
|
+
'readonly': '🎉 任务已完成~',
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
/* ── 发送按钮:disabled / active / running 三态 ──
|
|
362
|
+
* 统一加 onMouseDown preventDefault:阻止按钮抢焦点,textarea 保持 focus-within,光晕不会闪烁
|
|
363
|
+
*/
|
|
364
|
+
const keepFocus = (e) => e.preventDefault();
|
|
365
|
+
|
|
366
|
+
function SendButton({ mode = 'disabled', onClick, label = '发送', ariaLabel }) {
|
|
367
|
+
if (mode === 'running') {
|
|
368
|
+
return (
|
|
369
|
+
<button
|
|
370
|
+
type="button"
|
|
371
|
+
className={`${SEND_BTN_BASE} ${SEND_RUNNING}`}
|
|
372
|
+
onMouseDown={keepFocus}
|
|
373
|
+
onClick={onClick}
|
|
374
|
+
aria-label={ariaLabel || '停止'}
|
|
375
|
+
data-tfds-component="ChatInput.Send"
|
|
376
|
+
>
|
|
377
|
+
<Icon name="square-stroked" size="sm" color="currentColor" />
|
|
378
|
+
</button>
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
if (mode === 'active') {
|
|
382
|
+
return (
|
|
383
|
+
<button
|
|
384
|
+
type="button"
|
|
385
|
+
className={`${SEND_BTN_BASE} ${SEND_ACTIVE}`}
|
|
386
|
+
onMouseDown={keepFocus}
|
|
387
|
+
onClick={onClick}
|
|
388
|
+
aria-label={ariaLabel || label}
|
|
389
|
+
data-tfds-component="ChatInput.Send"
|
|
390
|
+
>
|
|
391
|
+
<Icon name="send-03-stroked" size="sm" color="currentColor" />
|
|
392
|
+
<span className="text-sm whitespace-nowrap">{label}</span>
|
|
393
|
+
</button>
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
return (
|
|
397
|
+
<button
|
|
398
|
+
type="button"
|
|
399
|
+
className={`${SEND_BTN_BASE} ${SEND_DISABLED}`}
|
|
400
|
+
onMouseDown={keepFocus}
|
|
401
|
+
disabled
|
|
402
|
+
aria-label={ariaLabel || label}
|
|
403
|
+
data-tfds-component="ChatInput.Send"
|
|
404
|
+
>
|
|
405
|
+
<Icon name="send-03-stroked" size="sm" color="currentColor" />
|
|
406
|
+
</button>
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/* ── 猫条头像:始终眨眼,营造活生生的角色感 ──
|
|
411
|
+
* 眨眼原理:
|
|
412
|
+
* - 身体用 evenodd 在眼睛位置挖洞(眼眶透明,露出背景作"眼白")
|
|
413
|
+
* - 在洞内放黑色小圆点作瞳孔
|
|
414
|
+
* - 叠一层与身体同色的椭圆"眼皮",默认 scaleY(0) 不可见
|
|
415
|
+
* - 眨眼时眼皮从顶部展开到 scaleY(1),盖住整个眼眶 = 闭眼
|
|
416
|
+
* - transform-origin 用 SVG 用户坐标系的明确像素值,跨浏览器稳定
|
|
417
|
+
*/
|
|
418
|
+
function CatAvatar() {
|
|
419
|
+
return (
|
|
420
|
+
<div className="shrink-0" aria-hidden="true">
|
|
421
|
+
<svg width="25" height="32" viewBox="0 0 25 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
422
|
+
{/* 瞳孔 */}
|
|
423
|
+
<path d="M17.5 9.8773C16.727 9.8773 16.0999 10.5048 16.0996 11.2777C16.0996 12.0509 16.7268 12.6781 17.5 12.6781C18.2732 12.6781 18.9004 12.0509 18.9004 11.2777C18.9001 10.5048 18.273 9.8773 17.5 9.8773Z" fill="currentColor"/>
|
|
424
|
+
<path d="M7.5 9.8773C6.72699 9.8773 6.09992 10.5048 6.09961 11.2777C6.09961 12.0509 6.7268 12.6781 7.5 12.6781C8.2732 12.6781 8.90039 12.0509 8.90039 11.2777C8.90008 10.5048 8.27301 9.8773 7.5 9.8773Z" fill="currentColor"/>
|
|
425
|
+
{/* 身体(evenodd 在眼睛位置挖洞) */}
|
|
426
|
+
<path fillRule="evenodd" clipRule="evenodd" d="M1.49609 0.133156C0.829529 -0.247634 0.000182045 0.233686 0 1.00132V31.2777H25V1.00132C24.9998 0.233686 24.1705 -0.247634 23.5039 0.133156L18 3.27769H7L1.49609 0.133156ZM17.5 8.67808C18.9357 8.67808 20.0993 9.84201 20.0996 11.2777C20.0996 12.7136 18.9359 13.8773 17.5 13.8773C16.0641 13.8773 14.9004 12.7136 14.9004 11.2777C14.9007 9.84201 16.0643 8.67808 17.5 8.67808ZM7.5 8.67808C8.93575 8.67808 10.0993 9.84201 10.0996 11.2777C10.0996 12.7136 8.93594 13.8773 7.5 13.8773C6.06406 13.8773 4.90039 12.7136 4.90039 11.2777C4.9007 9.84201 6.06425 8.67808 7.5 8.67808Z" fill="currentColor"/>
|
|
427
|
+
{/* 眼皮:始终循环眨眼 */}
|
|
428
|
+
<ellipse cx="17.5" cy="11.277" rx="2.65" ry="2.65" fill="currentColor" className="cat-eyelid cat-eyelid--right"/>
|
|
429
|
+
<ellipse cx="7.5" cy="11.277" rx="2.65" ry="2.65" fill="currentColor" className="cat-eyelid cat-eyelid--left"/>
|
|
430
|
+
</svg>
|
|
431
|
+
</div>
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/* ── 浮层菜单:相对父级定位,向上弹出 ── */
|
|
436
|
+
function PopoverMenu({ open, items = [], selectedId, onPick, emptyText = '暂无可选项' }) {
|
|
437
|
+
if (!open) return null;
|
|
438
|
+
return (
|
|
439
|
+
<div className={POPOVER_PANEL} role="menu">
|
|
440
|
+
{items.length === 0 && (
|
|
441
|
+
<div className="px-3 py-2 text-sm text-foreground-muted">{emptyText}</div>
|
|
442
|
+
)}
|
|
443
|
+
{items.map((it) => {
|
|
444
|
+
const active = selectedId === it.id;
|
|
445
|
+
return (
|
|
446
|
+
<button
|
|
447
|
+
key={it.id}
|
|
448
|
+
type="button"
|
|
449
|
+
role="menuitem"
|
|
450
|
+
className={POPOVER_ITEM}
|
|
451
|
+
onMouseDown={keepFocus}
|
|
452
|
+
onClick={() => onPick?.(it)}
|
|
453
|
+
>
|
|
454
|
+
{it.icon && (
|
|
455
|
+
<span className="text-foreground-muted shrink-0 flex mt-[2px]">
|
|
456
|
+
<Icon name={it.icon} size="sm" color="currentColor" />
|
|
457
|
+
</span>
|
|
458
|
+
)}
|
|
459
|
+
<span className="flex-1 min-w-0">
|
|
460
|
+
<span className={`block truncate ${active ? 'text-brand-500 font-medium' : ''}`}>
|
|
461
|
+
{it.label}
|
|
462
|
+
</span>
|
|
463
|
+
{it.desc && (
|
|
464
|
+
<span className="block text-xs leading-4 text-foreground-muted truncate">{it.desc}</span>
|
|
465
|
+
)}
|
|
466
|
+
</span>
|
|
467
|
+
{active && (
|
|
468
|
+
<span className="text-brand-500 shrink-0 flex mt-[2px]">
|
|
469
|
+
<Icon name="check-stroked" size="sm" color="currentColor" />
|
|
470
|
+
</span>
|
|
471
|
+
)}
|
|
472
|
+
</button>
|
|
473
|
+
);
|
|
474
|
+
})}
|
|
475
|
+
</div>
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/* ── 已选实体 chip:浅青底,带图标 + 标签 + 关闭 X ── */
|
|
480
|
+
function EntityChip({ icon, label, onRemove }) {
|
|
481
|
+
return (
|
|
482
|
+
<span className={ENTITY_CHIP}>
|
|
483
|
+
{icon && (
|
|
484
|
+
<span className="shrink-0 flex text-brand-500">
|
|
485
|
+
<Icon name={icon} size={14} color="currentColor" />
|
|
486
|
+
</span>
|
|
487
|
+
)}
|
|
488
|
+
<span className="truncate max-w-[220px]">{label}</span>
|
|
489
|
+
<button
|
|
490
|
+
type="button"
|
|
491
|
+
className="shrink-0 inline-flex items-center justify-center text-foreground-muted"
|
|
492
|
+
onMouseDown={keepFocus}
|
|
493
|
+
onClick={onRemove}
|
|
494
|
+
aria-label="移除"
|
|
495
|
+
>
|
|
496
|
+
<Icon name="x-close-stroked" size={14} color="currentColor" />
|
|
497
|
+
</button>
|
|
498
|
+
</span>
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/* ── 附件卡:参考 Figma,180px,左图右文
|
|
503
|
+
* 默认:左侧显示文件类型图标(彩色)
|
|
504
|
+
* hover:左侧整体切换为"删除 X"按钮(灰底方框 + X icon),右侧不再放独立 X
|
|
505
|
+
* 彩色文件类型图标当前用 PDF 兜底,未来扩展见 FILE_TYPE_ICONS
|
|
506
|
+
*/
|
|
507
|
+
function AttachmentCard({ file, onRemove }) {
|
|
508
|
+
const typeIcon = getFileTypeIcon(file.name);
|
|
509
|
+
return (
|
|
510
|
+
<span className={ATTACH_CARD}>
|
|
511
|
+
<button
|
|
512
|
+
type="button"
|
|
513
|
+
className="shrink-0 relative w-8 h-[34px] inline-flex items-center justify-center cursor-pointer"
|
|
514
|
+
onMouseDown={keepFocus}
|
|
515
|
+
onClick={onRemove}
|
|
516
|
+
aria-label="移除附件"
|
|
517
|
+
>
|
|
518
|
+
{/* 默认态:彩色文件图标 */}
|
|
519
|
+
<img
|
|
520
|
+
src={typeIcon}
|
|
521
|
+
alt=""
|
|
522
|
+
className="block w-8 h-[34px] object-contain group-hover:hidden"
|
|
523
|
+
/>
|
|
524
|
+
{/* hover 态:灰底圆角 + X 图标,整块就是删除按钮 */}
|
|
525
|
+
<span className="hidden group-hover:inline-flex items-center justify-center w-8 h-8 rounded-md bg-blueGrey-200 text-foreground-secondary">
|
|
526
|
+
<Icon name="x-close-stroked" size="md" color="currentColor" />
|
|
527
|
+
</span>
|
|
528
|
+
</button>
|
|
529
|
+
<span className="flex-1 min-w-0 flex flex-col gap-0.5">
|
|
530
|
+
<span className="text-xs leading-4 text-foreground truncate" title={file.name}>{file.name}</span>
|
|
531
|
+
<span className="text-[10px] leading-3 text-foreground-muted">{formatFileSize(file.size)}</span>
|
|
532
|
+
</span>
|
|
533
|
+
</span>
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/* ── 内置默认工具栏左侧功能按钮 ──
|
|
538
|
+
* compact = true 时(如 default-sm),Agent 按钮收缩为 36×36 图标钮,与其它工具栏按钮等宽
|
|
539
|
+
* 内置浮层:Agent / @ / 指令;附件触发隐藏 file input
|
|
540
|
+
*/
|
|
541
|
+
function DefaultToolbarLeft({
|
|
542
|
+
agentLabel,
|
|
543
|
+
compact = false,
|
|
544
|
+
agentOptions,
|
|
545
|
+
mentionOptions,
|
|
546
|
+
commandOptions,
|
|
547
|
+
selectedAgentId,
|
|
548
|
+
onPickAgent,
|
|
549
|
+
onPickMention,
|
|
550
|
+
onPickCommand,
|
|
551
|
+
onAttachClick,
|
|
552
|
+
}) {
|
|
553
|
+
const [openMenu, setOpenMenu] = useState(null);
|
|
554
|
+
const wrapperRef = useRef(null);
|
|
555
|
+
|
|
556
|
+
/* Agent 默认态识别:第一项视为"默认/Auto",其它都按"已选具体模型"显示绿色高亮 */
|
|
557
|
+
const isAutoAgent = !selectedAgentId || selectedAgentId === agentOptions?.[0]?.id;
|
|
558
|
+
|
|
559
|
+
const closeMenu = useCallback(() => setOpenMenu(null), []);
|
|
560
|
+
const toggle = (key) => () => setOpenMenu((prev) => (prev === key ? null : key));
|
|
561
|
+
|
|
562
|
+
/* 外部点击关闭浮层(忽略 wrapper 内点击;用 mousedown,先于 button click) */
|
|
563
|
+
useEffect(() => {
|
|
564
|
+
if (!openMenu) return;
|
|
565
|
+
const handler = (e) => {
|
|
566
|
+
if (wrapperRef.current && !wrapperRef.current.contains(e.target)) closeMenu();
|
|
567
|
+
};
|
|
568
|
+
document.addEventListener('mousedown', handler);
|
|
569
|
+
return () => document.removeEventListener('mousedown', handler);
|
|
570
|
+
}, [openMenu, closeMenu]);
|
|
571
|
+
|
|
572
|
+
/* 选项点击:触发回调 → 关闭浮层 */
|
|
573
|
+
const handlePick = (kind) => (item) => {
|
|
574
|
+
if (kind === 'agent') onPickAgent?.(item);
|
|
575
|
+
if (kind === 'mention') onPickMention?.(item);
|
|
576
|
+
if (kind === 'command') onPickCommand?.(item);
|
|
577
|
+
closeMenu();
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
return (
|
|
581
|
+
<div ref={wrapperRef} className="flex flex-1 min-w-0 items-center gap-2">
|
|
582
|
+
{/* @Agent 选择器:默认带文字+下拉箭头;compact 时只剩 Bot 图标 */}
|
|
583
|
+
<div className="relative">
|
|
584
|
+
{compact ? (
|
|
585
|
+
<button
|
|
586
|
+
type="button"
|
|
587
|
+
className={TOOL_ICON_BTN}
|
|
588
|
+
onMouseDown={keepFocus}
|
|
589
|
+
onClick={toggle('agent')}
|
|
590
|
+
aria-haspopup="menu"
|
|
591
|
+
aria-expanded={openMenu === 'agent'}
|
|
592
|
+
aria-label={`Agent:${agentLabel}`}
|
|
593
|
+
data-tfds-component="ChatInput.Agent"
|
|
594
|
+
>
|
|
595
|
+
<Icon name="bot-01-stroked" size="sm" color="currentColor" />
|
|
596
|
+
</button>
|
|
597
|
+
) : (
|
|
598
|
+
<button
|
|
599
|
+
type="button"
|
|
600
|
+
className={isAutoAgent ? AGENT_BTN : AGENT_BTN_ACTIVE}
|
|
601
|
+
onMouseDown={keepFocus}
|
|
602
|
+
onClick={toggle('agent')}
|
|
603
|
+
aria-haspopup="menu"
|
|
604
|
+
aria-expanded={openMenu === 'agent'}
|
|
605
|
+
aria-label="选择 Agent"
|
|
606
|
+
data-tfds-component="ChatInput.Agent"
|
|
607
|
+
>
|
|
608
|
+
<span className={`shrink-0 flex ${isAutoAgent ? 'text-foreground-muted' : 'text-brand-500'}`}>
|
|
609
|
+
<Icon name="bot-01-stroked" size="sm" color="currentColor" />
|
|
610
|
+
</span>
|
|
611
|
+
<span className="whitespace-nowrap">{agentLabel}</span>
|
|
612
|
+
<span className={`shrink-0 flex ${isAutoAgent ? 'text-foreground-muted' : 'text-brand-500'}`}>
|
|
613
|
+
<Icon name="chevron-down-stroked" size="sm" color="currentColor" />
|
|
614
|
+
</span>
|
|
615
|
+
</button>
|
|
616
|
+
)}
|
|
617
|
+
<PopoverMenu
|
|
618
|
+
open={openMenu === 'agent'}
|
|
619
|
+
items={agentOptions}
|
|
620
|
+
selectedId={selectedAgentId}
|
|
621
|
+
onPick={handlePick('agent')}
|
|
622
|
+
/>
|
|
623
|
+
</div>
|
|
624
|
+
|
|
625
|
+
{/* @ 提及 */}
|
|
626
|
+
<div className="relative">
|
|
627
|
+
<button
|
|
628
|
+
type="button"
|
|
629
|
+
className={TOOL_ICON_BTN}
|
|
630
|
+
onMouseDown={keepFocus}
|
|
631
|
+
onClick={toggle('mention')}
|
|
632
|
+
aria-haspopup="menu"
|
|
633
|
+
aria-expanded={openMenu === 'mention'}
|
|
634
|
+
aria-label="@ 提及"
|
|
635
|
+
data-tfds-component="ChatInput.Mention"
|
|
636
|
+
>
|
|
637
|
+
<Icon name="at-sign-stroked" size="sm" color="currentColor" />
|
|
638
|
+
</button>
|
|
639
|
+
<PopoverMenu
|
|
640
|
+
open={openMenu === 'mention'}
|
|
641
|
+
items={mentionOptions}
|
|
642
|
+
onPick={handlePick('mention')}
|
|
643
|
+
emptyText="暂无可选实体"
|
|
644
|
+
/>
|
|
645
|
+
</div>
|
|
646
|
+
|
|
647
|
+
{/* 指令 */}
|
|
648
|
+
<div className="relative">
|
|
649
|
+
<button
|
|
650
|
+
type="button"
|
|
651
|
+
className={TOOL_ICON_BTN}
|
|
652
|
+
onMouseDown={keepFocus}
|
|
653
|
+
onClick={toggle('command')}
|
|
654
|
+
aria-haspopup="menu"
|
|
655
|
+
aria-expanded={openMenu === 'command'}
|
|
656
|
+
aria-label="指令"
|
|
657
|
+
data-tfds-component="ChatInput.Command"
|
|
658
|
+
>
|
|
659
|
+
<Icon name="ai-divider" size="sm" color="currentColor" />
|
|
660
|
+
</button>
|
|
661
|
+
<PopoverMenu
|
|
662
|
+
open={openMenu === 'command'}
|
|
663
|
+
items={commandOptions}
|
|
664
|
+
onPick={handlePick('command')}
|
|
665
|
+
emptyText="暂无可用指令"
|
|
666
|
+
/>
|
|
667
|
+
</div>
|
|
668
|
+
|
|
669
|
+
{/* 附件:唤起本地文件选择器 */}
|
|
670
|
+
<button
|
|
671
|
+
type="button"
|
|
672
|
+
className={TOOL_ICON_BTN}
|
|
673
|
+
onMouseDown={keepFocus}
|
|
674
|
+
onClick={onAttachClick}
|
|
675
|
+
aria-label="附件"
|
|
676
|
+
data-tfds-component="ChatInput.Attach"
|
|
677
|
+
>
|
|
678
|
+
<Icon name="paperclip-stroked" size="sm" color="currentColor" />
|
|
679
|
+
</button>
|
|
680
|
+
</div>
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/* ── im-basic:左下角声明式按钮区(默认图片 + 表情,最多 5 个) ── */
|
|
685
|
+
function ImBasicToolbarLeft({ actions = [] }) {
|
|
686
|
+
return (
|
|
687
|
+
<div className="flex flex-1 min-w-0 items-center gap-2">
|
|
688
|
+
{actions.map((action) => (
|
|
689
|
+
<button
|
|
690
|
+
key={action.id}
|
|
691
|
+
type="button"
|
|
692
|
+
className={TOOL_ICON_BTN}
|
|
693
|
+
onMouseDown={keepFocus}
|
|
694
|
+
onClick={action.onClick}
|
|
695
|
+
aria-label={action.ariaLabel}
|
|
696
|
+
title={action.ariaLabel}
|
|
697
|
+
data-tfds-component="ChatInput.ImAction"
|
|
698
|
+
>
|
|
699
|
+
<Icon name={action.icon} size="sm" color="currentColor" />
|
|
700
|
+
</button>
|
|
701
|
+
))}
|
|
702
|
+
</div>
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
export default function ChatInput({
|
|
707
|
+
variant = 'default',
|
|
708
|
+
agentName = 'Auto',
|
|
709
|
+
agentOptions = DEFAULT_AGENT_OPTIONS,
|
|
710
|
+
mentionOptions = DEFAULT_MENTION_OPTIONS,
|
|
711
|
+
commandOptions = DEFAULT_COMMAND_OPTIONS,
|
|
712
|
+
acceptFiles = '*',
|
|
713
|
+
multipleFiles = true,
|
|
714
|
+
catBarText,
|
|
715
|
+
catBarIcon,
|
|
716
|
+
placeholder = '需要我为你做什么',
|
|
717
|
+
statusText,
|
|
718
|
+
actionText,
|
|
719
|
+
onSend,
|
|
720
|
+
onStop,
|
|
721
|
+
onAction,
|
|
722
|
+
onAgentChange,
|
|
723
|
+
onMentionsChange,
|
|
724
|
+
onCommandsChange,
|
|
725
|
+
onAttachmentsChange,
|
|
726
|
+
toolbar,
|
|
727
|
+
children,
|
|
728
|
+
className = '',
|
|
729
|
+
style,
|
|
730
|
+
/* 仅供平台预览页使用:true 时挂载后自动注入示例 mention/command/附件,方便看"已选内容"样式 */
|
|
731
|
+
_seedContent = false,
|
|
732
|
+
/* 状态机受控:默认 true 保留旧行为;传 false 让外部 variant 完全决定状态切换 */
|
|
733
|
+
autoReplyOnSend = true,
|
|
734
|
+
imActions,
|
|
735
|
+
/* im-basic:可选覆盖左下「图片 / 表情」默认行为 */
|
|
736
|
+
onImImageClick,
|
|
737
|
+
onImEmojiClick,
|
|
738
|
+
/* 草稿回填:每次 prefillSeed 变化时把 prefillText 覆盖塞进编辑器并聚焦 */
|
|
739
|
+
prefillText = '',
|
|
740
|
+
prefillSeed = 0,
|
|
741
|
+
}) {
|
|
742
|
+
const [view, setView] = useState(() => (variant === 'im-basic' ? 'default' : variant));
|
|
743
|
+
/* 编辑器的"是否为空"态:决定占位符显示 + 发送按钮可点 */
|
|
744
|
+
const [isEditorEmpty, setIsEditorEmpty] = useState(true);
|
|
745
|
+
/* 内置选中态:Agent 单选 + 附件多选;mention/command chip 直接活在 contentEditable DOM 里,不进 React state */
|
|
746
|
+
const initAgent = agentOptions.find((o) => o.label === agentName) || agentOptions[0] || { id: 'auto', label: agentName };
|
|
747
|
+
const [selectedAgent, setSelectedAgent] = useState(initAgent);
|
|
748
|
+
const [attachments, setAttachments] = useState([]);
|
|
749
|
+
|
|
750
|
+
const editorRef = useRef(null);
|
|
751
|
+
const fileInputRef = useRef(null);
|
|
752
|
+
/* 最外层 wrapper:用于"焦点离开组件 + 内容为空时收起"判断 */
|
|
753
|
+
const rootRef = useRef(null);
|
|
754
|
+
/* chip 的 React Root 实例字典:chip wrapper DOM → root,方便卸载时 unmount */
|
|
755
|
+
const chipRootsRef = useRef(new Map());
|
|
756
|
+
/* 最近一次编辑器内的 selection range,用于在浮层关闭后恢复光标 */
|
|
757
|
+
const savedRangeRef = useRef(null);
|
|
758
|
+
|
|
759
|
+
useEffect(() => { injectCatAnim(); }, []);
|
|
760
|
+
useEffect(() => {
|
|
761
|
+
setView(variant === 'im-basic' ? 'default' : variant);
|
|
762
|
+
/* variant 切换时清空编辑器 */
|
|
763
|
+
if (editorRef.current) {
|
|
764
|
+
chipRootsRef.current.forEach((root) => root.unmount());
|
|
765
|
+
chipRootsRef.current.clear();
|
|
766
|
+
editorRef.current.innerHTML = '';
|
|
767
|
+
}
|
|
768
|
+
setIsEditorEmpty(true);
|
|
769
|
+
}, [variant]);
|
|
770
|
+
|
|
771
|
+
/* 焦点离开整个 ChatInput + 编辑器为空 + 无附件 → 自动收起到 default-sm
|
|
772
|
+
* ⚠️ 仅当 variant === 'default-sm' 时启用(即"小→展开→输入→缩回"场景)
|
|
773
|
+
* variant === 'default' 是常驻展开场景,不应被该逻辑干扰
|
|
774
|
+
* 其它态(busy / readonly / replying)也不参与
|
|
775
|
+
*/
|
|
776
|
+
useEffect(() => {
|
|
777
|
+
if (variant !== 'default-sm') return;
|
|
778
|
+
if (view !== 'default') return;
|
|
779
|
+
const collapseIfEmpty = () => {
|
|
780
|
+
if (!rootRef.current) return;
|
|
781
|
+
if (rootRef.current.contains(document.activeElement)) return;
|
|
782
|
+
if (!isEditorEmpty) return;
|
|
783
|
+
if (attachments.length > 0) return;
|
|
784
|
+
setView('default-sm');
|
|
785
|
+
};
|
|
786
|
+
const onFocusIn = () => { setTimeout(collapseIfEmpty, 0); };
|
|
787
|
+
const onMouseDown = (e) => {
|
|
788
|
+
if (rootRef.current && rootRef.current.contains(e.target)) return;
|
|
789
|
+
setTimeout(collapseIfEmpty, 0);
|
|
790
|
+
};
|
|
791
|
+
document.addEventListener('focusin', onFocusIn);
|
|
792
|
+
document.addEventListener('mousedown', onMouseDown);
|
|
793
|
+
return () => {
|
|
794
|
+
document.removeEventListener('focusin', onFocusIn);
|
|
795
|
+
document.removeEventListener('mousedown', onMouseDown);
|
|
796
|
+
};
|
|
797
|
+
}, [variant, view, isEditorEmpty, attachments.length]);
|
|
798
|
+
|
|
799
|
+
/* 卸载时清掉所有 chip 的 React root,防止内存泄漏 */
|
|
800
|
+
useEffect(() => () => {
|
|
801
|
+
chipRootsRef.current.forEach((root) => root.unmount());
|
|
802
|
+
chipRootsRef.current.clear();
|
|
803
|
+
}, []);
|
|
804
|
+
|
|
805
|
+
/* 草稿回填:每次 prefillSeed 变化时把 prefillText 覆盖塞进编辑器并聚焦
|
|
806
|
+
* 用途:推荐 chip / 追问 chip 点击时把文案写入输入框,等用户主动点发送 */
|
|
807
|
+
useEffect(() => {
|
|
808
|
+
if (prefillSeed <= 0) return;
|
|
809
|
+
if (view !== 'default') return;
|
|
810
|
+
const el = editorRef.current;
|
|
811
|
+
if (!el) return;
|
|
812
|
+
/* 先清掉所有 chip root,再覆盖文本 */
|
|
813
|
+
chipRootsRef.current.forEach((root) => root.unmount());
|
|
814
|
+
chipRootsRef.current.clear();
|
|
815
|
+
el.innerHTML = '';
|
|
816
|
+
el.appendChild(document.createTextNode(prefillText || ''));
|
|
817
|
+
setIsEditorEmpty(!(prefillText || '').trim());
|
|
818
|
+
/* 聚焦并把光标放到末尾 */
|
|
819
|
+
el.focus();
|
|
820
|
+
const range = document.createRange();
|
|
821
|
+
range.selectNodeContents(el);
|
|
822
|
+
range.collapse(false);
|
|
823
|
+
const sel = window.getSelection();
|
|
824
|
+
sel?.removeAllRanges();
|
|
825
|
+
sel?.addRange(range);
|
|
826
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
827
|
+
}, [prefillSeed]);
|
|
828
|
+
|
|
829
|
+
const resolvedCatText = catBarText || DEFAULT_CAT_TEXT[view] || '';
|
|
830
|
+
|
|
831
|
+
/* ── 编辑器空态检测:有文字 or 有 chip 都算非空 ── */
|
|
832
|
+
const refreshEmpty = useCallback(() => {
|
|
833
|
+
const el = editorRef.current;
|
|
834
|
+
if (!el) {
|
|
835
|
+
setIsEditorEmpty(true);
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
const hasText = (el.innerText || '').replace(/\u00a0/g, ' ').trim().length > 0;
|
|
839
|
+
const hasChip = el.querySelector('[data-chip="1"]') != null;
|
|
840
|
+
setIsEditorEmpty(!hasText && !hasChip);
|
|
841
|
+
}, []);
|
|
842
|
+
|
|
843
|
+
/* ── 收集编辑器当前内容(用于 onSend 回调) ──
|
|
844
|
+
* 返回值:
|
|
845
|
+
* · text 纯文本拼接(兼容旧调用方),chip label 也会被算进去
|
|
846
|
+
* · segments 按 DOM 顺序排好的 token 列表 [{type:'text',value} | {type:'entity',kind,icon,label,id}]
|
|
847
|
+
* ↳ 直接对接 ChatMessage.userContent,保留 chip 与文字混排结构
|
|
848
|
+
* · mentions / commands 分类后的 chip 列表(兼容旧调用方)
|
|
849
|
+
*/
|
|
850
|
+
const collectContext = useCallback(() => {
|
|
851
|
+
const el = editorRef.current;
|
|
852
|
+
if (!el) return { text: '', segments: [], mentions: [], commands: [] };
|
|
853
|
+
|
|
854
|
+
/* 深度优先遍历编辑器,按 DOM 顺序产出 segments */
|
|
855
|
+
const segments = [];
|
|
856
|
+
const pushText = (val) => {
|
|
857
|
+
if (!val) return;
|
|
858
|
+
const last = segments[segments.length - 1];
|
|
859
|
+
if (last && last.type === 'text') {
|
|
860
|
+
last.value += val;
|
|
861
|
+
} else {
|
|
862
|
+
segments.push({ type: 'text', value: val });
|
|
863
|
+
}
|
|
864
|
+
};
|
|
865
|
+
const walk = (node) => {
|
|
866
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
867
|
+
pushText((node.textContent || '').replace(/\u00a0/g, ' '));
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
|
871
|
+
/* chip wrapper:data-chip="1" → 当作 entity token,停止下钻 */
|
|
872
|
+
if (node.getAttribute && node.getAttribute('data-chip') === '1') {
|
|
873
|
+
const kind = node.getAttribute('data-kind') || 'mention';
|
|
874
|
+
segments.push({
|
|
875
|
+
type: 'entity',
|
|
876
|
+
kind,
|
|
877
|
+
id: node.getAttribute('data-id') || undefined,
|
|
878
|
+
icon: node.getAttribute('data-icon') || undefined,
|
|
879
|
+
label: node.getAttribute('data-label') || '',
|
|
880
|
+
});
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
/* 块级换行(div / br)→ 补一个换行符 */
|
|
884
|
+
const tag = node.tagName;
|
|
885
|
+
if (tag === 'BR') {
|
|
886
|
+
pushText('\n');
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
const isBlock = tag === 'DIV' || tag === 'P';
|
|
890
|
+
if (isBlock && segments.length > 0) pushText('\n');
|
|
891
|
+
node.childNodes.forEach(walk);
|
|
892
|
+
};
|
|
893
|
+
el.childNodes.forEach(walk);
|
|
894
|
+
|
|
895
|
+
/* segments 收尾:去掉首尾空白文本 token;空 text 段落丢弃 */
|
|
896
|
+
while (segments.length && segments[0].type === 'text' && !segments[0].value.trim()) segments.shift();
|
|
897
|
+
while (segments.length && segments[segments.length - 1].type === 'text' && !segments[segments.length - 1].value.trim()) segments.pop();
|
|
898
|
+
|
|
899
|
+
const chips = segments
|
|
900
|
+
.filter((s) => s.type === 'entity')
|
|
901
|
+
.map(({ kind, id, label, icon }) => ({ kind, id, label, icon }));
|
|
902
|
+
|
|
903
|
+
return {
|
|
904
|
+
text: (el.innerText || '').replace(/\u00a0/g, ' ').trim(),
|
|
905
|
+
segments,
|
|
906
|
+
mentions: chips.filter((c) => c.kind === 'mention'),
|
|
907
|
+
commands: chips.filter((c) => c.kind === 'command'),
|
|
908
|
+
};
|
|
909
|
+
}, []);
|
|
910
|
+
|
|
911
|
+
/* ── 保存 / 恢复编辑器光标位置 ── */
|
|
912
|
+
const saveSelection = useCallback(() => {
|
|
913
|
+
const sel = typeof window !== 'undefined' ? window.getSelection() : null;
|
|
914
|
+
if (!sel || !sel.rangeCount) return;
|
|
915
|
+
const range = sel.getRangeAt(0);
|
|
916
|
+
const el = editorRef.current;
|
|
917
|
+
if (el && el.contains(range.commonAncestorContainer)) {
|
|
918
|
+
savedRangeRef.current = range.cloneRange();
|
|
919
|
+
}
|
|
920
|
+
}, []);
|
|
921
|
+
|
|
922
|
+
const restoreSelectionToEnd = useCallback(() => {
|
|
923
|
+
const el = editorRef.current;
|
|
924
|
+
if (!el) return null;
|
|
925
|
+
const sel = window.getSelection();
|
|
926
|
+
sel.removeAllRanges();
|
|
927
|
+
if (savedRangeRef.current && el.contains(savedRangeRef.current.commonAncestorContainer)) {
|
|
928
|
+
sel.addRange(savedRangeRef.current);
|
|
929
|
+
return savedRangeRef.current;
|
|
930
|
+
}
|
|
931
|
+
const range = document.createRange();
|
|
932
|
+
range.selectNodeContents(el);
|
|
933
|
+
range.collapse(false);
|
|
934
|
+
sel.addRange(range);
|
|
935
|
+
return range;
|
|
936
|
+
}, []);
|
|
937
|
+
|
|
938
|
+
/* ── 在光标位置插入 chip(核心:富文本混排关键能力) ──
|
|
939
|
+
* - 用 createRoot 把 React EntityChip 渲染进一个 contentEditable=false 的 wrapper span
|
|
940
|
+
* - wrapper 上挂 data-* 标记,方便后续提取
|
|
941
|
+
* - chip 前后按需插入 NBSP:
|
|
942
|
+
* · 后置 NBSP 总是加 → 让光标可以落在 chip 后继续输入
|
|
943
|
+
* · 前置 NBSP 仅在光标前一个字符非空白时加 → 让 chip 与前面已有文字保持 4px 视觉间距,
|
|
944
|
+
* 避免编辑器开头/换行后的多余缩进
|
|
945
|
+
*/
|
|
946
|
+
const insertChip = useCallback((kind, item) => {
|
|
947
|
+
const el = editorRef.current;
|
|
948
|
+
if (!el) return;
|
|
949
|
+
el.focus();
|
|
950
|
+
|
|
951
|
+
const wrapper = document.createElement('span');
|
|
952
|
+
wrapper.setAttribute('data-chip', '1');
|
|
953
|
+
wrapper.setAttribute('data-kind', kind);
|
|
954
|
+
wrapper.setAttribute('data-id', String(item.id));
|
|
955
|
+
wrapper.setAttribute('data-label', item.label);
|
|
956
|
+
if (item.icon) wrapper.setAttribute('data-icon', item.icon);
|
|
957
|
+
wrapper.contentEditable = 'false';
|
|
958
|
+
wrapper.style.userSelect = 'none';
|
|
959
|
+
|
|
960
|
+
const range = restoreSelectionToEnd();
|
|
961
|
+
const spaceAfter = document.createTextNode('\u00a0');
|
|
962
|
+
|
|
963
|
+
if (range) {
|
|
964
|
+
/* 判断是否需要前置空格:光标前是文本且最后一个字符不是空白 → 需要补 */
|
|
965
|
+
let needSpaceBefore = false;
|
|
966
|
+
const sc = range.startContainer;
|
|
967
|
+
if (sc.nodeType === Node.TEXT_NODE && range.startOffset > 0) {
|
|
968
|
+
const prev = sc.textContent[range.startOffset - 1];
|
|
969
|
+
if (prev && prev !== '\u00a0' && prev !== ' ' && prev !== '\n') needSpaceBefore = true;
|
|
970
|
+
} else if (sc.nodeType === Node.ELEMENT_NODE && range.startOffset > 0) {
|
|
971
|
+
/* 光标紧跟在某个非文本节点之后(如另一个 chip wrapper),也补一个空格 */
|
|
972
|
+
const prevNode = sc.childNodes[range.startOffset - 1];
|
|
973
|
+
if (prevNode && prevNode.nodeType === Node.ELEMENT_NODE) needSpaceBefore = true;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
range.collapse(false);
|
|
977
|
+
range.insertNode(spaceAfter);
|
|
978
|
+
range.insertNode(wrapper);
|
|
979
|
+
if (needSpaceBefore) {
|
|
980
|
+
range.insertNode(document.createTextNode('\u00a0'));
|
|
981
|
+
}
|
|
982
|
+
range.setStartAfter(spaceAfter);
|
|
983
|
+
range.collapse(true);
|
|
984
|
+
const sel = window.getSelection();
|
|
985
|
+
sel.removeAllRanges();
|
|
986
|
+
sel.addRange(range);
|
|
987
|
+
savedRangeRef.current = range.cloneRange();
|
|
988
|
+
} else {
|
|
989
|
+
el.appendChild(wrapper);
|
|
990
|
+
el.appendChild(spaceAfter);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/* 用 React 渲染 chip 内容到 wrapper:复用 EntityChip 组件 */
|
|
994
|
+
const root = createRoot(wrapper);
|
|
995
|
+
chipRootsRef.current.set(wrapper, root);
|
|
996
|
+
const handleChipRemove = () => {
|
|
997
|
+
const r = chipRootsRef.current.get(wrapper);
|
|
998
|
+
if (r) {
|
|
999
|
+
r.unmount();
|
|
1000
|
+
chipRootsRef.current.delete(wrapper);
|
|
1001
|
+
}
|
|
1002
|
+
const next = wrapper.nextSibling;
|
|
1003
|
+
if (next && next.nodeType === Node.TEXT_NODE && next.textContent === '\u00a0') {
|
|
1004
|
+
next.parentNode.removeChild(next);
|
|
1005
|
+
}
|
|
1006
|
+
if (wrapper.parentNode) wrapper.parentNode.removeChild(wrapper);
|
|
1007
|
+
refreshEmpty();
|
|
1008
|
+
editorRef.current?.focus();
|
|
1009
|
+
};
|
|
1010
|
+
root.render(<EntityChip icon={item.icon} label={item.label} onRemove={handleChipRemove} />);
|
|
1011
|
+
|
|
1012
|
+
refreshEmpty();
|
|
1013
|
+
}, [restoreSelectionToEnd, refreshEmpty]);
|
|
1014
|
+
|
|
1015
|
+
/* ── 交互处理 ── */
|
|
1016
|
+
const handleExpand = useCallback(() => {
|
|
1017
|
+
setView('default');
|
|
1018
|
+
requestAnimationFrame(() => editorRef.current?.focus());
|
|
1019
|
+
}, []);
|
|
1020
|
+
|
|
1021
|
+
/* ── 容器级聚焦热区:点击整个 ChatInput 的非交互空白区都会聚焦到编辑器
|
|
1022
|
+
* · 仅 default 态生效(其他态没有可输入区)
|
|
1023
|
+
* · 排除真正的交互元素:button / a / input / select / textarea / [role=button]
|
|
1024
|
+
* / 编辑器内 chip wrapper / 自定义 [data-no-focus]
|
|
1025
|
+
* · 排除编辑器自身:让原生光标定位逻辑接管(点哪个字光标就在哪)
|
|
1026
|
+
* · 用 mousedown + preventDefault 阻止默认聚焦切换,再手动 focus 编辑器并把光标置末尾 */
|
|
1027
|
+
const handleContainerMouseDown = useCallback((e) => {
|
|
1028
|
+
/* 直接读 view(在本函数下方才声明 isDefault,避免 TDZ ReferenceError) */
|
|
1029
|
+
if (view !== 'default') return;
|
|
1030
|
+
const target = e.target;
|
|
1031
|
+
if (!(target instanceof Element)) return;
|
|
1032
|
+
const editor = editorRef.current;
|
|
1033
|
+
if (editor && editor.contains(target)) return;
|
|
1034
|
+
if (target.closest('button, a, input, select, textarea, [role="button"], [data-chip="1"], [data-no-focus="1"]')) return;
|
|
1035
|
+
e.preventDefault();
|
|
1036
|
+
if (!editor) return;
|
|
1037
|
+
editor.focus();
|
|
1038
|
+
const range = document.createRange();
|
|
1039
|
+
range.selectNodeContents(editor);
|
|
1040
|
+
range.collapse(false);
|
|
1041
|
+
const sel = window.getSelection();
|
|
1042
|
+
sel?.removeAllRanges();
|
|
1043
|
+
sel?.addRange(range);
|
|
1044
|
+
}, [view]);
|
|
1045
|
+
|
|
1046
|
+
/* default-sm 下选了工具栏功能项后,自动展开为 default 态(已有内容已经在 editor / state 里) */
|
|
1047
|
+
const expandIfCollapsed = useCallback(() => {
|
|
1048
|
+
setView((prev) => (prev === 'default-sm' ? 'default' : prev));
|
|
1049
|
+
}, []);
|
|
1050
|
+
|
|
1051
|
+
const handleSend = useCallback(() => {
|
|
1052
|
+
const ctx = collectContext();
|
|
1053
|
+
const hasAtt = attachments.length > 0;
|
|
1054
|
+
const hasCtx = !!(ctx.text || ctx.mentions.length || ctx.commands.length);
|
|
1055
|
+
/* im-basic:允许仅选图片附件后发送(纯 IM) */
|
|
1056
|
+
if (!hasCtx && !(variant === 'im-basic' && hasAtt)) return;
|
|
1057
|
+
onSend?.(ctx.text, {
|
|
1058
|
+
agent: selectedAgent,
|
|
1059
|
+
segments: ctx.segments,
|
|
1060
|
+
mentions: ctx.mentions,
|
|
1061
|
+
commands: ctx.commands,
|
|
1062
|
+
attachments: attachments.map((a) => a.file),
|
|
1063
|
+
});
|
|
1064
|
+
/* 清空编辑器 */
|
|
1065
|
+
chipRootsRef.current.forEach((root) => root.unmount());
|
|
1066
|
+
chipRootsRef.current.clear();
|
|
1067
|
+
if (editorRef.current) editorRef.current.innerHTML = '';
|
|
1068
|
+
setIsEditorEmpty(true);
|
|
1069
|
+
if (variant === 'im-basic' && hasAtt) {
|
|
1070
|
+
setAttachments([]);
|
|
1071
|
+
onAttachmentsChange?.([]);
|
|
1072
|
+
}
|
|
1073
|
+
/* autoReplyOnSend=true(默认)→ 内部自动跳 replying;im-basic 永不进入 AI 回复态 */
|
|
1074
|
+
if (autoReplyOnSend && variant !== 'im-basic') setView('replying');
|
|
1075
|
+
}, [collectContext, onSend, selectedAgent, attachments, autoReplyOnSend, variant, onAttachmentsChange]);
|
|
1076
|
+
|
|
1077
|
+
/* ── 工具栏选择回调(选中后若处于 default-sm,自动展开为 default 并带入选中值) ── */
|
|
1078
|
+
const handlePickAgent = useCallback((item) => {
|
|
1079
|
+
setSelectedAgent(item);
|
|
1080
|
+
onAgentChange?.(item);
|
|
1081
|
+
expandIfCollapsed();
|
|
1082
|
+
}, [onAgentChange, expandIfCollapsed]);
|
|
1083
|
+
|
|
1084
|
+
const handlePickMention = useCallback((item) => {
|
|
1085
|
+
expandIfCollapsed();
|
|
1086
|
+
/* 等下一帧 default 态的 editor 挂载完,再插 chip,否则插不到目标 DOM */
|
|
1087
|
+
requestAnimationFrame(() => {
|
|
1088
|
+
insertChip('mention', item);
|
|
1089
|
+
onMentionsChange?.(collectContext().mentions);
|
|
1090
|
+
});
|
|
1091
|
+
}, [insertChip, collectContext, onMentionsChange, expandIfCollapsed]);
|
|
1092
|
+
|
|
1093
|
+
const handlePickCommand = useCallback((item) => {
|
|
1094
|
+
expandIfCollapsed();
|
|
1095
|
+
requestAnimationFrame(() => {
|
|
1096
|
+
insertChip('command', item);
|
|
1097
|
+
onCommandsChange?.(collectContext().commands);
|
|
1098
|
+
});
|
|
1099
|
+
}, [insertChip, collectContext, onCommandsChange, expandIfCollapsed]);
|
|
1100
|
+
|
|
1101
|
+
const handleAttachClick = useCallback(() => {
|
|
1102
|
+
fileInputRef.current?.click();
|
|
1103
|
+
}, []);
|
|
1104
|
+
|
|
1105
|
+
const handleFileChange = useCallback((e) => {
|
|
1106
|
+
const files = Array.from(e.target.files || []);
|
|
1107
|
+
if (!files.length) return;
|
|
1108
|
+
setAttachments((prev) => {
|
|
1109
|
+
const next = [...prev, ...files.map((f) => ({ file: f, _key: `${f.name}-${f.size}-${Date.now()}-${Math.random()}` }))];
|
|
1110
|
+
onAttachmentsChange?.(next.map((x) => x.file));
|
|
1111
|
+
return next;
|
|
1112
|
+
});
|
|
1113
|
+
e.target.value = '';
|
|
1114
|
+
expandIfCollapsed();
|
|
1115
|
+
}, [onAttachmentsChange, expandIfCollapsed]);
|
|
1116
|
+
|
|
1117
|
+
const handleRemoveAttachment = useCallback((key) => {
|
|
1118
|
+
setAttachments((prev) => {
|
|
1119
|
+
const next = prev.filter((it) => it._key !== key);
|
|
1120
|
+
onAttachmentsChange?.(next.map((x) => x.file));
|
|
1121
|
+
return next;
|
|
1122
|
+
});
|
|
1123
|
+
}, [onAttachmentsChange]);
|
|
1124
|
+
|
|
1125
|
+
const handleStop = useCallback(() => {
|
|
1126
|
+
onStop?.();
|
|
1127
|
+
setView('default');
|
|
1128
|
+
}, [onStop]);
|
|
1129
|
+
|
|
1130
|
+
const handleEditorKeyDown = useCallback((e) => {
|
|
1131
|
+
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); }
|
|
1132
|
+
}, [handleSend]);
|
|
1133
|
+
|
|
1134
|
+
const handleEditorInput = useCallback(() => {
|
|
1135
|
+
refreshEmpty();
|
|
1136
|
+
saveSelection();
|
|
1137
|
+
}, [refreshEmpty, saveSelection]);
|
|
1138
|
+
|
|
1139
|
+
/* ── 视图判断 ── */
|
|
1140
|
+
const isCollapsed = view === 'default-sm';
|
|
1141
|
+
const isDefault = view === 'default';
|
|
1142
|
+
const isReplying = view === 'replying';
|
|
1143
|
+
const isBusy = view === 'busy';
|
|
1144
|
+
const isReadonly = view === 'readonly';
|
|
1145
|
+
const isImBasic = variant === 'im-basic';
|
|
1146
|
+
|
|
1147
|
+
const resolvedImActions = (
|
|
1148
|
+
Array.isArray(imActions)
|
|
1149
|
+
? imActions
|
|
1150
|
+
: imActions === undefined
|
|
1151
|
+
? DEFAULT_IM_ACTIONS
|
|
1152
|
+
: []
|
|
1153
|
+
)
|
|
1154
|
+
.slice(0, 5)
|
|
1155
|
+
.map((action) => {
|
|
1156
|
+
const id = String(action?.id || '');
|
|
1157
|
+
const icon = action?.icon || (id === 'image'
|
|
1158
|
+
? 'image-01-stroked'
|
|
1159
|
+
: id === 'emoji'
|
|
1160
|
+
? 'face-smile-stroked'
|
|
1161
|
+
: 'plus-stroked');
|
|
1162
|
+
const ariaLabel = action?.ariaLabel || action?.label || id || '快捷操作';
|
|
1163
|
+
const fallbackClick = id === 'image'
|
|
1164
|
+
? (onImImageClick || handleAttachClick)
|
|
1165
|
+
: id === 'emoji'
|
|
1166
|
+
? (onImEmojiClick || (() => {}))
|
|
1167
|
+
: (() => {});
|
|
1168
|
+
return {
|
|
1169
|
+
id: id || `im-action-${icon}`,
|
|
1170
|
+
icon,
|
|
1171
|
+
ariaLabel,
|
|
1172
|
+
onClick: action?.onClick || fallbackClick,
|
|
1173
|
+
};
|
|
1174
|
+
});
|
|
1175
|
+
|
|
1176
|
+
/* 大状态(default + replying,双行结构)用 py-14;单行态(default-sm / busy / readonly)用 py-12 */
|
|
1177
|
+
const isLarge = isDefault || isReplying;
|
|
1178
|
+
|
|
1179
|
+
/* 青紫光晕:默认态聚焦时点亮;忙碌态常驻 */
|
|
1180
|
+
const inputAreaCls = [
|
|
1181
|
+
INPUT_AREA_BASE,
|
|
1182
|
+
isLarge ? INPUT_PAD_LG : INPUT_PAD_SM,
|
|
1183
|
+
isDefault && FOCUS_SHADOW,
|
|
1184
|
+
isBusy && `${BUSY_BORDER} ${GLOW_SHADOW}`,
|
|
1185
|
+
].filter(Boolean).join(' ');
|
|
1186
|
+
|
|
1187
|
+
/* 忙碌态:渐变描边 + 淡彩内底,由 background + border-clip 合成 */
|
|
1188
|
+
const inputAreaStyle = isBusy ? { background: BUSY_BG } : undefined;
|
|
1189
|
+
|
|
1190
|
+
/* ── 工具栏左侧(default-sm 收起态把 Agent 按钮收成图标钮) ── */
|
|
1191
|
+
const toolbarLeft = toolbar
|
|
1192
|
+
? <div className="flex flex-1 min-w-0 items-center gap-2">{toolbar}</div>
|
|
1193
|
+
: isImBasic
|
|
1194
|
+
? (
|
|
1195
|
+
<ImBasicToolbarLeft
|
|
1196
|
+
actions={resolvedImActions}
|
|
1197
|
+
/>
|
|
1198
|
+
)
|
|
1199
|
+
: (
|
|
1200
|
+
<DefaultToolbarLeft
|
|
1201
|
+
agentLabel={selectedAgent.label}
|
|
1202
|
+
compact={isCollapsed}
|
|
1203
|
+
agentOptions={agentOptions}
|
|
1204
|
+
mentionOptions={mentionOptions}
|
|
1205
|
+
commandOptions={commandOptions}
|
|
1206
|
+
selectedAgentId={selectedAgent.id}
|
|
1207
|
+
onPickAgent={handlePickAgent}
|
|
1208
|
+
onPickMention={handlePickMention}
|
|
1209
|
+
onPickCommand={handlePickCommand}
|
|
1210
|
+
onAttachClick={handleAttachClick}
|
|
1211
|
+
/>
|
|
1212
|
+
);
|
|
1213
|
+
|
|
1214
|
+
/* ── 附件行(仅 default 态展示,独立于编辑器位于其上方) ── */
|
|
1215
|
+
const hasAttachments = attachments.length > 0;
|
|
1216
|
+
const showAttachments = isDefault && !toolbar && hasAttachments;
|
|
1217
|
+
|
|
1218
|
+
const attachmentsBlock = showAttachments && (
|
|
1219
|
+
<div className="flex flex-wrap gap-2 w-full">
|
|
1220
|
+
{attachments.map((att) => (
|
|
1221
|
+
<AttachmentCard
|
|
1222
|
+
key={att._key}
|
|
1223
|
+
file={att.file}
|
|
1224
|
+
onRemove={() => handleRemoveAttachment(att._key)}
|
|
1225
|
+
/>
|
|
1226
|
+
))}
|
|
1227
|
+
</div>
|
|
1228
|
+
);
|
|
1229
|
+
|
|
1230
|
+
/* ── 平台预览页 seed:挂载后注入示例 mention/command/附件,方便看"已选内容"样态
|
|
1231
|
+
* 仅 _seedContent=true 时生效;不影响业务接入
|
|
1232
|
+
*/
|
|
1233
|
+
useEffect(() => {
|
|
1234
|
+
if (!_seedContent) return;
|
|
1235
|
+
/* 仅 default 系变体支持注入;其它变体(busy/readonly)由 mapProps 已自动跳过 */
|
|
1236
|
+
if (variant !== 'default' && variant !== 'default-sm' && variant !== 'replying' && variant !== 'im-basic') return;
|
|
1237
|
+
setView('default');
|
|
1238
|
+
const t = setTimeout(() => {
|
|
1239
|
+
const m = mentionOptions[0];
|
|
1240
|
+
const c = commandOptions[0];
|
|
1241
|
+
if (m) insertChip('mention', m);
|
|
1242
|
+
if (c) insertChip('command', c);
|
|
1243
|
+
setAttachments([
|
|
1244
|
+
{ _key: 'demo-1', file: { name: '火花诊断(火花规则文档V3).pdf', size: 379187, type: 'application/pdf' } },
|
|
1245
|
+
]);
|
|
1246
|
+
}, 30);
|
|
1247
|
+
return () => clearTimeout(t);
|
|
1248
|
+
/* eslint-disable-next-line react-hooks/exhaustive-deps */
|
|
1249
|
+
}, [_seedContent, variant]);
|
|
1250
|
+
|
|
1251
|
+
/* ── 隐藏文件选择器:附件按钮触发 ── */
|
|
1252
|
+
const hiddenFileInput = !toolbar && (
|
|
1253
|
+
<input
|
|
1254
|
+
ref={fileInputRef}
|
|
1255
|
+
type="file"
|
|
1256
|
+
hidden
|
|
1257
|
+
multiple={multipleFiles}
|
|
1258
|
+
accept={acceptFiles}
|
|
1259
|
+
onChange={handleFileChange}
|
|
1260
|
+
/>
|
|
1261
|
+
);
|
|
1262
|
+
|
|
1263
|
+
const wrapperCls = isImBasic ? WRAPPER_IM_BASIC : WRAPPER;
|
|
1264
|
+
const backdropCls = isImBasic ? BACKDROP_SYMMETRIC : BACKDROP;
|
|
1265
|
+
|
|
1266
|
+
return (
|
|
1267
|
+
<div
|
|
1268
|
+
ref={rootRef}
|
|
1269
|
+
className={[wrapperCls, className].filter(Boolean).join(' ')}
|
|
1270
|
+
style={style}
|
|
1271
|
+
onMouseDown={handleContainerMouseDown}
|
|
1272
|
+
data-tfds-component="ChatInput"
|
|
1273
|
+
>
|
|
1274
|
+
|
|
1275
|
+
{/* ── 猫条(im-basic 无 AI 场景,不展示) ── */}
|
|
1276
|
+
{!isImBasic && (
|
|
1277
|
+
<div className={CAT_BAR}>
|
|
1278
|
+
{catBarIcon
|
|
1279
|
+
? <div className="flex items-center justify-center shrink-0">{catBarIcon}</div>
|
|
1280
|
+
: <CatAvatar />
|
|
1281
|
+
}
|
|
1282
|
+
<div className={CAT_BAR_TEXT}>
|
|
1283
|
+
<span className="flex-1 min-w-0 truncate">{resolvedCatText}</span>
|
|
1284
|
+
</div>
|
|
1285
|
+
</div>
|
|
1286
|
+
)}
|
|
1287
|
+
|
|
1288
|
+
{/* ── 输入区域 ── */}
|
|
1289
|
+
<div className={inputAreaCls} style={inputAreaStyle} data-tfds-component="ChatInput.Surface">
|
|
1290
|
+
|
|
1291
|
+
{/* 收起态:点击占位文字区域才展开;点工具栏 / 发送按钮区域走各自的浮层 / 文件选择 */}
|
|
1292
|
+
{isCollapsed && (
|
|
1293
|
+
<div
|
|
1294
|
+
className={`${COLLAPSED} cursor-text`}
|
|
1295
|
+
role="button"
|
|
1296
|
+
tabIndex={0}
|
|
1297
|
+
onKeyDown={(e) => e.key === 'Enter' && handleExpand()}
|
|
1298
|
+
data-tfds-component="ChatInput.Collapsed"
|
|
1299
|
+
>
|
|
1300
|
+
<div className="flex flex-1 min-w-0 items-center gap-3" onClick={(e) => e.stopPropagation()}>
|
|
1301
|
+
{toolbarLeft}
|
|
1302
|
+
</div>
|
|
1303
|
+
<p
|
|
1304
|
+
className={`${PLACEHOLDER_CLS} cursor-text`}
|
|
1305
|
+
onClick={handleExpand}
|
|
1306
|
+
>
|
|
1307
|
+
{children || placeholder}
|
|
1308
|
+
</p>
|
|
1309
|
+
<span onClick={(e) => e.stopPropagation()}>
|
|
1310
|
+
<SendButton mode="disabled" />
|
|
1311
|
+
</span>
|
|
1312
|
+
</div>
|
|
1313
|
+
)}
|
|
1314
|
+
|
|
1315
|
+
{/* 展开默认态:富文本 contentEditable 编辑器,chip 与文字混排 */}
|
|
1316
|
+
{isDefault && (
|
|
1317
|
+
<div className={EXPANDED}>
|
|
1318
|
+
{attachmentsBlock}
|
|
1319
|
+
<div className="w-full relative">
|
|
1320
|
+
{children || (
|
|
1321
|
+
<>
|
|
1322
|
+
{isEditorEmpty && (
|
|
1323
|
+
<span className={PLACEHOLDER_OVERLAY}>{placeholder}</span>
|
|
1324
|
+
)}
|
|
1325
|
+
<div
|
|
1326
|
+
ref={editorRef}
|
|
1327
|
+
className={EDITOR_CLS}
|
|
1328
|
+
contentEditable
|
|
1329
|
+
suppressContentEditableWarning
|
|
1330
|
+
role="textbox"
|
|
1331
|
+
aria-multiline="true"
|
|
1332
|
+
aria-label={placeholder}
|
|
1333
|
+
onInput={handleEditorInput}
|
|
1334
|
+
onKeyDown={handleEditorKeyDown}
|
|
1335
|
+
onKeyUp={saveSelection}
|
|
1336
|
+
onMouseUp={saveSelection}
|
|
1337
|
+
onBlur={saveSelection}
|
|
1338
|
+
data-tfds-component="ChatInput.Editor"
|
|
1339
|
+
/>
|
|
1340
|
+
</>
|
|
1341
|
+
)}
|
|
1342
|
+
</div>
|
|
1343
|
+
<div className={TOOLBAR_ROW} data-tfds-component="ChatInput.Toolbar">
|
|
1344
|
+
{toolbarLeft}
|
|
1345
|
+
<SendButton
|
|
1346
|
+
mode={isEditorEmpty && !(isImBasic && attachments.length > 0) ? 'disabled' : 'active'}
|
|
1347
|
+
onClick={handleSend}
|
|
1348
|
+
/>
|
|
1349
|
+
</div>
|
|
1350
|
+
</div>
|
|
1351
|
+
)}
|
|
1352
|
+
|
|
1353
|
+
{/* 回复中:保持与默认态相同高度,仅文案换为状态提示 */}
|
|
1354
|
+
{isReplying && (
|
|
1355
|
+
<div className={EXPANDED}>
|
|
1356
|
+
<div className={SINGLE_LINE_SLOT}>
|
|
1357
|
+
<span className="text-sm leading-[30px] text-foreground-disabled select-none truncate">
|
|
1358
|
+
{statusText || placeholder}
|
|
1359
|
+
</span>
|
|
1360
|
+
</div>
|
|
1361
|
+
<div className={TOOLBAR_ROW}>
|
|
1362
|
+
{toolbarLeft}
|
|
1363
|
+
<SendButton mode="running" onClick={handleStop} />
|
|
1364
|
+
</div>
|
|
1365
|
+
</div>
|
|
1366
|
+
)}
|
|
1367
|
+
|
|
1368
|
+
{/* 忙碌:执行中 / 排队中 — 渐变背景 + 状态文案 + 36×36 停止按钮 */}
|
|
1369
|
+
{isBusy && (
|
|
1370
|
+
<div className="flex items-center gap-3 w-full">
|
|
1371
|
+
<p className={STATUS_TEXT_CLS}>
|
|
1372
|
+
{statusText || '已耗时 3 min,运行结束后会通过飞书消息告知'}
|
|
1373
|
+
</p>
|
|
1374
|
+
<SendButton mode="running" onClick={handleStop} />
|
|
1375
|
+
</div>
|
|
1376
|
+
)}
|
|
1377
|
+
|
|
1378
|
+
{/* 只读:分享预览 / 抢单引导 — 白底 + 状态文案 + md 文字按钮 */}
|
|
1379
|
+
{isReadonly && (
|
|
1380
|
+
<div className="flex items-center gap-3 w-full">
|
|
1381
|
+
<p className={STATUS_TEXT_CLS}>
|
|
1382
|
+
{statusText || '来自其他成员的任务分享,当前页面仅支持预览'}
|
|
1383
|
+
</p>
|
|
1384
|
+
<Button variant="primary" size="md" onClick={onAction} className="shrink-0">
|
|
1385
|
+
{actionText || '继续对话'}
|
|
1386
|
+
</Button>
|
|
1387
|
+
</div>
|
|
1388
|
+
)}
|
|
1389
|
+
|
|
1390
|
+
</div>
|
|
1391
|
+
|
|
1392
|
+
{/* ── 底层氛围背景 ── */}
|
|
1393
|
+
<div className={backdropCls} aria-hidden="true" />
|
|
1394
|
+
|
|
1395
|
+
{/* ── 隐藏文件选择器(用内置工具栏时启用) ── */}
|
|
1396
|
+
{hiddenFileInput}
|
|
1397
|
+
</div>
|
|
1398
|
+
);
|
|
1399
|
+
}
|