@steerable/agent-shell-web 0.6.15
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/LICENSE +91 -0
- package/README.md +20 -0
- package/package.json +69 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.png +0 -0
- package/src/AppShell.tsx +24 -0
- package/src/assets/logo-generic.png +0 -0
- package/src/brand.test.ts +22 -0
- package/src/brand.ts +48 -0
- package/src/components/AgentSidebar.test.tsx +127 -0
- package/src/components/AgentSidebar.tsx +1010 -0
- package/src/components/ChatHeader.test.tsx +230 -0
- package/src/components/ChatHeader.tsx +436 -0
- package/src/components/DockPanelHeader.tsx +69 -0
- package/src/components/LocalLlmSettingsModal.test.tsx +372 -0
- package/src/components/LocalLlmSettingsModal.tsx +72 -0
- package/src/components/TerminalPanel.tsx +68 -0
- package/src/components/TerminalView.tsx +228 -0
- package/src/components/chat/ApprovalModal.test.tsx +83 -0
- package/src/components/chat/ApprovalModal.tsx +229 -0
- package/src/components/chat/AskUserModalHost.test.tsx +176 -0
- package/src/components/chat/AskUserModalHost.tsx +61 -0
- package/src/components/chat/AskUserQuestionMenu.tsx +621 -0
- package/src/components/chat/AssistantMessage.test.tsx +68 -0
- package/src/components/chat/AssistantMessage.tsx +469 -0
- package/src/components/chat/ChatInput.test.tsx +609 -0
- package/src/components/chat/ChatInput.tsx +2068 -0
- package/src/components/chat/ChatProjectBadge.tsx +362 -0
- package/src/components/chat/EmptyChat.tsx +18 -0
- package/src/components/chat/ExecPolicyPicker.test.tsx +26 -0
- package/src/components/chat/ExecPolicyPicker.tsx +120 -0
- package/src/components/chat/ExecutedActionsCard.tsx +216 -0
- package/src/components/chat/InterruptedTurnCard.tsx +53 -0
- package/src/components/chat/LocalChatPanel.tsx +408 -0
- package/src/components/chat/Markdown.test.tsx +170 -0
- package/src/components/chat/Markdown.tsx +432 -0
- package/src/components/chat/MessageList.tsx +332 -0
- package/src/components/chat/ModelPicker.test.tsx +149 -0
- package/src/components/chat/ModelPicker.tsx +289 -0
- package/src/components/chat/OrchestrationChildrenCard.tsx +40 -0
- package/src/components/chat/PlanTodoList.tsx +121 -0
- package/src/components/chat/ProjectTrustBanner.tsx +91 -0
- package/src/components/chat/SessionTreeModal.test.tsx +161 -0
- package/src/components/chat/SessionTreeModal.tsx +221 -0
- package/src/components/chat/StreamingStatus.tsx +95 -0
- package/src/components/chat/TaskOutcomeCards.test.tsx +97 -0
- package/src/components/chat/TaskOutcomeCards.tsx +128 -0
- package/src/components/chat/TaskPanelModal.test.tsx +198 -0
- package/src/components/chat/TaskPanelModal.tsx +310 -0
- package/src/components/chat/TaskProcessPanel.test.tsx +111 -0
- package/src/components/chat/TaskProcessPanel.tsx +208 -0
- package/src/components/chat/TurnProcessGroup.test.tsx +161 -0
- package/src/components/chat/TurnProcessGroup.tsx +193 -0
- package/src/components/chat/UnifiedDiff.tsx +31 -0
- package/src/components/chat/UserMessage.tsx +155 -0
- package/src/components/chat/elapsed.test.ts +42 -0
- package/src/components/chat/elapsed.ts +45 -0
- package/src/components/chat/executed-actions-model.test.ts +134 -0
- package/src/components/chat/executed-actions-model.ts +143 -0
- package/src/components/chat/orchestration-children-model.test.ts +115 -0
- package/src/components/chat/orchestration-children-model.ts +104 -0
- package/src/components/chat/timestamp.ts +24 -0
- package/src/components/chat/turn-timeline.test.ts +97 -0
- package/src/components/chat/turn-timeline.ts +132 -0
- package/src/components/chat/useChatTasks.test.ts +136 -0
- package/src/components/chat/useChatTasks.ts +134 -0
- package/src/components/chat/useCopy.ts +62 -0
- package/src/components/settings/AgentCapabilityFields.tsx +343 -0
- package/src/components/settings/AgentsSettingsPanel.test.tsx +229 -0
- package/src/components/settings/AgentsSettingsPanel.tsx +412 -0
- package/src/components/settings/DiagnoseSettingsPanel.tsx +119 -0
- package/src/components/settings/InsightsSettingsPanel.test.tsx +132 -0
- package/src/components/settings/InsightsSettingsPanel.tsx +309 -0
- package/src/components/settings/LlmSettingsPanel.tsx +875 -0
- package/src/components/settings/McpSettingsPanel.tsx +466 -0
- package/src/components/settings/ModelCapabilityChips.tsx +46 -0
- package/src/components/settings/ModelIdCombobox.test.tsx +88 -0
- package/src/components/settings/ModelIdCombobox.tsx +185 -0
- package/src/components/settings/SecuritySettingsPanel.test.tsx +122 -0
- package/src/components/settings/SecuritySettingsPanel.tsx +169 -0
- package/src/components/settings/SettingsSaveButton.tsx +35 -0
- package/src/components/settings/SkillsSettingsPanel.test.tsx +62 -0
- package/src/components/settings/SkillsSettingsPanel.tsx +334 -0
- package/src/components/settings/TelemetrySettingsPanel.tsx +173 -0
- package/src/components/settings/UsagePanel.tsx +196 -0
- package/src/components/settings/WebSearchSettingsPanel.test.tsx +90 -0
- package/src/components/settings/WebSearchSettingsPanel.tsx +183 -0
- package/src/components/settings/compat-flags-model.test.ts +71 -0
- package/src/components/settings/compat-flags-model.ts +57 -0
- package/src/components/settings/llm-vendors.test.ts +171 -0
- package/src/components/settings/llm-vendors.ts +275 -0
- package/src/components/settings/model-capabilities.test.ts +88 -0
- package/src/components/settings/model-capabilities.ts +123 -0
- package/src/components/settings/preset-choice-model.test.ts +96 -0
- package/src/components/settings/preset-choice-model.ts +75 -0
- package/src/fixtures/browser-dev-data.json +315 -0
- package/src/hooks/useChatsAndAgents.ts +182 -0
- package/src/layouts/AgentLayout.tsx +574 -0
- package/src/lib/attachments.ts +78 -0
- package/src/lib/browser-dev-electron-mock.ts +640 -0
- package/src/lib/chat-title.ts +33 -0
- package/src/lib/chat-transport.ts +346 -0
- package/src/lib/date-groups.ts +34 -0
- package/src/lib/demo-flag.ts +15 -0
- package/src/lib/electron-bridge.ts +270 -0
- package/src/lib/exec-policy.ts +25 -0
- package/src/lib/http-bridge.ts +293 -0
- package/src/lib/insights.ts +17 -0
- package/src/lib/local-api.ts +714 -0
- package/src/lib/pending-first-message.test.ts +17 -0
- package/src/lib/pending-first-message.ts +36 -0
- package/src/lib/slash-sources.ts +191 -0
- package/src/main.tsx +103 -0
- package/src/packs/registry.ts +64 -0
- package/src/pages/AgentPage.tsx +1226 -0
- package/src/pages/ChatPanelPreviewPage.tsx +264 -0
- package/src/pages/SettingsPage.test.tsx +102 -0
- package/src/pages/SettingsPage.tsx +197 -0
- package/src/styles/index.css +156 -0
- package/src/vite-env.d.ts +1 -0
- package/vite.base.ts +94 -0
|
@@ -0,0 +1,2068 @@
|
|
|
1
|
+
import {
|
|
2
|
+
forwardRef,
|
|
3
|
+
useCallback,
|
|
4
|
+
useEffect,
|
|
5
|
+
useImperativeHandle,
|
|
6
|
+
useLayoutEffect,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
type ClipboardEvent,
|
|
11
|
+
type FormEvent,
|
|
12
|
+
type KeyboardEvent,
|
|
13
|
+
type PointerEvent as ReactPointerEvent,
|
|
14
|
+
type ReactNode,
|
|
15
|
+
type RefObject,
|
|
16
|
+
} from 'react';
|
|
17
|
+
import { useNavigate } from 'react-router-dom';
|
|
18
|
+
import {
|
|
19
|
+
LuArrowUp,
|
|
20
|
+
LuBot,
|
|
21
|
+
LuChevronDown,
|
|
22
|
+
LuSettings,
|
|
23
|
+
LuSquare,
|
|
24
|
+
LuBlocks,
|
|
25
|
+
LuPlug,
|
|
26
|
+
LuFile,
|
|
27
|
+
LuPaperclip,
|
|
28
|
+
LuX,
|
|
29
|
+
LuInfinity,
|
|
30
|
+
LuListChecks,
|
|
31
|
+
LuMessageSquare,
|
|
32
|
+
} from 'react-icons/lu';
|
|
33
|
+
import type { LocalChat, LocalChatAgent } from '@/lib/local-api';
|
|
34
|
+
import type { ExecPolicy } from '@/lib/exec-policy';
|
|
35
|
+
import type { AttachmentFile } from '@/lib/attachments';
|
|
36
|
+
import type { SteerOutcome } from '@steerable/agent-ui';
|
|
37
|
+
import {
|
|
38
|
+
isHiddenSlashSkill,
|
|
39
|
+
resolveSlashTool,
|
|
40
|
+
SLASH_TOKEN_PATTERN,
|
|
41
|
+
useSlashSources,
|
|
42
|
+
type McpToolItem,
|
|
43
|
+
type SkillItem,
|
|
44
|
+
} from '@/lib/slash-sources';
|
|
45
|
+
import { ExecPolicyPicker } from './ExecPolicyPicker';
|
|
46
|
+
|
|
47
|
+
export type ChatMode = 'agent' | 'plan';
|
|
48
|
+
export type { ExecPolicy, McpToolItem, SkillItem };
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* ChatInput — Tier-1 port of `deeppath`'s ChatInput.
|
|
52
|
+
*
|
|
53
|
+
* Coverage (Phase 2a):
|
|
54
|
+
* - Bordered card container with shadow-sm + rounded-lg (matches the cloud
|
|
55
|
+
* product's `chat-input-box`).
|
|
56
|
+
* - Auto-growing rich text input (1 → 8 lines) that grows as the user types.
|
|
57
|
+
* - `Enter` to send, `Shift+Enter` for newline (`Cmd/Ctrl+Enter` also sends;
|
|
58
|
+
* IME composition is guarded so confirming a candidate never submits).
|
|
59
|
+
* - Send button on the right; turns into a Stop button when streaming.
|
|
60
|
+
* - Externally-controlled value (`LocalChatPanel` lifts it so EmptyChat can
|
|
61
|
+
* inject prompts via `setAndFocusInputMessage`).
|
|
62
|
+
*
|
|
63
|
+
* Phase 2b additions:
|
|
64
|
+
* - Agent picker in the meta row above the input box (next to the project
|
|
65
|
+
* badge). Clicking an expert switches who this composer talks to
|
|
66
|
+
* (`onSelectAgent`). Extra experts are still added by typing `@` in the
|
|
67
|
+
* input.
|
|
68
|
+
* - Settings gear — fires `onOpenSettings` so the parent (LocalChatPanel /
|
|
69
|
+
* AgentPage) can open the LocalLlmSettingsModal. Keeping modal ownership
|
|
70
|
+
* out of ChatInput avoids dragging state up just so this component can
|
|
71
|
+
* mount it.
|
|
72
|
+
*
|
|
73
|
+
* Deferred:
|
|
74
|
+
* - Multi-agent / runtime model picker (cloud product's ChatToolbar).
|
|
75
|
+
* - `FileUpload` + drag-and-drop overlay.
|
|
76
|
+
* - `AutomationAlert` (banner for upload progress / automation triggers).
|
|
77
|
+
* - Compact-mode responsive layout.
|
|
78
|
+
*/
|
|
79
|
+
|
|
80
|
+
const MIN_HEIGHT_PX = 44;
|
|
81
|
+
const MAX_HEIGHT_PX = 220;
|
|
82
|
+
const MENTION_PATTERN = /(@[^\s@]+)/g;
|
|
83
|
+
const MAX_MENTION_SUGGESTIONS = 8;
|
|
84
|
+
|
|
85
|
+
// macOS uses ⌘; Linux/Windows users see ⌃ instead so the tooltip matches the
|
|
86
|
+
// shortcut they can actually press. We detect via `navigator.platform` —
|
|
87
|
+
// `userAgent` works too but is more brittle as Apple drops Mac branding.
|
|
88
|
+
const IS_MAC =
|
|
89
|
+
typeof navigator !== 'undefined' &&
|
|
90
|
+
/Mac|iPod|iPhone|iPad/.test(navigator.platform || '');
|
|
91
|
+
const MOD_KEY_LABEL = IS_MAC ? '⌘' : 'Ctrl';
|
|
92
|
+
|
|
93
|
+
export interface ChatInputHandle {
|
|
94
|
+
focus: () => void;
|
|
95
|
+
focusAtEnd: () => void;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface ChatInputProps {
|
|
99
|
+
value: string;
|
|
100
|
+
onChange: (next: string) => void;
|
|
101
|
+
onSubmit: () => void | Promise<void>;
|
|
102
|
+
onCancel?: () => void;
|
|
103
|
+
/**
|
|
104
|
+
* 轮中转向(streaming 期间 Enter):优先把文本注入运行中的回合;注入
|
|
105
|
+
* 失败由 hook 兜底——回合仍在跑则排入 W6-2 follow-up 队列('queued',
|
|
106
|
+
* 本组件提示"已改为排队"),回合恰好已结束则作为新消息直接发出
|
|
107
|
+
* ('sent')。三种结果消息都已落地,调用方据此清空草稿。
|
|
108
|
+
*/
|
|
109
|
+
onSteer?: (text: string) => Promise<SteerOutcome>;
|
|
110
|
+
/**
|
|
111
|
+
* W6-2 follow-up 队列:streaming 期间按 ⌘/Ctrl+Enter 把当前文本排入
|
|
112
|
+
* 待发队列,本轮结束后自动作为下一轮发出(与 Enter 的轮中转向相对)。
|
|
113
|
+
*/
|
|
114
|
+
onFollowUp?: (text: string) => void;
|
|
115
|
+
/** 当前排队待发的 follow-up 文本(用于在输入框上方展示待发队列)。 */
|
|
116
|
+
pendingFollowUps?: string[];
|
|
117
|
+
/** 撤回第 N 条排队中的 follow-up。 */
|
|
118
|
+
onRemoveFollowUp?: (index: number) => void;
|
|
119
|
+
isStreaming?: boolean;
|
|
120
|
+
disabled?: boolean;
|
|
121
|
+
placeholder?: string;
|
|
122
|
+
/** Active agent for this chat. */
|
|
123
|
+
currentAgent?: LocalChatAgent | null;
|
|
124
|
+
/** Agent catalog for the meta-row selector above the input box. */
|
|
125
|
+
agents?: LocalChatAgent[];
|
|
126
|
+
/** Recent chats that can be referenced with @. */
|
|
127
|
+
chats?: LocalChat[];
|
|
128
|
+
/** Shared selection used for the next new chat. */
|
|
129
|
+
selectedAgentId?: string | null;
|
|
130
|
+
/** Called when the user chooses an agent from the meta-row selector. */
|
|
131
|
+
onSelectAgent?: (agentId: string) => void | Promise<void>;
|
|
132
|
+
/** Settings gear callback. When unset, the gear button is hidden. */
|
|
133
|
+
onOpenSettings?: () => void;
|
|
134
|
+
/** Extra controls on the left toolbar (e.g. the model/effort picker).
|
|
135
|
+
* Rendered after the mode toggle and before the settings gear. */
|
|
136
|
+
toolbarExtras?: ReactNode;
|
|
137
|
+
/** Slot in the composer meta row above the input box, before the agent
|
|
138
|
+
* picker (e.g. the project badge). */
|
|
139
|
+
leadingChrome?: ReactNode;
|
|
140
|
+
/** Current chat mode. When unset the toggle is hidden (defaults to 'agent'). */
|
|
141
|
+
mode?: ChatMode;
|
|
142
|
+
/** Called when the user switches between Agent / Plan mode. */
|
|
143
|
+
onModeChange?: (mode: ChatMode) => void;
|
|
144
|
+
/** 命令沙箱档(工作区 / 完整权限)。未传 onChange 时不显示选择器。 */
|
|
145
|
+
execPolicy?: ExecPolicy;
|
|
146
|
+
/** Called when the user switches the per-turn exec sandbox policy. */
|
|
147
|
+
onExecPolicyChange?: (policy: ExecPolicy) => void;
|
|
148
|
+
/** Attached files (drag-and-drop / file picker). */
|
|
149
|
+
files?: AttachmentFile[];
|
|
150
|
+
/** Callback when files change */
|
|
151
|
+
onFilesChange?: (files: AttachmentFile[]) => void;
|
|
152
|
+
/** Structured @ references selected from the input menu. */
|
|
153
|
+
onMentionReferencesChange?: (references: MentionReference[]) => void;
|
|
154
|
+
/** Local skills for slash command autocomplete and chip rendering. */
|
|
155
|
+
skills?: SkillItem[];
|
|
156
|
+
/** MCP tools for slash command autocomplete and chip rendering. */
|
|
157
|
+
mcpTools?: McpToolItem[];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export type MentionReference = {
|
|
161
|
+
type: 'agent' | 'chat' | 'skill' | 'mcp';
|
|
162
|
+
id: string;
|
|
163
|
+
label: string;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
type MentionSuggestion = MentionReference & {
|
|
167
|
+
description?: string | null;
|
|
168
|
+
color?: string | null;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
type MentionQuery = {
|
|
172
|
+
start: number;
|
|
173
|
+
end: number;
|
|
174
|
+
query: string;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// ── "/" 触发器:本地技能 + MCP 工具 ─────────────────────────────────────
|
|
178
|
+
// 两类候选混排在同一个弹层里(技能在前、MCP 工具在后,分组标题隔开),
|
|
179
|
+
// 选中后统一把 insertToken 以 "/<token> " 形式写回输入框。MCP token 即
|
|
180
|
+
// 后端一等工具名 mcp__<serverKey>__<toolName>,cleanUserMessage 会识别。
|
|
181
|
+
// 候选清单与内置技能的隐藏规则由 `@/lib/slash-sources` 单点持有,消息气泡
|
|
182
|
+
// 里的工具卡片走同一份数据。
|
|
183
|
+
|
|
184
|
+
type SlashSuggestion =
|
|
185
|
+
| { kind: 'skill'; insertToken: string; skill: SkillItem }
|
|
186
|
+
| { kind: 'mcp'; insertToken: string; mcp: McpToolItem };
|
|
187
|
+
|
|
188
|
+
type MentionChipRange = {
|
|
189
|
+
start: number;
|
|
190
|
+
end: number;
|
|
191
|
+
text: string;
|
|
192
|
+
ref?: MentionReference;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
function mentionChipClassName(ref?: MentionReference): string {
|
|
196
|
+
const base = 'rounded-agent-sm px-1.5 py-0.5 font-medium';
|
|
197
|
+
if (ref?.type === 'agent') {
|
|
198
|
+
return `${base} bg-indigo-500/10 text-indigo-600 outline outline-1 outline-indigo-500/20 -outline-offset-1 dark:bg-indigo-500/20 dark:text-indigo-400`;
|
|
199
|
+
}
|
|
200
|
+
if (ref?.type === 'chat') {
|
|
201
|
+
return `${base} bg-emerald-500/10 text-emerald-600 outline outline-1 outline-emerald-500/20 -outline-offset-1 dark:bg-emerald-500/20 dark:text-emerald-400`;
|
|
202
|
+
}
|
|
203
|
+
if (ref?.type === 'skill') {
|
|
204
|
+
return `${base} bg-amber-500/10 text-amber-600 outline outline-1 outline-amber-500/20 -outline-offset-1 dark:bg-amber-500/20 dark:text-amber-400`;
|
|
205
|
+
}
|
|
206
|
+
if (ref?.type === 'mcp') {
|
|
207
|
+
return `${base} bg-sky-500/10 text-sky-600 outline outline-1 outline-sky-500/20 -outline-offset-1 dark:bg-sky-500/20 dark:text-sky-400`;
|
|
208
|
+
}
|
|
209
|
+
return `${base} bg-agent-foreground/10 text-agent-foreground`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function getEditableText(element: HTMLDivElement): string {
|
|
213
|
+
return (element.textContent ?? '').replace(/\u00a0/g, ' ');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function isEditorDomEmpty(editor: HTMLDivElement): boolean {
|
|
217
|
+
return getEditableText(editor).length === 0;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function hasEmptyImeScaffold(editor: HTMLDivElement): boolean {
|
|
221
|
+
if (!isEditorDomEmpty(editor)) return false;
|
|
222
|
+
const hasTextNode = Array.from(editor.childNodes).some(
|
|
223
|
+
(node) => node.nodeType === Node.TEXT_NODE,
|
|
224
|
+
);
|
|
225
|
+
return hasTextNode && Boolean(editor.querySelector('br'));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function parseMentionSegments(
|
|
229
|
+
value: string,
|
|
230
|
+
mentionReferences: MentionReference[] = [],
|
|
231
|
+
agents: LocalChatAgent[] = [],
|
|
232
|
+
chats: LocalChat[] = [],
|
|
233
|
+
skills: SkillItem[] = [],
|
|
234
|
+
mcpTools: McpToolItem[] = [],
|
|
235
|
+
): Array<
|
|
236
|
+
| { type: 'text'; text: string }
|
|
237
|
+
| { type: 'br' }
|
|
238
|
+
| { type: 'mention'; start: number; end: number; text: string; ref?: MentionReference }
|
|
239
|
+
> {
|
|
240
|
+
const segments: Array<
|
|
241
|
+
| { type: 'text'; text: string }
|
|
242
|
+
| { type: 'br' }
|
|
243
|
+
| { type: 'mention'; start: number; end: number; text: string; ref?: MentionReference }
|
|
244
|
+
> = [];
|
|
245
|
+
const lines = value.split('\n');
|
|
246
|
+
let lineStartIdx = 0;
|
|
247
|
+
|
|
248
|
+
lines.forEach((line, lineIndex) => {
|
|
249
|
+
let lastIndex = 0;
|
|
250
|
+
const ranges: MentionChipRange[] = [];
|
|
251
|
+
|
|
252
|
+
mentionReferences.forEach((ref) => {
|
|
253
|
+
const prefix = ref.type === 'skill' || ref.type === 'mcp' ? '/' : '@';
|
|
254
|
+
const token = `${prefix}${ref.label}`;
|
|
255
|
+
let from = 0;
|
|
256
|
+
while (from < line.length) {
|
|
257
|
+
const start = line.indexOf(token, from);
|
|
258
|
+
if (start === -1) break;
|
|
259
|
+
const end = start + token.length;
|
|
260
|
+
if (!ranges.some((range) => start < range.end && end > range.start)) {
|
|
261
|
+
ranges.push({ start, end, text: token, ref });
|
|
262
|
+
}
|
|
263
|
+
from = end;
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
line.replace(MENTION_PATTERN, (match, _mention, offset) => {
|
|
268
|
+
const end = offset + match.length;
|
|
269
|
+
if (!ranges.some((range) => offset < range.end && end > range.start)) {
|
|
270
|
+
const name = match.slice(1);
|
|
271
|
+
const agent = agents.find((a) => a.name === name || a.slug === name);
|
|
272
|
+
const chat = chats.find((c) => (c.title || '未命名对话') === name);
|
|
273
|
+
const ref: MentionReference | undefined = agent
|
|
274
|
+
? { type: 'agent', id: agent.id, label: agent.name }
|
|
275
|
+
: chat
|
|
276
|
+
? { type: 'chat', id: chat.id, label: chat.title || '未命名对话' }
|
|
277
|
+
: undefined;
|
|
278
|
+
|
|
279
|
+
ranges.push({ start: offset, end, text: match, ref });
|
|
280
|
+
}
|
|
281
|
+
return match;
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
const slashRegex = new RegExp(SLASH_TOKEN_PATTERN.source, 'g');
|
|
285
|
+
let slashMatch: RegExpExecArray | null;
|
|
286
|
+
while ((slashMatch = slashRegex.exec(line)) !== null) {
|
|
287
|
+
const fullMatch = slashMatch[0];
|
|
288
|
+
const token = slashMatch[1];
|
|
289
|
+
const offset = slashMatch.index + (fullMatch.length - token.length);
|
|
290
|
+
const end = offset + token.length;
|
|
291
|
+
|
|
292
|
+
if (ranges.some((range) => offset < range.end && end > range.start)) continue;
|
|
293
|
+
|
|
294
|
+
const ref = resolveSlashTool(token.slice(1), skills, mcpTools);
|
|
295
|
+
if (ref) ranges.push({ start: offset, end, text: token, ref });
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
ranges.sort((a, b) => a.start - b.start);
|
|
299
|
+
|
|
300
|
+
ranges.forEach((range) => {
|
|
301
|
+
if (range.start > lastIndex) {
|
|
302
|
+
segments.push({ type: 'text', text: line.slice(lastIndex, range.start) });
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const globalStart = lineStartIdx + range.start;
|
|
306
|
+
const globalEnd = lineStartIdx + range.end;
|
|
307
|
+
|
|
308
|
+
segments.push({
|
|
309
|
+
type: 'mention',
|
|
310
|
+
start: globalStart,
|
|
311
|
+
end: globalEnd,
|
|
312
|
+
text: range.text,
|
|
313
|
+
ref: range.ref,
|
|
314
|
+
});
|
|
315
|
+
lastIndex = range.end;
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
if (lastIndex < line.length) {
|
|
319
|
+
segments.push({ type: 'text', text: line.slice(lastIndex) });
|
|
320
|
+
}
|
|
321
|
+
if (lineIndex < lines.length - 1) {
|
|
322
|
+
segments.push({ type: 'br' });
|
|
323
|
+
}
|
|
324
|
+
lineStartIdx += line.length + 1;
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
return segments;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Empty editors need a text node (IME marked-text attachment) plus a `<br>`
|
|
332
|
+
* (caret height). Replacing this while focused kills the first Pinyin letter.
|
|
333
|
+
*/
|
|
334
|
+
function writeEditorValue(
|
|
335
|
+
editor: HTMLDivElement,
|
|
336
|
+
next: string,
|
|
337
|
+
mentionReferences: MentionReference[] = [],
|
|
338
|
+
agents: LocalChatAgent[] = [],
|
|
339
|
+
chats: LocalChat[] = [],
|
|
340
|
+
onRemoveMention?: (start: number, end: number, ref?: MentionReference) => void,
|
|
341
|
+
skills: SkillItem[] = [],
|
|
342
|
+
mcpTools: McpToolItem[] = [],
|
|
343
|
+
) {
|
|
344
|
+
if (next.length === 0) {
|
|
345
|
+
editor.replaceChildren(document.createTextNode(''), document.createElement('br'));
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const segments = parseMentionSegments(next, mentionReferences, agents, chats, skills, mcpTools);
|
|
350
|
+
const hasMentions = segments.some((s) => s.type === 'mention');
|
|
351
|
+
if (!hasMentions) {
|
|
352
|
+
editor.textContent = next;
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const nodes: Node[] = [];
|
|
357
|
+
segments.forEach((seg) => {
|
|
358
|
+
if (seg.type === 'text') {
|
|
359
|
+
nodes.push(document.createTextNode(seg.text));
|
|
360
|
+
} else if (seg.type === 'br') {
|
|
361
|
+
nodes.push(document.createElement('br'));
|
|
362
|
+
} else {
|
|
363
|
+
const isAgent = seg.ref?.type === 'agent';
|
|
364
|
+
const isChat = seg.ref?.type === 'chat';
|
|
365
|
+
const isSkill = seg.ref?.type === 'skill';
|
|
366
|
+
const isMcp = seg.ref?.type === 'mcp';
|
|
367
|
+
const removeLabel = `移除 ${seg.text}`;
|
|
368
|
+
|
|
369
|
+
const chip = document.createElement('button');
|
|
370
|
+
chip.type = 'button';
|
|
371
|
+
chip.contentEditable = 'false';
|
|
372
|
+
chip.setAttribute('data-mention-chip', '');
|
|
373
|
+
chip.setAttribute('data-mention-type', seg.ref?.type ?? 'plain');
|
|
374
|
+
chip.setAttribute('data-testid', `mention-chip-${seg.ref?.id ?? seg.text}`);
|
|
375
|
+
chip.setAttribute('aria-label', removeLabel);
|
|
376
|
+
chip.setAttribute('title', removeLabel);
|
|
377
|
+
chip.tabIndex = -1;
|
|
378
|
+
chip.className = `group relative inline-flex items-center cursor-pointer select-none align-baseline ${mentionChipClassName(seg.ref)}`;
|
|
379
|
+
|
|
380
|
+
chip.onmousedown = (event) => {
|
|
381
|
+
event.preventDefault();
|
|
382
|
+
event.stopPropagation();
|
|
383
|
+
};
|
|
384
|
+
chip.onclick = (event) => {
|
|
385
|
+
event.preventDefault();
|
|
386
|
+
event.stopPropagation();
|
|
387
|
+
onRemoveMention?.(seg.start, seg.end, seg.ref);
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
if (isSkill) {
|
|
391
|
+
const iconSpan = document.createElement('span');
|
|
392
|
+
iconSpan.setAttribute('aria-hidden', 'true');
|
|
393
|
+
iconSpan.className = 'pointer-events-none mr-1 inline-flex items-center shrink-0 text-amber-600 dark:text-amber-400';
|
|
394
|
+
iconSpan.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-3 w-3"><rect width="7" height="7" x="14" y="3" rx="1"/><path d="M10 21V8a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-5a1 1 0 0 0-1-1H3"/></svg>';
|
|
395
|
+
chip.appendChild(iconSpan);
|
|
396
|
+
} else if (isMcp) {
|
|
397
|
+
const iconSpan = document.createElement('span');
|
|
398
|
+
iconSpan.setAttribute('aria-hidden', 'true');
|
|
399
|
+
iconSpan.className = 'pointer-events-none mr-1 inline-flex items-center shrink-0 text-sky-600 dark:text-sky-400';
|
|
400
|
+
iconSpan.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-3 w-3"><path d="M12 22v-5"/><path d="M9 8V2"/><path d="M15 8V2"/><path d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z"/></svg>';
|
|
401
|
+
chip.appendChild(iconSpan);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
chip.appendChild(document.createTextNode(seg.text));
|
|
405
|
+
|
|
406
|
+
if (onRemoveMention) {
|
|
407
|
+
const removeSpan = document.createElement('span');
|
|
408
|
+
removeSpan.setAttribute('data-mention-remove', '');
|
|
409
|
+
removeSpan.className = `pointer-events-none absolute -right-1 -top-1 flex h-3.5 w-3.5 items-center justify-center rounded-full text-white opacity-0 shadow-sm transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100 ${
|
|
410
|
+
isAgent
|
|
411
|
+
? 'bg-indigo-500'
|
|
412
|
+
: isChat
|
|
413
|
+
? 'bg-emerald-500'
|
|
414
|
+
: isSkill
|
|
415
|
+
? 'bg-amber-500'
|
|
416
|
+
: isMcp
|
|
417
|
+
? 'bg-sky-500'
|
|
418
|
+
: 'bg-agent-muted-foreground'
|
|
419
|
+
}`;
|
|
420
|
+
removeSpan.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="h-2.5 w-2.5"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>';
|
|
421
|
+
chip.appendChild(removeSpan);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
nodes.push(chip);
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
const last = nodes[nodes.length - 1];
|
|
429
|
+
if (last instanceof HTMLButtonElement) {
|
|
430
|
+
nodes.push(document.createTextNode(''));
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
editor.replaceChildren(...nodes);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function placeCaretInEmptyEditor(editor: HTMLDivElement) {
|
|
437
|
+
const text = Array.from(editor.childNodes).find(
|
|
438
|
+
(node): node is Text => node.nodeType === Node.TEXT_NODE,
|
|
439
|
+
);
|
|
440
|
+
if (!text) return;
|
|
441
|
+
const range = document.createRange();
|
|
442
|
+
range.setStart(text, 0);
|
|
443
|
+
range.collapse(true);
|
|
444
|
+
const selection = window.getSelection();
|
|
445
|
+
selection?.removeAllRanges();
|
|
446
|
+
selection?.addRange(range);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function updateEditorHeight(editor: HTMLDivElement) {
|
|
450
|
+
editor.style.height = 'auto';
|
|
451
|
+
const next = Math.min(Math.max(editor.scrollHeight, MIN_HEIGHT_PX), MAX_HEIGHT_PX);
|
|
452
|
+
editor.style.height = `${next}px`;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function offsetFromPoint(root: HTMLDivElement, x: number, y: number): number | null {
|
|
456
|
+
const doc = document as Document & {
|
|
457
|
+
caretPositionFromPoint?: (x: number, y: number) => { offsetNode: Node; offset: number } | null;
|
|
458
|
+
caretRangeFromPoint?: (x: number, y: number) => Range | null;
|
|
459
|
+
};
|
|
460
|
+
if (typeof doc.caretPositionFromPoint === 'function') {
|
|
461
|
+
const pos = doc.caretPositionFromPoint(x, y);
|
|
462
|
+
if (!pos || !root.contains(pos.offsetNode)) return null;
|
|
463
|
+
const measure = document.createRange();
|
|
464
|
+
measure.selectNodeContents(root);
|
|
465
|
+
measure.setEnd(pos.offsetNode, pos.offset);
|
|
466
|
+
return measure.toString().length;
|
|
467
|
+
}
|
|
468
|
+
const range = doc.caretRangeFromPoint?.(x, y);
|
|
469
|
+
if (!range || !root.contains(range.startContainer)) return null;
|
|
470
|
+
const measure = document.createRange();
|
|
471
|
+
measure.selectNodeContents(root);
|
|
472
|
+
measure.setEnd(range.startContainer, range.startOffset);
|
|
473
|
+
return measure.toString().length;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function getSelectionOffsets(root: HTMLDivElement): { start: number; end: number } {
|
|
477
|
+
const selection = window.getSelection();
|
|
478
|
+
if (!selection || selection.rangeCount === 0) {
|
|
479
|
+
const end = root.innerText.length;
|
|
480
|
+
return { start: end, end };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const range = selection.getRangeAt(0);
|
|
484
|
+
if (
|
|
485
|
+
!root.contains(range.startContainer) ||
|
|
486
|
+
!root.contains(range.endContainer)
|
|
487
|
+
) {
|
|
488
|
+
const end = root.textContent?.length ?? 0;
|
|
489
|
+
return { start: end, end };
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const getOffset = (container: Node, offset: number) => {
|
|
493
|
+
const measureRange = document.createRange();
|
|
494
|
+
measureRange.selectNodeContents(root);
|
|
495
|
+
measureRange.setEnd(container, offset);
|
|
496
|
+
return measureRange.toString().length;
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
return {
|
|
500
|
+
start: getOffset(range.startContainer, range.startOffset),
|
|
501
|
+
end: getOffset(range.endContainer, range.endOffset),
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function setCaretOffset(root: HTMLDivElement, offset: number) {
|
|
506
|
+
const range = document.createRange();
|
|
507
|
+
const selection = window.getSelection();
|
|
508
|
+
let remaining = offset;
|
|
509
|
+
let placed = false;
|
|
510
|
+
|
|
511
|
+
const walk = (node: Node): boolean => {
|
|
512
|
+
if (
|
|
513
|
+
node instanceof HTMLElement &&
|
|
514
|
+
(node.getAttribute('contenteditable') === 'false' || node.tagName === 'BUTTON')
|
|
515
|
+
) {
|
|
516
|
+
const len = node.textContent?.length ?? 0;
|
|
517
|
+
if (remaining === 0) {
|
|
518
|
+
range.setStartBefore(node);
|
|
519
|
+
range.collapse(true);
|
|
520
|
+
placed = true;
|
|
521
|
+
return true;
|
|
522
|
+
}
|
|
523
|
+
if (remaining <= len) {
|
|
524
|
+
range.setStartAfter(node);
|
|
525
|
+
range.collapse(true);
|
|
526
|
+
placed = true;
|
|
527
|
+
return true;
|
|
528
|
+
}
|
|
529
|
+
remaining -= len;
|
|
530
|
+
return false;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
534
|
+
const textLength = node.textContent?.length ?? 0;
|
|
535
|
+
if (remaining <= textLength) {
|
|
536
|
+
range.setStart(node, remaining);
|
|
537
|
+
range.collapse(true);
|
|
538
|
+
placed = true;
|
|
539
|
+
return true;
|
|
540
|
+
}
|
|
541
|
+
remaining -= textLength;
|
|
542
|
+
return false;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (node instanceof HTMLBRElement) {
|
|
546
|
+
if (remaining <= 1) {
|
|
547
|
+
range.setStartAfter(node);
|
|
548
|
+
range.collapse(true);
|
|
549
|
+
placed = true;
|
|
550
|
+
return true;
|
|
551
|
+
}
|
|
552
|
+
remaining -= 1;
|
|
553
|
+
return false;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
for (const child of Array.from(node.childNodes)) {
|
|
557
|
+
if (walk(child)) return true;
|
|
558
|
+
}
|
|
559
|
+
return false;
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
walk(root);
|
|
563
|
+
|
|
564
|
+
if (!placed) {
|
|
565
|
+
range.selectNodeContents(root);
|
|
566
|
+
range.collapse(false);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
selection?.removeAllRanges();
|
|
570
|
+
selection?.addRange(range);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function getMentionQuery(value: string, caretOffset: number): MentionQuery | null {
|
|
574
|
+
const beforeCaret = value.slice(0, caretOffset);
|
|
575
|
+
const tokenStart = beforeCaret.search(/(?:^|\s)@\S*$/);
|
|
576
|
+
if (tokenStart === -1) return null;
|
|
577
|
+
|
|
578
|
+
const prefix = beforeCaret[tokenStart];
|
|
579
|
+
const start = prefix === '@' ? tokenStart : tokenStart + 1;
|
|
580
|
+
const token = value.slice(start, caretOffset);
|
|
581
|
+
if (!token.startsWith('@')) return null;
|
|
582
|
+
|
|
583
|
+
return {
|
|
584
|
+
start,
|
|
585
|
+
end: caretOffset,
|
|
586
|
+
query: token.slice(1).toLowerCase(),
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* "/" 技能 token 检测 — 与 getMentionQuery 同构,基于光标位置而不是整个
|
|
592
|
+
* 输入框内容,所以输入框已有文字时在任意位置输入 "/" 也能触发技能池。
|
|
593
|
+
* token 必须出现在行首或空白之后(排除 "C:/path"、"https://" 这类路径 /
|
|
594
|
+
* URL 中间的斜杠),且 token 内不允许再出现 "/"(排除 "/mnt/c" 这类路径)。
|
|
595
|
+
*/
|
|
596
|
+
function getSlashQuery(value: string, caretOffset: number): MentionQuery | null {
|
|
597
|
+
const beforeCaret = value.slice(0, caretOffset);
|
|
598
|
+
const tokenStart = beforeCaret.search(/(?:^|\s)\/[^\s/]*$/);
|
|
599
|
+
if (tokenStart === -1) return null;
|
|
600
|
+
|
|
601
|
+
const prefix = beforeCaret[tokenStart];
|
|
602
|
+
const start = prefix === '/' ? tokenStart : tokenStart + 1;
|
|
603
|
+
const token = value.slice(start, caretOffset);
|
|
604
|
+
if (!token.startsWith('/')) return null;
|
|
605
|
+
|
|
606
|
+
return {
|
|
607
|
+
start,
|
|
608
|
+
end: caretOffset,
|
|
609
|
+
query: token.slice(1).toLowerCase(),
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function getMentionRanges(
|
|
614
|
+
val: string,
|
|
615
|
+
refs: MentionReference[],
|
|
616
|
+
): Array<{ start: number; end: number; ref: MentionReference }> {
|
|
617
|
+
const ranges: Array<{ start: number; end: number; ref: MentionReference }> = [];
|
|
618
|
+
if (refs.length === 0) return ranges;
|
|
619
|
+
|
|
620
|
+
const lines = val.split('\n');
|
|
621
|
+
let lineStartIdx = 0;
|
|
622
|
+
|
|
623
|
+
const sortedRefs = [...refs].sort((a, b) => b.label.length - a.label.length);
|
|
624
|
+
|
|
625
|
+
lines.forEach((line) => {
|
|
626
|
+
const lineRanges: Array<{ start: number; end: number; ref: MentionReference }> = [];
|
|
627
|
+
|
|
628
|
+
sortedRefs.forEach((ref) => {
|
|
629
|
+
const prefix = ref.type === 'skill' || ref.type === 'mcp' ? '/' : '@';
|
|
630
|
+
const tokenWithSpace = `${prefix}${ref.label} `;
|
|
631
|
+
const tokenWithoutSpace = `${prefix}${ref.label}`;
|
|
632
|
+
|
|
633
|
+
// Search for token with space first
|
|
634
|
+
let from = 0;
|
|
635
|
+
while (from < line.length) {
|
|
636
|
+
const start = line.indexOf(tokenWithSpace, from);
|
|
637
|
+
if (start === -1) break;
|
|
638
|
+
const end = start + tokenWithSpace.length;
|
|
639
|
+
if (!lineRanges.some((r) => start < r.end && end > r.start)) {
|
|
640
|
+
lineRanges.push({
|
|
641
|
+
start: lineStartIdx + start,
|
|
642
|
+
end: lineStartIdx + end,
|
|
643
|
+
ref,
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
from = end;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Search for token without space
|
|
650
|
+
from = 0;
|
|
651
|
+
while (from < line.length) {
|
|
652
|
+
const start = line.indexOf(tokenWithoutSpace, from);
|
|
653
|
+
if (start === -1) break;
|
|
654
|
+
const end = start + tokenWithoutSpace.length;
|
|
655
|
+
if (!lineRanges.some((r) => start < r.end && end > r.start)) {
|
|
656
|
+
lineRanges.push({
|
|
657
|
+
start: lineStartIdx + start,
|
|
658
|
+
end: lineStartIdx + end,
|
|
659
|
+
ref,
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
from = end;
|
|
663
|
+
}
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
ranges.push(...lineRanges);
|
|
667
|
+
lineStartIdx += line.length + 1; // +1 for \n
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
return ranges;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|
674
|
+
function ChatInput(
|
|
675
|
+
{
|
|
676
|
+
value,
|
|
677
|
+
onChange,
|
|
678
|
+
onSubmit,
|
|
679
|
+
onCancel,
|
|
680
|
+
onSteer,
|
|
681
|
+
onFollowUp,
|
|
682
|
+
pendingFollowUps = [],
|
|
683
|
+
onRemoveFollowUp,
|
|
684
|
+
isStreaming = false,
|
|
685
|
+
disabled = false,
|
|
686
|
+
placeholder = '向 Agent 发送消息…',
|
|
687
|
+
currentAgent,
|
|
688
|
+
agents = [],
|
|
689
|
+
chats = [],
|
|
690
|
+
selectedAgentId,
|
|
691
|
+
onSelectAgent,
|
|
692
|
+
onOpenSettings,
|
|
693
|
+
toolbarExtras,
|
|
694
|
+
leadingChrome,
|
|
695
|
+
mode,
|
|
696
|
+
onModeChange,
|
|
697
|
+
execPolicy,
|
|
698
|
+
onExecPolicyChange,
|
|
699
|
+
files,
|
|
700
|
+
onFilesChange,
|
|
701
|
+
onMentionReferencesChange,
|
|
702
|
+
skills: propSkills,
|
|
703
|
+
mcpTools: propMcpTools,
|
|
704
|
+
},
|
|
705
|
+
ref,
|
|
706
|
+
) {
|
|
707
|
+
const editorRef = useRef<HTMLDivElement>(null);
|
|
708
|
+
const agentMenuRef = useRef<HTMLDivElement>(null);
|
|
709
|
+
const skillOptionRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
|
710
|
+
const mentionOptionRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
|
711
|
+
const pendingCaretOffsetRef = useRef<number | null>(null);
|
|
712
|
+
// Overlay editor + IME: never rewrite textContent/selection while the
|
|
713
|
+
// user is composing. macOS Pinyin otherwise commits the first letter as
|
|
714
|
+
// Latin instead of starting a marked-text session.
|
|
715
|
+
const isComposingRef = useRef(false);
|
|
716
|
+
// Last value we pushed from the editor (or accepted from the parent).
|
|
717
|
+
// While focused, a matching value must not rewrite contenteditable DOM —
|
|
718
|
+
// that clobber is what turns the first Pinyin letter into ASCII.
|
|
719
|
+
const lastPushedValueRef = useRef(value);
|
|
720
|
+
// Chromium can fire `input` for the first Pinyin letter *before*
|
|
721
|
+
// `compositionstart`. A pending sync is tagged with this generation so
|
|
722
|
+
// compositionstart can cancel it. Use a macrotask (not a microtask):
|
|
723
|
+
// compositionstart often lands in a later task than `input`.
|
|
724
|
+
const inputSyncGenRef = useRef(0);
|
|
725
|
+
const inputSyncTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
726
|
+
// Some IMEs (e.g. Microsoft Pinyin, Sogou) fire `compositionend` and then
|
|
727
|
+
// a `keydown` for the same Enter keystroke used to confirm the candidate,
|
|
728
|
+
// with `isComposing` already `false` by the time keydown runs. Without
|
|
729
|
+
// this guard that Enter falls through to the "insert newline" branch below
|
|
730
|
+
// and the user ends up with a stray blank line after committing text.
|
|
731
|
+
const compositionEndAtRef = useRef<number>(0);
|
|
732
|
+
|
|
733
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
734
|
+
const [localFiles, setLocalFiles] = useState<AttachmentFile[]>([]);
|
|
735
|
+
const actualFiles = files ?? localFiles;
|
|
736
|
+
const actualOnFilesChange = onFilesChange ?? setLocalFiles;
|
|
737
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
738
|
+
|
|
739
|
+
const slashSources = useSlashSources();
|
|
740
|
+
const skills = useMemo(
|
|
741
|
+
() => (propSkills ?? slashSources.skills).filter((s) => !isHiddenSlashSkill(s)),
|
|
742
|
+
[propSkills, slashSources.skills],
|
|
743
|
+
);
|
|
744
|
+
const mcpTools = propMcpTools ?? slashSources.mcpTools;
|
|
745
|
+
const [suggestions, setSuggestions] = useState<SlashSuggestion[]>([]);
|
|
746
|
+
const [showSuggestions, setShowSuggestions] = useState(false);
|
|
747
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
748
|
+
const [agentMenuOpen, setAgentMenuOpen] = useState(false);
|
|
749
|
+
const [slashQuery, setSlashQuery] = useState<MentionQuery | null>(null);
|
|
750
|
+
const [mentionQuery, setMentionQuery] = useState<MentionQuery | null>(null);
|
|
751
|
+
const [selectedMentionIndex, setSelectedMentionIndex] = useState(0);
|
|
752
|
+
const [mentionReferences, setMentionReferences] = useState<MentionReference[]>([]);
|
|
753
|
+
// 转向降级提示:Enter 转向失败被兜底进 W6-2 队列时告知用户"不是注入",
|
|
754
|
+
// 几秒后自动消失(队列横幅本身常驻,提示只是动作反馈)。
|
|
755
|
+
const [steerNotice, setSteerNotice] = useState<string | null>(null);
|
|
756
|
+
const steerNoticeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
757
|
+
|
|
758
|
+
const showSteerNotice = useCallback((text: string) => {
|
|
759
|
+
if (steerNoticeTimerRef.current) clearTimeout(steerNoticeTimerRef.current);
|
|
760
|
+
setSteerNotice(text);
|
|
761
|
+
steerNoticeTimerRef.current = setTimeout(() => setSteerNotice(null), 5000);
|
|
762
|
+
}, []);
|
|
763
|
+
|
|
764
|
+
useEffect(
|
|
765
|
+
() => () => {
|
|
766
|
+
if (steerNoticeTimerRef.current) clearTimeout(steerNoticeTimerRef.current);
|
|
767
|
+
if (inputSyncTimerRef.current) clearTimeout(inputSyncTimerRef.current);
|
|
768
|
+
},
|
|
769
|
+
[],
|
|
770
|
+
);
|
|
771
|
+
|
|
772
|
+
const sortedAgents = useMemo(() => {
|
|
773
|
+
return [...agents].sort((a, b) => {
|
|
774
|
+
if (a.isBuiltin !== b.isBuiltin) return a.isBuiltin ? -1 : 1;
|
|
775
|
+
const orderA = a.sortOrder ?? 0;
|
|
776
|
+
const orderB = b.sortOrder ?? 0;
|
|
777
|
+
if (orderA !== orderB) return orderA - orderB;
|
|
778
|
+
return a.name.localeCompare(b.name, 'zh-CN');
|
|
779
|
+
});
|
|
780
|
+
}, [agents]);
|
|
781
|
+
|
|
782
|
+
const selectedAgent =
|
|
783
|
+
sortedAgents.find((agent) => agent.id === selectedAgentId) ??
|
|
784
|
+
currentAgent ??
|
|
785
|
+
sortedAgents[0] ??
|
|
786
|
+
null;
|
|
787
|
+
|
|
788
|
+
const mentionSuggestions = useMemo<MentionSuggestion[]>(() => {
|
|
789
|
+
if (!mentionQuery) return [];
|
|
790
|
+
const query = mentionQuery.query;
|
|
791
|
+
const matchesQuery = (text: string | null | undefined) =>
|
|
792
|
+
!query || (text ?? '').toLowerCase().includes(query);
|
|
793
|
+
|
|
794
|
+
const agentSuggestions: MentionSuggestion[] = sortedAgents
|
|
795
|
+
.filter((agent) => matchesQuery(agent.name) || matchesQuery(agent.slug))
|
|
796
|
+
.map((agent) => ({
|
|
797
|
+
type: 'agent',
|
|
798
|
+
id: agent.id,
|
|
799
|
+
label: agent.name,
|
|
800
|
+
description: agent.description,
|
|
801
|
+
color: agent.color,
|
|
802
|
+
}));
|
|
803
|
+
|
|
804
|
+
const chatSuggestions: MentionSuggestion[] = chats
|
|
805
|
+
.filter((chat) => matchesQuery(chat.title))
|
|
806
|
+
.slice(0, MAX_MENTION_SUGGESTIONS)
|
|
807
|
+
.map((chat) => ({
|
|
808
|
+
type: 'chat',
|
|
809
|
+
id: chat.id,
|
|
810
|
+
label: chat.title || '未命名对话',
|
|
811
|
+
description: chat.updatedAt ? `历史对话 · ${new Date(chat.updatedAt).toLocaleString()}` : '历史对话',
|
|
812
|
+
}));
|
|
813
|
+
|
|
814
|
+
return [...agentSuggestions, ...chatSuggestions].slice(0, MAX_MENTION_SUGGESTIONS);
|
|
815
|
+
}, [chats, mentionQuery, sortedAgents]);
|
|
816
|
+
|
|
817
|
+
useEffect(() => {
|
|
818
|
+
if (selectedMentionIndex >= mentionSuggestions.length) {
|
|
819
|
+
setSelectedMentionIndex(0);
|
|
820
|
+
}
|
|
821
|
+
}, [mentionSuggestions.length, selectedMentionIndex]);
|
|
822
|
+
|
|
823
|
+
useEffect(() => {
|
|
824
|
+
onMentionReferencesChange?.(mentionReferences);
|
|
825
|
+
}, [mentionReferences, onMentionReferencesChange]);
|
|
826
|
+
|
|
827
|
+
useLayoutEffect(() => {
|
|
828
|
+
if (!mentionQuery || mentionSuggestions.length === 0) return;
|
|
829
|
+
mentionOptionRefs.current[selectedMentionIndex]?.scrollIntoView({
|
|
830
|
+
block: 'nearest',
|
|
831
|
+
});
|
|
832
|
+
}, [mentionQuery, mentionSuggestions.length, selectedMentionIndex]);
|
|
833
|
+
|
|
834
|
+
useLayoutEffect(() => {
|
|
835
|
+
if (!showSuggestions || suggestions.length === 0) return;
|
|
836
|
+
skillOptionRefs.current[selectedIndex]?.scrollIntoView({
|
|
837
|
+
block: 'nearest',
|
|
838
|
+
});
|
|
839
|
+
}, [selectedIndex, showSuggestions, suggestions.length]);
|
|
840
|
+
|
|
841
|
+
useEffect(() => {
|
|
842
|
+
setMentionReferences((prev) =>
|
|
843
|
+
prev.filter((ref) => value.includes(`@${ref.label}`)),
|
|
844
|
+
);
|
|
845
|
+
if (!value) {
|
|
846
|
+
setMentionQuery(null);
|
|
847
|
+
setSlashQuery(null);
|
|
848
|
+
}
|
|
849
|
+
}, [value]);
|
|
850
|
+
|
|
851
|
+
// 外部直接改写 value(如 EmptyChat 注入提示词)不会走 handleInput,
|
|
852
|
+
// slashQuery 里的偏移量可能失效——校验 token 还在原位,不在就清掉。
|
|
853
|
+
useEffect(() => {
|
|
854
|
+
if (!slashQuery) return;
|
|
855
|
+
const token = value.slice(slashQuery.start, slashQuery.end);
|
|
856
|
+
if (!token.startsWith('/') || token.slice(1).toLowerCase() !== slashQuery.query) {
|
|
857
|
+
setSlashQuery(null);
|
|
858
|
+
}
|
|
859
|
+
}, [value, slashQuery]);
|
|
860
|
+
|
|
861
|
+
// 弹层每次打开时刷新候选:MCP 服务/技能可能在设置里刚增删或重新连接,
|
|
862
|
+
// 只在挂载时拉一次会拿到过期清单(2026-07-31 用户反馈 slash 里看不到
|
|
863
|
+
// 已启动的 MCP)。首次拉取由 useSlashSources 自己完成。
|
|
864
|
+
const refreshSlashSources = slashSources.refresh;
|
|
865
|
+
useEffect(() => {
|
|
866
|
+
if (slashQuery) void refreshSlashSources();
|
|
867
|
+
}, [slashQuery, refreshSlashSources]);
|
|
868
|
+
|
|
869
|
+
// 技能池由光标处的 "/" token 驱动(getSlashQuery)——输入框已有内容时
|
|
870
|
+
// 在任意位置输入 "/" 同样触发,不再要求整段内容以 "/" 开头且不含空格。
|
|
871
|
+
// 候选 = 本地技能 + MCP 工具,统一成 SlashSuggestion 扁平列表(键盘导航
|
|
872
|
+
// 只认这个扁平顺序),渲染时再按 kind 分组加标题。
|
|
873
|
+
useEffect(() => {
|
|
874
|
+
if (slashQuery) {
|
|
875
|
+
const query = slashQuery.query;
|
|
876
|
+
const skillMatches: SlashSuggestion[] = skills
|
|
877
|
+
.filter(
|
|
878
|
+
(s) =>
|
|
879
|
+
s.name.toLowerCase().includes(query) ||
|
|
880
|
+
(s.displayName && s.displayName.toLowerCase().includes(query)) ||
|
|
881
|
+
(s.description && s.description.toLowerCase().includes(query))
|
|
882
|
+
)
|
|
883
|
+
.map((s) => ({ kind: 'skill' as const, insertToken: s.name, skill: s }));
|
|
884
|
+
const mcpMatches: SlashSuggestion[] = mcpTools
|
|
885
|
+
.filter(
|
|
886
|
+
(t) =>
|
|
887
|
+
t.toolName.toLowerCase().includes(query) ||
|
|
888
|
+
t.serverName.toLowerCase().includes(query) ||
|
|
889
|
+
t.token.toLowerCase().includes(query) ||
|
|
890
|
+
(t.description && t.description.toLowerCase().includes(query))
|
|
891
|
+
)
|
|
892
|
+
.map((t) => ({ kind: 'mcp' as const, insertToken: t.token, mcp: t }));
|
|
893
|
+
const combined = [...skillMatches, ...mcpMatches];
|
|
894
|
+
setSuggestions(combined);
|
|
895
|
+
setShowSuggestions(combined.length > 0);
|
|
896
|
+
setSelectedIndex(0);
|
|
897
|
+
} else {
|
|
898
|
+
setShowSuggestions(false);
|
|
899
|
+
}
|
|
900
|
+
}, [slashQuery, skills, mcpTools]);
|
|
901
|
+
|
|
902
|
+
// 渲染行:分组标题 + 候选项(idx 对应扁平 suggestions 的下标,供高亮/键盘导航)
|
|
903
|
+
const suggestionRows = useMemo(() => {
|
|
904
|
+
const rows: Array<
|
|
905
|
+
{ type: 'header'; label: string } | { type: 'item'; item: SlashSuggestion; idx: number }
|
|
906
|
+
> = [];
|
|
907
|
+
const skillGroup = suggestions.filter((s) => s.kind === 'skill');
|
|
908
|
+
const mcpGroup = suggestions.filter((s) => s.kind === 'mcp');
|
|
909
|
+
let idx = 0;
|
|
910
|
+
if (skillGroup.length > 0) {
|
|
911
|
+
rows.push({ type: 'header', label: '指定运行的本地技能 (Skill)' });
|
|
912
|
+
for (const s of skillGroup) rows.push({ type: 'item', item: s, idx: idx++ });
|
|
913
|
+
}
|
|
914
|
+
if (mcpGroup.length > 0) {
|
|
915
|
+
rows.push({ type: 'header', label: '指定调用的 MCP 工具 (MCP Tool)' });
|
|
916
|
+
for (const s of mcpGroup) rows.push({ type: 'item', item: s, idx: idx++ });
|
|
917
|
+
}
|
|
918
|
+
return rows;
|
|
919
|
+
}, [suggestions]);
|
|
920
|
+
|
|
921
|
+
useEffect(() => {
|
|
922
|
+
if (!agentMenuOpen) return;
|
|
923
|
+
const handlePointerDown = (event: PointerEvent) => {
|
|
924
|
+
const target = event.target;
|
|
925
|
+
if (
|
|
926
|
+
target instanceof Node &&
|
|
927
|
+
agentMenuRef.current?.contains(target)
|
|
928
|
+
) {
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
setAgentMenuOpen(false);
|
|
932
|
+
};
|
|
933
|
+
window.addEventListener('pointerdown', handlePointerDown);
|
|
934
|
+
return () => window.removeEventListener('pointerdown', handlePointerDown);
|
|
935
|
+
}, [agentMenuOpen]);
|
|
936
|
+
|
|
937
|
+
const handleSelectSuggestion = (suggestion: SlashSuggestion) => {
|
|
938
|
+
const token = `/${suggestion.insertToken} `;
|
|
939
|
+
// 只替换光标处的 "/xxx" token,保留输入框里已有的其它内容。
|
|
940
|
+
const range = slashQuery ?? { start: 0, end: value.length };
|
|
941
|
+
const nextValue = `${value.slice(0, range.start)}${token}${value.slice(range.end)}`;
|
|
942
|
+
const caret = range.start + token.length;
|
|
943
|
+
pendingCaretOffsetRef.current = caret;
|
|
944
|
+
const refType = suggestion.kind === 'skill' ? 'skill' : 'mcp';
|
|
945
|
+
setMentionReferences((prev) => {
|
|
946
|
+
const withoutDuplicate = prev.filter(
|
|
947
|
+
(item) => !(item.type === refType && item.id === suggestion.insertToken),
|
|
948
|
+
);
|
|
949
|
+
return [
|
|
950
|
+
...withoutDuplicate,
|
|
951
|
+
{
|
|
952
|
+
type: refType,
|
|
953
|
+
id: suggestion.insertToken,
|
|
954
|
+
label: suggestion.insertToken,
|
|
955
|
+
},
|
|
956
|
+
];
|
|
957
|
+
});
|
|
958
|
+
onChange(nextValue);
|
|
959
|
+
setSlashQuery(null);
|
|
960
|
+
setShowSuggestions(false);
|
|
961
|
+
setTimeout(() => {
|
|
962
|
+
const editor = editorRef.current;
|
|
963
|
+
if (editor) {
|
|
964
|
+
editor.focus();
|
|
965
|
+
setCaretOffset(editor, Math.min(caret, getEditableText(editor).length));
|
|
966
|
+
}
|
|
967
|
+
}, 50);
|
|
968
|
+
};
|
|
969
|
+
|
|
970
|
+
const syncMentionQuery = (nextValue: string, caretOffset: number) => {
|
|
971
|
+
const nextQuery = getMentionQuery(nextValue, caretOffset);
|
|
972
|
+
setMentionQuery(nextQuery);
|
|
973
|
+
if (nextQuery) {
|
|
974
|
+
setSelectedMentionIndex(0);
|
|
975
|
+
}
|
|
976
|
+
setSlashQuery(getSlashQuery(nextValue, caretOffset));
|
|
977
|
+
};
|
|
978
|
+
|
|
979
|
+
useImperativeHandle(
|
|
980
|
+
ref,
|
|
981
|
+
() => ({
|
|
982
|
+
focus: () => editorRef.current?.focus(),
|
|
983
|
+
focusAtEnd: () => {
|
|
984
|
+
const editor = editorRef.current;
|
|
985
|
+
if (!editor) return;
|
|
986
|
+
editor.focus();
|
|
987
|
+
setCaretOffset(editor, value.length);
|
|
988
|
+
},
|
|
989
|
+
}),
|
|
990
|
+
[value.length],
|
|
991
|
+
);
|
|
992
|
+
|
|
993
|
+
// Keep the browser-owned contentEditable DOM as plain text. React renders
|
|
994
|
+
// mention chip backgrounds in the mirror layer to avoid reconciling
|
|
995
|
+
// editable nodes. Never clobber glyphs from React while IME is attaching
|
|
996
|
+
// or while the focused editor already owns this value.
|
|
997
|
+
useLayoutEffect(() => {
|
|
998
|
+
const editor = editorRef.current;
|
|
999
|
+
if (!editor || isComposingRef.current || editor.hasAttribute('data-composing')) {
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
const editorText = getEditableText(editor);
|
|
1003
|
+
const focused = document.activeElement === editor;
|
|
1004
|
+
const pendingCaretOffset = pendingCaretOffsetRef.current;
|
|
1005
|
+
const parentChangedValue = value !== lastPushedValueRef.current;
|
|
1006
|
+
|
|
1007
|
+
if (focused && !parentChangedValue && pendingCaretOffset === null) {
|
|
1008
|
+
updateEditorHeight(editor);
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
if (editorText !== value || pendingCaretOffset !== null) {
|
|
1013
|
+
writeEditorValue(
|
|
1014
|
+
editor,
|
|
1015
|
+
value,
|
|
1016
|
+
mentionReferences,
|
|
1017
|
+
agents,
|
|
1018
|
+
chats,
|
|
1019
|
+
disabled ? undefined : removeMentionToken,
|
|
1020
|
+
skills,
|
|
1021
|
+
mcpTools,
|
|
1022
|
+
);
|
|
1023
|
+
} else if (!value && !hasEmptyImeScaffold(editor)) {
|
|
1024
|
+
writeEditorValue(editor, '');
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
lastPushedValueRef.current = value;
|
|
1028
|
+
updateEditorHeight(editor);
|
|
1029
|
+
|
|
1030
|
+
if (pendingCaretOffset !== null && focused) {
|
|
1031
|
+
setCaretOffset(editor, Math.min(pendingCaretOffset, value.length));
|
|
1032
|
+
pendingCaretOffsetRef.current = null;
|
|
1033
|
+
}
|
|
1034
|
+
}, [value, mentionReferences, agents, chats, disabled, skills, mcpTools]);
|
|
1035
|
+
|
|
1036
|
+
// `Cmd+.` / `Ctrl+.` cancels the in-flight stream — matches macOS's
|
|
1037
|
+
// conventional "interrupt" chord (Xcode, Terminal). Bound at the window
|
|
1038
|
+
// level so users can hit it without the textarea being focused.
|
|
1039
|
+
// Confined to the `isStreaming` window so it never fights with other
|
|
1040
|
+
// app-level shortcuts when the chat is idle.
|
|
1041
|
+
useEffect(() => {
|
|
1042
|
+
if (!isStreaming || !onCancel) return;
|
|
1043
|
+
const onKey = (event: globalThis.KeyboardEvent) => {
|
|
1044
|
+
if ((event.metaKey || event.ctrlKey) && event.key === '.') {
|
|
1045
|
+
event.preventDefault();
|
|
1046
|
+
onCancel();
|
|
1047
|
+
}
|
|
1048
|
+
};
|
|
1049
|
+
window.addEventListener('keydown', onKey);
|
|
1050
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
1051
|
+
}, [isStreaming, onCancel]);
|
|
1052
|
+
|
|
1053
|
+
const trimmed = value.trim();
|
|
1054
|
+
const canSend = (trimmed.length > 0 || actualFiles.length > 0) && !disabled && !isStreaming;
|
|
1055
|
+
|
|
1056
|
+
const handleDragOver = (event: React.DragEvent) => {
|
|
1057
|
+
event.preventDefault();
|
|
1058
|
+
if (event.dataTransfer.types.includes('Files') && !disabled) {
|
|
1059
|
+
setIsDragging(true);
|
|
1060
|
+
}
|
|
1061
|
+
};
|
|
1062
|
+
|
|
1063
|
+
const handleDragLeave = (event: React.DragEvent) => {
|
|
1064
|
+
event.preventDefault();
|
|
1065
|
+
setIsDragging(false);
|
|
1066
|
+
};
|
|
1067
|
+
|
|
1068
|
+
// 把新文件并入列表:按 path(有路径时)或 name(浏览器模式无路径)去重。
|
|
1069
|
+
const addFiles = (incoming: AttachmentFile[]) => {
|
|
1070
|
+
const keyOf = (f: AttachmentFile) => f.path || f.name;
|
|
1071
|
+
const existingKeys = new Set(actualFiles.map(keyOf));
|
|
1072
|
+
const filteredNewFiles = incoming.filter((f) => !existingKeys.has(keyOf(f)));
|
|
1073
|
+
if (filteredNewFiles.length > 0) {
|
|
1074
|
+
actualOnFilesChange([...actualFiles, ...filteredNewFiles]);
|
|
1075
|
+
}
|
|
1076
|
+
};
|
|
1077
|
+
const toAttachment = (file: File): AttachmentFile => ({
|
|
1078
|
+
name: file.name,
|
|
1079
|
+
// Electron 的 File 对象带真实路径;浏览器模式没有路径(提交时读字节)。
|
|
1080
|
+
path: (file as { path?: string }).path || '',
|
|
1081
|
+
file,
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
// 按钮点击 → 触发隐藏的 <input type="file" multiple>,由浏览器/Electron
|
|
1085
|
+
// 直接弹出系统文件选择框(不依赖 IPC,避免「点击无反应」)。
|
|
1086
|
+
const handlePickFiles = () => {
|
|
1087
|
+
if (disabled) return;
|
|
1088
|
+
fileInputRef.current?.click();
|
|
1089
|
+
};
|
|
1090
|
+
const handleFileInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
1091
|
+
const selected = Array.from(event.target.files ?? []);
|
|
1092
|
+
// 重置 value,允许再次选择同名文件时仍触发 change。
|
|
1093
|
+
event.target.value = '';
|
|
1094
|
+
if (selected.length === 0) return;
|
|
1095
|
+
addFiles(selected.map(toAttachment));
|
|
1096
|
+
};
|
|
1097
|
+
|
|
1098
|
+
const handleDrop = (event: React.DragEvent) => {
|
|
1099
|
+
event.preventDefault();
|
|
1100
|
+
setIsDragging(false);
|
|
1101
|
+
|
|
1102
|
+
if (disabled) return;
|
|
1103
|
+
|
|
1104
|
+
const droppedFiles = Array.from(event.dataTransfer.files);
|
|
1105
|
+
if (droppedFiles.length > 0) {
|
|
1106
|
+
addFiles(droppedFiles.map(toAttachment));
|
|
1107
|
+
}
|
|
1108
|
+
};
|
|
1109
|
+
|
|
1110
|
+
const handleRemoveFile = (indexToRemove: number) => {
|
|
1111
|
+
actualOnFilesChange(actualFiles.filter((_, idx) => idx !== indexToRemove));
|
|
1112
|
+
};
|
|
1113
|
+
|
|
1114
|
+
const replaceSelection = (insertText: string) => {
|
|
1115
|
+
const editor = editorRef.current;
|
|
1116
|
+
if (!editor) return;
|
|
1117
|
+
const { start, end } = getSelectionOffsets(editor);
|
|
1118
|
+
const nextValue = `${value.slice(0, start)}${insertText}${value.slice(end)}`;
|
|
1119
|
+
pendingCaretOffsetRef.current = start + insertText.length;
|
|
1120
|
+
onChange(nextValue);
|
|
1121
|
+
syncMentionQuery(nextValue, pendingCaretOffsetRef.current);
|
|
1122
|
+
};
|
|
1123
|
+
|
|
1124
|
+
const replaceRange = (start: number, end: number, insertText: string) => {
|
|
1125
|
+
const nextValue = `${value.slice(0, start)}${insertText}${value.slice(end)}`;
|
|
1126
|
+
pendingCaretOffsetRef.current = start + insertText.length;
|
|
1127
|
+
onChange(nextValue);
|
|
1128
|
+
syncMentionQuery(nextValue, pendingCaretOffsetRef.current);
|
|
1129
|
+
};
|
|
1130
|
+
|
|
1131
|
+
const removeMentionToken = (start: number, end: number, ref?: MentionReference) => {
|
|
1132
|
+
let removeEnd = end;
|
|
1133
|
+
if (value[removeEnd] === ' ') removeEnd += 1;
|
|
1134
|
+
replaceRange(start, removeEnd, '');
|
|
1135
|
+
if (ref) {
|
|
1136
|
+
setMentionReferences((prev) =>
|
|
1137
|
+
prev.filter((item) => !(item.type === ref.type && item.id === ref.id)),
|
|
1138
|
+
);
|
|
1139
|
+
}
|
|
1140
|
+
};
|
|
1141
|
+
|
|
1142
|
+
const handleEditorPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
|
|
1143
|
+
if (event.button !== 0) return;
|
|
1144
|
+
const editor = editorRef.current;
|
|
1145
|
+
if (!editor) return;
|
|
1146
|
+
const offset = offsetFromPoint(editor, event.clientX, event.clientY);
|
|
1147
|
+
if (offset == null) return;
|
|
1148
|
+
const ranges = getMentionRanges(value, mentionReferences);
|
|
1149
|
+
const matched = ranges.find((range) => range.start <= offset && offset < range.end);
|
|
1150
|
+
if (!matched) return;
|
|
1151
|
+
event.preventDefault();
|
|
1152
|
+
removeMentionToken(matched.start, matched.end, matched.ref);
|
|
1153
|
+
};
|
|
1154
|
+
|
|
1155
|
+
const handleSelectMention = (suggestion: MentionSuggestion) => {
|
|
1156
|
+
if (!mentionQuery) return;
|
|
1157
|
+
const token = `@${suggestion.label} `;
|
|
1158
|
+
replaceRange(mentionQuery.start, mentionQuery.end, token);
|
|
1159
|
+
setMentionReferences((prev) => {
|
|
1160
|
+
const withoutDuplicate = prev.filter(
|
|
1161
|
+
(item) => !(item.type === suggestion.type && item.id === suggestion.id),
|
|
1162
|
+
);
|
|
1163
|
+
return [
|
|
1164
|
+
...withoutDuplicate,
|
|
1165
|
+
{
|
|
1166
|
+
type: suggestion.type,
|
|
1167
|
+
id: suggestion.id,
|
|
1168
|
+
label: suggestion.label,
|
|
1169
|
+
},
|
|
1170
|
+
];
|
|
1171
|
+
});
|
|
1172
|
+
setMentionQuery(null);
|
|
1173
|
+
};
|
|
1174
|
+
|
|
1175
|
+
const cancelPendingInputSync = () => {
|
|
1176
|
+
inputSyncGenRef.current += 1;
|
|
1177
|
+
if (inputSyncTimerRef.current !== null) {
|
|
1178
|
+
clearTimeout(inputSyncTimerRef.current);
|
|
1179
|
+
inputSyncTimerRef.current = null;
|
|
1180
|
+
}
|
|
1181
|
+
};
|
|
1182
|
+
|
|
1183
|
+
const markComposing = () => {
|
|
1184
|
+
isComposingRef.current = true;
|
|
1185
|
+
cancelPendingInputSync();
|
|
1186
|
+
editorRef.current?.setAttribute('data-composing', 'true');
|
|
1187
|
+
};
|
|
1188
|
+
|
|
1189
|
+
const syncFromEditor = () => {
|
|
1190
|
+
const editor = editorRef.current;
|
|
1191
|
+
if (!editor) return;
|
|
1192
|
+
const { end } = getSelectionOffsets(editor);
|
|
1193
|
+
const nextValue = getEditableText(editor);
|
|
1194
|
+
lastPushedValueRef.current = nextValue;
|
|
1195
|
+
pendingCaretOffsetRef.current = end;
|
|
1196
|
+
onChange(nextValue);
|
|
1197
|
+
syncMentionQuery(nextValue, end);
|
|
1198
|
+
};
|
|
1199
|
+
|
|
1200
|
+
const handleInput = (event: FormEvent<HTMLDivElement>) => {
|
|
1201
|
+
if (
|
|
1202
|
+
isComposingRef.current ||
|
|
1203
|
+
Boolean((event.nativeEvent as { isComposing?: boolean }).isComposing)
|
|
1204
|
+
) {
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
// Defer past this task so a late `compositionstart` (first Pinyin letter
|
|
1208
|
+
// on Chromium) can cancel the sync. Immediate onChange mounts the
|
|
1209
|
+
// highlight mirror and kills the IME session.
|
|
1210
|
+
const gen = ++inputSyncGenRef.current;
|
|
1211
|
+
if (inputSyncTimerRef.current !== null) {
|
|
1212
|
+
clearTimeout(inputSyncTimerRef.current);
|
|
1213
|
+
}
|
|
1214
|
+
inputSyncTimerRef.current = setTimeout(() => {
|
|
1215
|
+
inputSyncTimerRef.current = null;
|
|
1216
|
+
if (gen !== inputSyncGenRef.current) return;
|
|
1217
|
+
if (isComposingRef.current) return;
|
|
1218
|
+
editorRef.current?.removeAttribute('data-composing');
|
|
1219
|
+
syncFromEditor();
|
|
1220
|
+
}, 0);
|
|
1221
|
+
};
|
|
1222
|
+
|
|
1223
|
+
const handleBeforeInput = (event: FormEvent<HTMLDivElement>) => {
|
|
1224
|
+
const native = event.nativeEvent as unknown as InputEvent;
|
|
1225
|
+
if (
|
|
1226
|
+
native.inputType === 'insertCompositionText' ||
|
|
1227
|
+
native.inputType === 'insertFromComposition' ||
|
|
1228
|
+
native.isComposing
|
|
1229
|
+
) {
|
|
1230
|
+
markComposing();
|
|
1231
|
+
}
|
|
1232
|
+
};
|
|
1233
|
+
|
|
1234
|
+
const handleCompositionStart = () => {
|
|
1235
|
+
markComposing();
|
|
1236
|
+
};
|
|
1237
|
+
|
|
1238
|
+
const handleCompositionEnd = () => {
|
|
1239
|
+
isComposingRef.current = false;
|
|
1240
|
+
compositionEndAtRef.current = Date.now();
|
|
1241
|
+
editorRef.current?.removeAttribute('data-composing');
|
|
1242
|
+
syncFromEditor();
|
|
1243
|
+
// Safari can flush the committed glyphs after compositionend.
|
|
1244
|
+
requestAnimationFrame(() => {
|
|
1245
|
+
if (!isComposingRef.current) syncFromEditor();
|
|
1246
|
+
});
|
|
1247
|
+
};
|
|
1248
|
+
|
|
1249
|
+
const handleEditorFocus = () => {
|
|
1250
|
+
const editor = editorRef.current;
|
|
1251
|
+
if (!editor || !isEditorDomEmpty(editor)) return;
|
|
1252
|
+
const hasTextNode = Array.from(editor.childNodes).some(
|
|
1253
|
+
(node) => node.nodeType === Node.TEXT_NODE,
|
|
1254
|
+
);
|
|
1255
|
+
if (!hasTextNode) writeEditorValue(editor, '');
|
|
1256
|
+
placeCaretInEmptyEditor(editor);
|
|
1257
|
+
};
|
|
1258
|
+
|
|
1259
|
+
useLayoutEffect(() => {
|
|
1260
|
+
const editor = editorRef.current;
|
|
1261
|
+
if (!editor) return;
|
|
1262
|
+
const onCompositionStart = () => {
|
|
1263
|
+
isComposingRef.current = true;
|
|
1264
|
+
inputSyncGenRef.current += 1;
|
|
1265
|
+
if (inputSyncTimerRef.current !== null) {
|
|
1266
|
+
clearTimeout(inputSyncTimerRef.current);
|
|
1267
|
+
inputSyncTimerRef.current = null;
|
|
1268
|
+
}
|
|
1269
|
+
editor.setAttribute('data-composing', 'true');
|
|
1270
|
+
};
|
|
1271
|
+
editor.addEventListener('compositionstart', onCompositionStart, true);
|
|
1272
|
+
return () => {
|
|
1273
|
+
editor.removeEventListener('compositionstart', onCompositionStart, true);
|
|
1274
|
+
};
|
|
1275
|
+
}, []);
|
|
1276
|
+
|
|
1277
|
+
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
|
|
1278
|
+
event.preventDefault();
|
|
1279
|
+
replaceSelection(event.clipboardData.getData('text/plain'));
|
|
1280
|
+
};
|
|
1281
|
+
|
|
1282
|
+
const handleEditorScroll = () => {
|
|
1283
|
+
// no-op without mirror overlay
|
|
1284
|
+
};
|
|
1285
|
+
|
|
1286
|
+
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
|
1287
|
+
// Honor IME composition (`isComposing` true while Chinese input is mid-
|
|
1288
|
+
// selection) — only intercept on plain key events.
|
|
1289
|
+
if (isComposingRef.current || event.nativeEvent.isComposing) return;
|
|
1290
|
+
// Windows / some Chromium IMEs report keyCode 229 on the first
|
|
1291
|
+
// Pinyin keydown, before compositionstart.
|
|
1292
|
+
if (event.keyCode === 229) {
|
|
1293
|
+
markComposing();
|
|
1294
|
+
return;
|
|
1295
|
+
}
|
|
1296
|
+
// Hide the placeholder on the first letter of an empty editor so it
|
|
1297
|
+
// does not cover IME marked text. Do this even when `isComposing` is
|
|
1298
|
+
// still false (macOS Pinyin first keydown).
|
|
1299
|
+
if (
|
|
1300
|
+
!event.metaKey &&
|
|
1301
|
+
!event.ctrlKey &&
|
|
1302
|
+
!event.altKey &&
|
|
1303
|
+
event.key.length === 1 &&
|
|
1304
|
+
!value
|
|
1305
|
+
) {
|
|
1306
|
+
editorRef.current?.setAttribute('data-composing', 'true');
|
|
1307
|
+
}
|
|
1308
|
+
// See `compositionEndAtRef` above: treat an Enter that lands right after
|
|
1309
|
+
// `compositionend` as "confirm candidate", not "submit/newline".
|
|
1310
|
+
if (event.key === 'Enter' && Date.now() - compositionEndAtRef.current < 50) {
|
|
1311
|
+
return;
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
if (event.key === 'Backspace' || event.key === 'Delete') {
|
|
1315
|
+
const editor = editorRef.current;
|
|
1316
|
+
if (editor) {
|
|
1317
|
+
const { start, end } = getSelectionOffsets(editor);
|
|
1318
|
+
if (start === end) {
|
|
1319
|
+
const ranges = getMentionRanges(value, mentionReferences);
|
|
1320
|
+
const targetIdx = event.key === 'Backspace' ? end - 1 : end;
|
|
1321
|
+
const matchedRange = ranges.find(
|
|
1322
|
+
(r) => r.start <= targetIdx && targetIdx < r.end,
|
|
1323
|
+
);
|
|
1324
|
+
if (matchedRange) {
|
|
1325
|
+
event.preventDefault();
|
|
1326
|
+
removeMentionToken(matchedRange.start, matchedRange.end, matchedRange.ref);
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
if (mentionQuery && mentionSuggestions.length > 0) {
|
|
1334
|
+
if (event.key === 'ArrowDown') {
|
|
1335
|
+
event.preventDefault();
|
|
1336
|
+
setSelectedMentionIndex((prev) => (prev + 1) % mentionSuggestions.length);
|
|
1337
|
+
return;
|
|
1338
|
+
}
|
|
1339
|
+
if (event.key === 'ArrowUp') {
|
|
1340
|
+
event.preventDefault();
|
|
1341
|
+
setSelectedMentionIndex(
|
|
1342
|
+
(prev) => (prev - 1 + mentionSuggestions.length) % mentionSuggestions.length,
|
|
1343
|
+
);
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
if (event.key === 'Enter' || event.key === 'Tab') {
|
|
1347
|
+
event.preventDefault();
|
|
1348
|
+
handleSelectMention(mentionSuggestions[selectedMentionIndex]);
|
|
1349
|
+
return;
|
|
1350
|
+
}
|
|
1351
|
+
if (event.key === 'Escape') {
|
|
1352
|
+
event.preventDefault();
|
|
1353
|
+
setMentionQuery(null);
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
if (showSuggestions && suggestions.length > 0) {
|
|
1359
|
+
if (event.key === 'ArrowDown') {
|
|
1360
|
+
event.preventDefault();
|
|
1361
|
+
setSelectedIndex((prev) => (prev + 1) % suggestions.length);
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
if (event.key === 'ArrowUp') {
|
|
1365
|
+
event.preventDefault();
|
|
1366
|
+
setSelectedIndex((prev) => (prev - 1 + suggestions.length) % suggestions.length);
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
if (event.key === 'Enter' || event.key === 'Tab') {
|
|
1370
|
+
event.preventDefault();
|
|
1371
|
+
handleSelectSuggestion(suggestions[selectedIndex]);
|
|
1372
|
+
return;
|
|
1373
|
+
}
|
|
1374
|
+
if (event.key === 'Escape') {
|
|
1375
|
+
event.preventDefault();
|
|
1376
|
+
setSlashQuery(null);
|
|
1377
|
+
setShowSuggestions(false);
|
|
1378
|
+
return;
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
// Enter 发送,Shift+Enter 换行(⌘/Ctrl+Enter 作为老习惯保留,同样发送)。
|
|
1383
|
+
// IME 候选确认的 Enter 已被上面的 composition 守卫拦截,不会误发送。
|
|
1384
|
+
const isSendCombo =
|
|
1385
|
+
event.key === 'Enter' && !event.shiftKey;
|
|
1386
|
+
if (isSendCombo) {
|
|
1387
|
+
event.preventDefault();
|
|
1388
|
+
// streaming 期间:Enter = 轮中转向(注入运行中的回合;注入不了时
|
|
1389
|
+
// hook 兜底为 W6-2 排队或新回合直发,消息不会丢);⌘/Ctrl+Enter =
|
|
1390
|
+
// follow-up 排队(本轮结束后自动作为下一轮发出)。
|
|
1391
|
+
if (isStreaming) {
|
|
1392
|
+
if ((event.metaKey || event.ctrlKey) && onFollowUp && trimmed) {
|
|
1393
|
+
onFollowUp(trimmed);
|
|
1394
|
+
onChange('');
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1397
|
+
if (onSteer && trimmed) {
|
|
1398
|
+
void onSteer(trimmed).then((outcome) => {
|
|
1399
|
+
// steered / queued / sent 三种结果消息都已落地(注入当前回合 /
|
|
1400
|
+
// 进入待发队列 / 作为新回合发出),草稿都可以清;queued 额外
|
|
1401
|
+
// 提示用户"不是追加进当前回合"。
|
|
1402
|
+
if (outcome === 'queued') {
|
|
1403
|
+
showSteerNotice('当前回合无法追加,已加入排队,本轮结束后自动发出');
|
|
1404
|
+
}
|
|
1405
|
+
onChange('');
|
|
1406
|
+
});
|
|
1407
|
+
}
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
if (canSend) {
|
|
1411
|
+
void onSubmit();
|
|
1412
|
+
}
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
if (event.key === 'Enter') {
|
|
1417
|
+
// Shift+Enter — 换行。
|
|
1418
|
+
event.preventDefault();
|
|
1419
|
+
replaceSelection('\n');
|
|
1420
|
+
}
|
|
1421
|
+
};
|
|
1422
|
+
|
|
1423
|
+
const handleSendClick = () => {
|
|
1424
|
+
if (isStreaming) {
|
|
1425
|
+
onCancel?.();
|
|
1426
|
+
return;
|
|
1427
|
+
}
|
|
1428
|
+
if (canSend) {
|
|
1429
|
+
void onSubmit();
|
|
1430
|
+
}
|
|
1431
|
+
};
|
|
1432
|
+
|
|
1433
|
+
const handleSelectAgent = (agent: LocalChatAgent) => {
|
|
1434
|
+
setAgentMenuOpen(false);
|
|
1435
|
+
if (agent.id === selectedAgent?.id) return;
|
|
1436
|
+
void onSelectAgent?.(agent.id);
|
|
1437
|
+
|
|
1438
|
+
// Switching this expert to primary: drop a leftover @mention token so
|
|
1439
|
+
// the same persona is not both the bound agent and a mention.
|
|
1440
|
+
const isMentioned = mentionReferences.some(
|
|
1441
|
+
(ref) => ref.type === 'agent' && ref.id === agent.id,
|
|
1442
|
+
);
|
|
1443
|
+
if (isMentioned) {
|
|
1444
|
+
const escapeRegExp = (str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1445
|
+
const mentionRegex = new RegExp(`@${escapeRegExp(agent.name)}\\s?`, 'g');
|
|
1446
|
+
const nextValue = value.replace(mentionRegex, '');
|
|
1447
|
+
pendingCaretOffsetRef.current = nextValue.length;
|
|
1448
|
+
onChange(nextValue);
|
|
1449
|
+
setMentionReferences((prev) =>
|
|
1450
|
+
prev.filter((ref) => !(ref.type === 'agent' && ref.id === agent.id)),
|
|
1451
|
+
);
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
editorRef.current?.focus();
|
|
1455
|
+
};
|
|
1456
|
+
|
|
1457
|
+
return (
|
|
1458
|
+
// @container:底栏元素的显隐/宽度用容器查询(@sm = 384px)而不是
|
|
1459
|
+
// 视口断点——侧边栏占宽后,视口够宽但输入框本身可能已经很窄。
|
|
1460
|
+
<div className="chat-input-container @container relative px-3 pb-3 pt-1">
|
|
1461
|
+
{(leadingChrome || selectedAgent) && (
|
|
1462
|
+
<div
|
|
1463
|
+
className="relative mb-1 flex min-w-0 items-center gap-1.5"
|
|
1464
|
+
data-testid="composer-meta-row"
|
|
1465
|
+
>
|
|
1466
|
+
{leadingChrome}
|
|
1467
|
+
{selectedAgent && (
|
|
1468
|
+
<AgentSelect
|
|
1469
|
+
refEl={agentMenuRef}
|
|
1470
|
+
agent={selectedAgent}
|
|
1471
|
+
agents={sortedAgents}
|
|
1472
|
+
open={agentMenuOpen}
|
|
1473
|
+
disabled={disabled || isStreaming || !onSelectAgent}
|
|
1474
|
+
onOpenChange={setAgentMenuOpen}
|
|
1475
|
+
onSelect={handleSelectAgent}
|
|
1476
|
+
mentionReferences={mentionReferences}
|
|
1477
|
+
/>
|
|
1478
|
+
)}
|
|
1479
|
+
</div>
|
|
1480
|
+
)}
|
|
1481
|
+
{mentionQuery && mentionSuggestions.length > 0 && (
|
|
1482
|
+
<div className="absolute bottom-full left-3 z-50 mb-1 max-h-72 w-80 max-w-[calc(100%-0.75rem)] overflow-y-auto rounded-agent-md border border-agent-border bg-agent-canvas p-1 shadow-lg">
|
|
1483
|
+
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-agent-muted-foreground">
|
|
1484
|
+
@ 选择 Agent 或历史对话
|
|
1485
|
+
</div>
|
|
1486
|
+
{mentionSuggestions.map((item, idx) => (
|
|
1487
|
+
<button
|
|
1488
|
+
key={`${item.type}-${item.id}`}
|
|
1489
|
+
ref={(node) => {
|
|
1490
|
+
mentionOptionRefs.current[idx] = node;
|
|
1491
|
+
}}
|
|
1492
|
+
type="button"
|
|
1493
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
1494
|
+
onClick={() => handleSelectMention(item)}
|
|
1495
|
+
onMouseEnter={() => setSelectedMentionIndex(idx)}
|
|
1496
|
+
className={`flex w-full items-start gap-2 rounded px-2 py-2 text-left transition-colors ${
|
|
1497
|
+
idx === selectedMentionIndex
|
|
1498
|
+
? 'bg-agent-foreground/10 text-agent-foreground'
|
|
1499
|
+
: 'text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground'
|
|
1500
|
+
}`}
|
|
1501
|
+
>
|
|
1502
|
+
{item.type === 'agent' ? (
|
|
1503
|
+
<span
|
|
1504
|
+
className="mt-0.5 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-[9px] font-semibold text-white"
|
|
1505
|
+
style={{ backgroundColor: item.color || '#7c3aed' }}
|
|
1506
|
+
>
|
|
1507
|
+
{item.label.trim()[0]?.toUpperCase() ?? 'A'}
|
|
1508
|
+
</span>
|
|
1509
|
+
) : (
|
|
1510
|
+
<span className="mt-0.5 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-agent-muted text-agent-muted-foreground">
|
|
1511
|
+
<LuMessageSquare className="h-3 w-3" />
|
|
1512
|
+
</span>
|
|
1513
|
+
)}
|
|
1514
|
+
<span className="min-w-0 flex-1">
|
|
1515
|
+
<span className="flex items-center gap-1.5">
|
|
1516
|
+
<span className="truncate text-xs font-medium">@{item.label}</span>
|
|
1517
|
+
<span className="shrink-0 rounded-full bg-agent-muted px-1.5 py-0.5 text-[10px] text-agent-muted-foreground">
|
|
1518
|
+
{item.type === 'agent' ? 'Agent' : '对话'}
|
|
1519
|
+
</span>
|
|
1520
|
+
</span>
|
|
1521
|
+
{item.description && (
|
|
1522
|
+
<span className="mt-0.5 line-clamp-1 block text-[10px] text-agent-muted-foreground/80">
|
|
1523
|
+
{item.description}
|
|
1524
|
+
</span>
|
|
1525
|
+
)}
|
|
1526
|
+
</span>
|
|
1527
|
+
</button>
|
|
1528
|
+
))}
|
|
1529
|
+
</div>
|
|
1530
|
+
)}
|
|
1531
|
+
{showSuggestions && suggestions.length > 0 && (
|
|
1532
|
+
<div
|
|
1533
|
+
className="absolute bottom-full left-3 z-50 mb-1 max-h-48 w-72 overflow-y-auto rounded-agent-md border border-agent-border bg-agent-canvas p-1 shadow-lg"
|
|
1534
|
+
data-testid="slash-menu"
|
|
1535
|
+
>
|
|
1536
|
+
{suggestionRows.map((row) =>
|
|
1537
|
+
row.type === 'header' ? (
|
|
1538
|
+
<div
|
|
1539
|
+
key={`header-${row.label}`}
|
|
1540
|
+
className="px-2 py-1 text-[10px] font-semibold text-agent-muted-foreground uppercase tracking-wider"
|
|
1541
|
+
>
|
|
1542
|
+
{row.label}
|
|
1543
|
+
</div>
|
|
1544
|
+
) : (
|
|
1545
|
+
<button
|
|
1546
|
+
key={`${row.item.kind}-${row.item.insertToken}`}
|
|
1547
|
+
ref={(node) => {
|
|
1548
|
+
skillOptionRefs.current[row.idx] = node;
|
|
1549
|
+
}}
|
|
1550
|
+
type="button"
|
|
1551
|
+
data-testid={`slash-option-${row.item.kind}-${row.item.insertToken}`}
|
|
1552
|
+
onClick={() => handleSelectSuggestion(row.item)}
|
|
1553
|
+
onMouseEnter={() => setSelectedIndex(row.idx)}
|
|
1554
|
+
className={`flex w-full items-start gap-2 rounded px-2 py-1.5 text-left transition-colors ${
|
|
1555
|
+
row.idx === selectedIndex
|
|
1556
|
+
? 'bg-agent-foreground/5 text-agent-foreground'
|
|
1557
|
+
: 'text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground'
|
|
1558
|
+
}`}
|
|
1559
|
+
>
|
|
1560
|
+
{row.item.kind === 'skill' ? (
|
|
1561
|
+
<LuBlocks className="h-3.5 w-3.5 mt-0.5 shrink-0 text-agent-muted-foreground" />
|
|
1562
|
+
) : (
|
|
1563
|
+
<LuPlug className="h-3.5 w-3.5 mt-0.5 shrink-0 text-agent-muted-foreground" />
|
|
1564
|
+
)}
|
|
1565
|
+
<div className="min-w-0 flex-1">
|
|
1566
|
+
{row.item.kind === 'skill' ? (
|
|
1567
|
+
<>
|
|
1568
|
+
<div className="text-xs font-medium truncate">
|
|
1569
|
+
{row.item.skill.displayName ? (
|
|
1570
|
+
<>
|
|
1571
|
+
{row.item.skill.displayName}
|
|
1572
|
+
<span className="ml-1.5 text-[10px] font-normal text-agent-muted-foreground/70">
|
|
1573
|
+
/{row.item.skill.name}
|
|
1574
|
+
</span>
|
|
1575
|
+
</>
|
|
1576
|
+
) : (
|
|
1577
|
+
<>/{row.item.skill.name}</>
|
|
1578
|
+
)}
|
|
1579
|
+
</div>
|
|
1580
|
+
{row.item.skill.description && (
|
|
1581
|
+
<div className="text-[10px] text-agent-muted-foreground/80 line-clamp-1">
|
|
1582
|
+
{row.item.skill.description}
|
|
1583
|
+
</div>
|
|
1584
|
+
)}
|
|
1585
|
+
</>
|
|
1586
|
+
) : (
|
|
1587
|
+
<>
|
|
1588
|
+
<div className="text-xs font-medium truncate">
|
|
1589
|
+
{row.item.mcp.toolName}
|
|
1590
|
+
<span className="ml-1.5 text-[10px] font-normal text-agent-muted-foreground/70">
|
|
1591
|
+
· {row.item.mcp.serverName}
|
|
1592
|
+
</span>
|
|
1593
|
+
</div>
|
|
1594
|
+
<div className="text-[10px] text-agent-muted-foreground/60 truncate">
|
|
1595
|
+
/{row.item.mcp.token}
|
|
1596
|
+
</div>
|
|
1597
|
+
{row.item.mcp.description && (
|
|
1598
|
+
<div className="text-[10px] text-agent-muted-foreground/80 line-clamp-1">
|
|
1599
|
+
{row.item.mcp.description}
|
|
1600
|
+
</div>
|
|
1601
|
+
)}
|
|
1602
|
+
</>
|
|
1603
|
+
)}
|
|
1604
|
+
</div>
|
|
1605
|
+
</button>
|
|
1606
|
+
)
|
|
1607
|
+
)}
|
|
1608
|
+
</div>
|
|
1609
|
+
)}
|
|
1610
|
+
<div
|
|
1611
|
+
className={`chat-input-box flex flex-col rounded-agent-lg border bg-agent-canvas shadow-sm transition-all duration-200 ${
|
|
1612
|
+
isDragging
|
|
1613
|
+
? 'border-blue-500 ring-2 ring-blue-500/20'
|
|
1614
|
+
: mode === 'plan'
|
|
1615
|
+
? 'border-amber-400/70 ring-1 ring-amber-400/20'
|
|
1616
|
+
: 'border-agent-border'
|
|
1617
|
+
}`}
|
|
1618
|
+
onDragOver={handleDragOver}
|
|
1619
|
+
onDragLeave={handleDragLeave}
|
|
1620
|
+
onDrop={handleDrop}
|
|
1621
|
+
>
|
|
1622
|
+
{pendingFollowUps.length > 0 && (
|
|
1623
|
+
<div className="flex flex-col gap-1 border-b border-agent-border bg-agent-muted/30 px-3 py-2">
|
|
1624
|
+
<div className="text-[11px] font-medium text-agent-muted-foreground">
|
|
1625
|
+
排队中({pendingFollowUps.length})· 本轮结束后自动发出 · 停止将丢弃
|
|
1626
|
+
</div>
|
|
1627
|
+
{pendingFollowUps.map((text, i) => (
|
|
1628
|
+
<div
|
|
1629
|
+
key={`${i}-${text.slice(0, 12)}`}
|
|
1630
|
+
className="group flex items-center gap-2 rounded-agent-md bg-agent-canvas px-2 py-1 text-xs text-agent-foreground"
|
|
1631
|
+
>
|
|
1632
|
+
<span className="min-w-0 flex-1 truncate">{text}</span>
|
|
1633
|
+
{onRemoveFollowUp && (
|
|
1634
|
+
<button
|
|
1635
|
+
type="button"
|
|
1636
|
+
aria-label={`撤回排队消息 ${i + 1}`}
|
|
1637
|
+
onClick={() => onRemoveFollowUp(i)}
|
|
1638
|
+
className="shrink-0 text-agent-muted-foreground opacity-0 transition-opacity hover:text-agent-destructive group-hover:opacity-100"
|
|
1639
|
+
>
|
|
1640
|
+
✕
|
|
1641
|
+
</button>
|
|
1642
|
+
)}
|
|
1643
|
+
</div>
|
|
1644
|
+
))}
|
|
1645
|
+
</div>
|
|
1646
|
+
)}
|
|
1647
|
+
{steerNotice && (
|
|
1648
|
+
<div
|
|
1649
|
+
role="status"
|
|
1650
|
+
className="border-b border-agent-border bg-agent-muted/20 px-3 py-1.5 text-[11px] text-agent-muted-foreground"
|
|
1651
|
+
>
|
|
1652
|
+
{steerNotice}
|
|
1653
|
+
</div>
|
|
1654
|
+
)}
|
|
1655
|
+
<div className="chat-input-surface relative overflow-hidden">
|
|
1656
|
+
<div
|
|
1657
|
+
className="chat-input-placeholder pointer-events-none absolute inset-x-0 top-0 px-3 pt-3 text-sm text-agent-muted-foreground"
|
|
1658
|
+
style={{ visibility: value ? 'hidden' : undefined }}
|
|
1659
|
+
>
|
|
1660
|
+
{placeholder}
|
|
1661
|
+
</div>
|
|
1662
|
+
<div
|
|
1663
|
+
ref={editorRef}
|
|
1664
|
+
contentEditable={!disabled}
|
|
1665
|
+
suppressContentEditableWarning
|
|
1666
|
+
role="textbox"
|
|
1667
|
+
aria-multiline="true"
|
|
1668
|
+
aria-disabled={disabled}
|
|
1669
|
+
data-testid="chat-composer"
|
|
1670
|
+
spellCheck={false}
|
|
1671
|
+
onFocus={handleEditorFocus}
|
|
1672
|
+
onPointerDown={handleEditorPointerDown}
|
|
1673
|
+
onInput={handleInput}
|
|
1674
|
+
onBeforeInput={handleBeforeInput}
|
|
1675
|
+
onPaste={handlePaste}
|
|
1676
|
+
onKeyDown={handleKeyDown}
|
|
1677
|
+
onCompositionStart={handleCompositionStart}
|
|
1678
|
+
onCompositionEnd={handleCompositionEnd}
|
|
1679
|
+
onScroll={handleEditorScroll}
|
|
1680
|
+
className="chat-input-editor relative z-10 block w-full overflow-y-auto whitespace-pre-wrap break-words border-0 bg-transparent px-3 pt-3 text-sm text-agent-foreground caret-agent-foreground outline-none selection:bg-agent-foreground/20"
|
|
1681
|
+
style={{
|
|
1682
|
+
minHeight: MIN_HEIGHT_PX,
|
|
1683
|
+
maxHeight: MAX_HEIGHT_PX,
|
|
1684
|
+
}}
|
|
1685
|
+
/>
|
|
1686
|
+
</div>
|
|
1687
|
+
{actualFiles.length > 0 && (
|
|
1688
|
+
<div className="flex flex-wrap gap-1.5 px-3 pb-2 pt-0.5">
|
|
1689
|
+
{actualFiles.map((file, idx) => (
|
|
1690
|
+
<div
|
|
1691
|
+
key={idx}
|
|
1692
|
+
className="group flex items-center gap-1.5 rounded bg-agent-muted/60 hover:bg-agent-muted px-2.5 py-1 text-xs border border-agent-border text-agent-foreground select-none max-w-xs transition-all duration-150"
|
|
1693
|
+
title={file.path}
|
|
1694
|
+
>
|
|
1695
|
+
<LuFile className="h-3.5 w-3.5 shrink-0 text-agent-muted-foreground" />
|
|
1696
|
+
<span className="truncate max-w-[160px] font-medium">
|
|
1697
|
+
{file.name}
|
|
1698
|
+
</span>
|
|
1699
|
+
<button
|
|
1700
|
+
type="button"
|
|
1701
|
+
onClick={() => handleRemoveFile(idx)}
|
|
1702
|
+
className="flex h-4 w-4 items-center justify-center rounded-full text-agent-muted-foreground hover:bg-agent-foreground/10 hover:text-agent-foreground transition-colors"
|
|
1703
|
+
aria-label={`移除文件 ${file.name}`}
|
|
1704
|
+
>
|
|
1705
|
+
<LuX className="h-3 w-3" />
|
|
1706
|
+
</button>
|
|
1707
|
+
</div>
|
|
1708
|
+
))}
|
|
1709
|
+
</div>
|
|
1710
|
+
)}
|
|
1711
|
+
<div className="flex items-center justify-between gap-2 px-2 pb-2 pt-1">
|
|
1712
|
+
{/* Left toolbar: mode + exec sandbox + host extras + settings.
|
|
1713
|
+
Agent picker lives in the meta row above the box, next to
|
|
1714
|
+
the project badge. min-w-0 允许窄屏时胶囊截断收缩。 */}
|
|
1715
|
+
<div className="flex min-w-0 items-center gap-1">
|
|
1716
|
+
{mode && onModeChange && (
|
|
1717
|
+
<ModeToggle
|
|
1718
|
+
mode={mode}
|
|
1719
|
+
disabled={disabled || isStreaming}
|
|
1720
|
+
onChange={onModeChange}
|
|
1721
|
+
/>
|
|
1722
|
+
)}
|
|
1723
|
+
{execPolicy && onExecPolicyChange && (
|
|
1724
|
+
<ExecPolicyPicker
|
|
1725
|
+
policy={execPolicy}
|
|
1726
|
+
disabled={disabled}
|
|
1727
|
+
onChange={onExecPolicyChange}
|
|
1728
|
+
/>
|
|
1729
|
+
)}
|
|
1730
|
+
{toolbarExtras}
|
|
1731
|
+
<button
|
|
1732
|
+
type="button"
|
|
1733
|
+
onClick={handlePickFiles}
|
|
1734
|
+
disabled={disabled || isStreaming}
|
|
1735
|
+
className="flex h-7 w-7 items-center justify-center rounded-full text-agent-muted-foreground transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
|
1736
|
+
title="上传文件(可多选)"
|
|
1737
|
+
aria-label="上传文件"
|
|
1738
|
+
>
|
|
1739
|
+
<LuPaperclip className="h-3.5 w-3.5" />
|
|
1740
|
+
</button>
|
|
1741
|
+
{/* 隐藏的原生多选文件输入:点击回形针按钮触发系统选择框。 */}
|
|
1742
|
+
<input
|
|
1743
|
+
ref={fileInputRef}
|
|
1744
|
+
type="file"
|
|
1745
|
+
multiple
|
|
1746
|
+
className="hidden"
|
|
1747
|
+
aria-hidden="true"
|
|
1748
|
+
tabIndex={-1}
|
|
1749
|
+
onChange={handleFileInputChange}
|
|
1750
|
+
/>
|
|
1751
|
+
{onOpenSettings && (
|
|
1752
|
+
<button
|
|
1753
|
+
type="button"
|
|
1754
|
+
onClick={onOpenSettings}
|
|
1755
|
+
className="flex h-7 w-7 items-center justify-center rounded-full text-agent-muted-foreground transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground"
|
|
1756
|
+
title="LLM 设置"
|
|
1757
|
+
aria-label="LLM 设置"
|
|
1758
|
+
data-testid="chat-llm-settings"
|
|
1759
|
+
>
|
|
1760
|
+
<LuSettings className="h-3.5 w-3.5" />
|
|
1761
|
+
</button>
|
|
1762
|
+
)}
|
|
1763
|
+
</div>
|
|
1764
|
+
<div className="flex items-center gap-2">
|
|
1765
|
+
{/* 快捷键提示只在输入盒足够宽时出现(容器查询,非视口) */}
|
|
1766
|
+
<div className="hidden text-[11px] text-agent-muted-foreground/80 @min-[520px]:block">
|
|
1767
|
+
{isStreaming ? (
|
|
1768
|
+
<>
|
|
1769
|
+
<kbd className="rounded border border-agent-border bg-agent-muted px-1 font-sans text-[10px]">
|
|
1770
|
+
{MOD_KEY_LABEL}
|
|
1771
|
+
</kbd>
|
|
1772
|
+
<span className="mx-0.5">+</span>
|
|
1773
|
+
<kbd className="rounded border border-agent-border bg-agent-muted px-1 font-sans text-[10px]">
|
|
1774
|
+
.
|
|
1775
|
+
</kbd>
|
|
1776
|
+
<span className="ml-1">停止</span>
|
|
1777
|
+
{onSteer && (
|
|
1778
|
+
<span className="ml-2">
|
|
1779
|
+
<kbd className="rounded border border-agent-border bg-agent-muted px-1 font-sans text-[10px]">
|
|
1780
|
+
Enter
|
|
1781
|
+
</kbd>
|
|
1782
|
+
<span className="ml-1">追加</span>
|
|
1783
|
+
</span>
|
|
1784
|
+
)}
|
|
1785
|
+
{onFollowUp && (
|
|
1786
|
+
<span className="ml-2">
|
|
1787
|
+
<kbd className="rounded border border-agent-border bg-agent-muted px-1 font-sans text-[10px]">
|
|
1788
|
+
{MOD_KEY_LABEL}
|
|
1789
|
+
</kbd>
|
|
1790
|
+
<span className="mx-0.5">+</span>
|
|
1791
|
+
<kbd className="rounded border border-agent-border bg-agent-muted px-1 font-sans text-[10px]">
|
|
1792
|
+
Enter
|
|
1793
|
+
</kbd>
|
|
1794
|
+
<span className="ml-1">排队</span>
|
|
1795
|
+
</span>
|
|
1796
|
+
)}
|
|
1797
|
+
</>
|
|
1798
|
+
) : (
|
|
1799
|
+
<>
|
|
1800
|
+
<kbd className="rounded border border-agent-border bg-agent-muted px-1 font-sans text-[10px]">
|
|
1801
|
+
Enter
|
|
1802
|
+
</kbd>
|
|
1803
|
+
<span className="ml-1">发送</span>
|
|
1804
|
+
<span className="ml-2">
|
|
1805
|
+
<kbd className="rounded border border-agent-border bg-agent-muted px-1 font-sans text-[10px]">
|
|
1806
|
+
Shift
|
|
1807
|
+
</kbd>
|
|
1808
|
+
<span className="mx-0.5">+</span>
|
|
1809
|
+
<kbd className="rounded border border-agent-border bg-agent-muted px-1 font-sans text-[10px]">
|
|
1810
|
+
Enter
|
|
1811
|
+
</kbd>
|
|
1812
|
+
<span className="ml-1">换行</span>
|
|
1813
|
+
</span>
|
|
1814
|
+
</>
|
|
1815
|
+
)}
|
|
1816
|
+
</div>
|
|
1817
|
+
<button
|
|
1818
|
+
type="button"
|
|
1819
|
+
onClick={handleSendClick}
|
|
1820
|
+
disabled={!isStreaming && !canSend}
|
|
1821
|
+
className={
|
|
1822
|
+
isStreaming
|
|
1823
|
+
? 'flex h-8 w-8 items-center justify-center rounded-full bg-agent-destructive text-white transition hover:opacity-90'
|
|
1824
|
+
: canSend
|
|
1825
|
+
? 'flex h-8 w-8 items-center justify-center rounded-full bg-agent-foreground text-agent-canvas transition hover:opacity-90'
|
|
1826
|
+
: 'flex h-8 w-8 cursor-not-allowed items-center justify-center rounded-full bg-agent-muted text-agent-muted-foreground'
|
|
1827
|
+
}
|
|
1828
|
+
title={
|
|
1829
|
+
isStreaming
|
|
1830
|
+
? pendingFollowUps.length > 0
|
|
1831
|
+
? `停止生成 (${MOD_KEY_LABEL}+.) · 将丢弃 ${pendingFollowUps.length} 条排队消息`
|
|
1832
|
+
: `停止生成 (${MOD_KEY_LABEL}+.)`
|
|
1833
|
+
: '发送 (Enter)'
|
|
1834
|
+
}
|
|
1835
|
+
aria-label={isStreaming ? '停止生成' : '发送消息'}
|
|
1836
|
+
data-testid="chat-send"
|
|
1837
|
+
>
|
|
1838
|
+
{isStreaming ? (
|
|
1839
|
+
<LuSquare className="h-3.5 w-3.5 fill-current" />
|
|
1840
|
+
) : (
|
|
1841
|
+
<LuArrowUp className="h-4 w-4" />
|
|
1842
|
+
)}
|
|
1843
|
+
</button>
|
|
1844
|
+
</div>
|
|
1845
|
+
</div>
|
|
1846
|
+
</div>
|
|
1847
|
+
</div>
|
|
1848
|
+
);
|
|
1849
|
+
},
|
|
1850
|
+
);
|
|
1851
|
+
|
|
1852
|
+
function agentInitial(agent: LocalChatAgent): string {
|
|
1853
|
+
return agent.name.trim()[0]?.toUpperCase() ?? 'A';
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
/**
|
|
1857
|
+
* ModeToggle — segmented Agent / Plan switch (类 Cursor 的 mode 切换).
|
|
1858
|
+
* Plan 模式下后端只暴露只读工具并引导模型先出结构化计划。
|
|
1859
|
+
*/
|
|
1860
|
+
function ModeToggle({
|
|
1861
|
+
mode,
|
|
1862
|
+
disabled,
|
|
1863
|
+
onChange,
|
|
1864
|
+
}: {
|
|
1865
|
+
mode: ChatMode;
|
|
1866
|
+
disabled: boolean;
|
|
1867
|
+
onChange: (mode: ChatMode) => void;
|
|
1868
|
+
}) {
|
|
1869
|
+
return (
|
|
1870
|
+
<div
|
|
1871
|
+
className="inline-flex h-7 items-center rounded-full border border-agent-border bg-agent-canvas p-0.5"
|
|
1872
|
+
role="radiogroup"
|
|
1873
|
+
aria-label="对话模式"
|
|
1874
|
+
data-testid="mode-toggle"
|
|
1875
|
+
>
|
|
1876
|
+
<button
|
|
1877
|
+
type="button"
|
|
1878
|
+
role="radio"
|
|
1879
|
+
aria-checked={mode === 'agent'}
|
|
1880
|
+
disabled={disabled}
|
|
1881
|
+
onClick={() => onChange('agent')}
|
|
1882
|
+
title="Agent 模式:直接执行任务"
|
|
1883
|
+
data-testid="mode-agent"
|
|
1884
|
+
className={[
|
|
1885
|
+
'inline-flex h-6 items-center gap-1 rounded-full px-2 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-70',
|
|
1886
|
+
mode === 'agent'
|
|
1887
|
+
? 'bg-agent-foreground/10 text-agent-foreground'
|
|
1888
|
+
: 'text-agent-muted-foreground hover:text-agent-foreground',
|
|
1889
|
+
].join(' ')}
|
|
1890
|
+
>
|
|
1891
|
+
<LuInfinity className="h-3.5 w-3.5" />
|
|
1892
|
+
<span>Agent</span>
|
|
1893
|
+
</button>
|
|
1894
|
+
<button
|
|
1895
|
+
type="button"
|
|
1896
|
+
role="radio"
|
|
1897
|
+
aria-checked={mode === 'plan'}
|
|
1898
|
+
disabled={disabled}
|
|
1899
|
+
onClick={() => onChange('plan')}
|
|
1900
|
+
title="Plan 模式:先制定计划,只读不执行"
|
|
1901
|
+
data-testid="mode-plan"
|
|
1902
|
+
className={[
|
|
1903
|
+
'inline-flex h-6 items-center gap-1 rounded-full px-2 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-70',
|
|
1904
|
+
mode === 'plan'
|
|
1905
|
+
? 'bg-amber-400/20 text-amber-600 dark:text-amber-400'
|
|
1906
|
+
: 'text-agent-muted-foreground hover:text-agent-foreground',
|
|
1907
|
+
].join(' ')}
|
|
1908
|
+
>
|
|
1909
|
+
<LuListChecks className="h-3.5 w-3.5" />
|
|
1910
|
+
<span>Plan</span>
|
|
1911
|
+
</button>
|
|
1912
|
+
</div>
|
|
1913
|
+
);
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
/**
|
|
1917
|
+
* AgentSelect — compact "你正在和谁说话" indicator in the composer meta row
|
|
1918
|
+
* (next to the project badge). Clicking an expert switches the bound / next
|
|
1919
|
+
* agent. Extra experts still come from `@` mentions in the input.
|
|
1920
|
+
*/
|
|
1921
|
+
function AgentSelect({
|
|
1922
|
+
refEl,
|
|
1923
|
+
agent,
|
|
1924
|
+
agents,
|
|
1925
|
+
open,
|
|
1926
|
+
disabled,
|
|
1927
|
+
onOpenChange,
|
|
1928
|
+
onSelect,
|
|
1929
|
+
mentionReferences = [],
|
|
1930
|
+
}: {
|
|
1931
|
+
refEl: RefObject<HTMLDivElement>;
|
|
1932
|
+
agent: LocalChatAgent;
|
|
1933
|
+
agents: LocalChatAgent[];
|
|
1934
|
+
open: boolean;
|
|
1935
|
+
disabled: boolean;
|
|
1936
|
+
onOpenChange: (open: boolean) => void;
|
|
1937
|
+
onSelect: (agent: LocalChatAgent) => void;
|
|
1938
|
+
mentionReferences?: MentionReference[];
|
|
1939
|
+
}) {
|
|
1940
|
+
const navigate = useNavigate();
|
|
1941
|
+
const mentionedAgentCount = useMemo(() => {
|
|
1942
|
+
return mentionReferences.filter((ref) => ref.type === 'agent' && ref.id !== agent.id).length;
|
|
1943
|
+
}, [mentionReferences, agent.id]);
|
|
1944
|
+
|
|
1945
|
+
const buttonTitle = useMemo(() => {
|
|
1946
|
+
const activeNames = [
|
|
1947
|
+
agent.name,
|
|
1948
|
+
...mentionReferences
|
|
1949
|
+
.filter((ref) => ref.type === 'agent' && ref.id !== agent.id)
|
|
1950
|
+
.map((ref) => ref.label),
|
|
1951
|
+
].filter(Boolean) as string[];
|
|
1952
|
+
|
|
1953
|
+
return `当前激活专家:${activeNames.join(', ')}`;
|
|
1954
|
+
}, [agent.name, agent.id, mentionReferences]);
|
|
1955
|
+
|
|
1956
|
+
return (
|
|
1957
|
+
<div
|
|
1958
|
+
ref={refEl}
|
|
1959
|
+
className="relative"
|
|
1960
|
+
>
|
|
1961
|
+
<button
|
|
1962
|
+
type="button"
|
|
1963
|
+
onClick={() => onOpenChange(!open)}
|
|
1964
|
+
disabled={disabled}
|
|
1965
|
+
className="inline-flex max-w-[140px] items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] text-agent-muted-foreground/80 transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground disabled:cursor-not-allowed disabled:opacity-70 @sm:max-w-[220px]"
|
|
1966
|
+
title={buttonTitle}
|
|
1967
|
+
aria-haspopup="menu"
|
|
1968
|
+
aria-expanded={open}
|
|
1969
|
+
data-testid="agent-select"
|
|
1970
|
+
>
|
|
1971
|
+
<AgentDot agent={agent} />
|
|
1972
|
+
<span className="truncate font-medium">
|
|
1973
|
+
{agent.name}
|
|
1974
|
+
{mentionedAgentCount > 0 ? ` +${mentionedAgentCount}` : ''}
|
|
1975
|
+
</span>
|
|
1976
|
+
<LuChevronDown className="h-3 w-3 shrink-0 text-agent-muted-foreground" />
|
|
1977
|
+
</button>
|
|
1978
|
+
|
|
1979
|
+
{open && (
|
|
1980
|
+
<div
|
|
1981
|
+
role="menu"
|
|
1982
|
+
className="absolute bottom-full left-0 z-50 mb-1 max-h-64 w-72 overflow-y-auto rounded-agent-md border border-agent-border bg-agent-canvas p-1 shadow-lg"
|
|
1983
|
+
>
|
|
1984
|
+
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-agent-muted-foreground">
|
|
1985
|
+
选择专家
|
|
1986
|
+
</div>
|
|
1987
|
+
{agents.map((item) => {
|
|
1988
|
+
const isPrimary = item.id === agent.id;
|
|
1989
|
+
const isMentioned = mentionReferences.some(
|
|
1990
|
+
(ref) => ref.type === 'agent' && ref.id === item.id,
|
|
1991
|
+
);
|
|
1992
|
+
return (
|
|
1993
|
+
<button
|
|
1994
|
+
key={item.id}
|
|
1995
|
+
type="button"
|
|
1996
|
+
role="menuitemradio"
|
|
1997
|
+
aria-checked={isPrimary}
|
|
1998
|
+
data-agent-id={item.id}
|
|
1999
|
+
data-testid={`agent-option-${item.id}`}
|
|
2000
|
+
onClick={() => onSelect(item)}
|
|
2001
|
+
className={[
|
|
2002
|
+
'flex w-full items-start gap-2 rounded px-2 py-2 text-left transition-colors',
|
|
2003
|
+
isPrimary
|
|
2004
|
+
? 'bg-agent-foreground/10 text-agent-foreground'
|
|
2005
|
+
: 'text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground',
|
|
2006
|
+
].join(' ')}
|
|
2007
|
+
>
|
|
2008
|
+
<AgentDot agent={item} />
|
|
2009
|
+
<span className="min-w-0 flex-1">
|
|
2010
|
+
<span className="flex items-center gap-1.5">
|
|
2011
|
+
<span className="block truncate text-xs font-medium">
|
|
2012
|
+
{item.name}
|
|
2013
|
+
</span>
|
|
2014
|
+
{isPrimary && (
|
|
2015
|
+
<span className="inline-flex items-center rounded bg-agent-foreground/10 px-1 py-0.5 text-[9px] font-medium text-agent-foreground">
|
|
2016
|
+
当前
|
|
2017
|
+
</span>
|
|
2018
|
+
)}
|
|
2019
|
+
{!isPrimary && isMentioned && (
|
|
2020
|
+
<span className="inline-flex items-center rounded bg-agent-muted px-1 py-0.5 text-[9px] font-medium text-agent-muted-foreground border border-agent-border">
|
|
2021
|
+
已引入
|
|
2022
|
+
</span>
|
|
2023
|
+
)}
|
|
2024
|
+
</span>
|
|
2025
|
+
{item.description && (
|
|
2026
|
+
<span className="mt-0.5 line-clamp-2 block text-[10px] text-agent-muted-foreground/80">
|
|
2027
|
+
{item.description}
|
|
2028
|
+
</span>
|
|
2029
|
+
)}
|
|
2030
|
+
</span>
|
|
2031
|
+
</button>
|
|
2032
|
+
);
|
|
2033
|
+
})}
|
|
2034
|
+
<button
|
|
2035
|
+
type="button"
|
|
2036
|
+
role="menuitem"
|
|
2037
|
+
data-testid="agent-manage"
|
|
2038
|
+
onClick={() => {
|
|
2039
|
+
onOpenChange(false);
|
|
2040
|
+
navigate('/settings?section=agents');
|
|
2041
|
+
}}
|
|
2042
|
+
className="mt-0.5 flex w-full items-center gap-2 rounded border-t border-agent-border/50 px-2 py-2 text-left text-agent-muted-foreground transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground"
|
|
2043
|
+
>
|
|
2044
|
+
<LuBot className="h-3.5 w-3.5 shrink-0" />
|
|
2045
|
+
<span className="text-xs font-medium">管理智能体</span>
|
|
2046
|
+
</button>
|
|
2047
|
+
</div>
|
|
2048
|
+
)}
|
|
2049
|
+
</div>
|
|
2050
|
+
);
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
function AgentDot({ agent }: { agent: LocalChatAgent }) {
|
|
2054
|
+
return (
|
|
2055
|
+
<span
|
|
2056
|
+
className="inline-flex shrink-0 items-center justify-center rounded-full text-[9px] font-semibold text-white"
|
|
2057
|
+
style={{
|
|
2058
|
+
width: 14,
|
|
2059
|
+
height: 14,
|
|
2060
|
+
backgroundColor: agent.color || '#7c3aed',
|
|
2061
|
+
}}
|
|
2062
|
+
>
|
|
2063
|
+
{agentInitial(agent)}
|
|
2064
|
+
</span>
|
|
2065
|
+
);
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
export default ChatInput;
|