@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.
Files changed (121) hide show
  1. package/LICENSE +91 -0
  2. package/README.md +20 -0
  3. package/package.json +69 -0
  4. package/public/favicon.ico +0 -0
  5. package/public/favicon.png +0 -0
  6. package/src/AppShell.tsx +24 -0
  7. package/src/assets/logo-generic.png +0 -0
  8. package/src/brand.test.ts +22 -0
  9. package/src/brand.ts +48 -0
  10. package/src/components/AgentSidebar.test.tsx +127 -0
  11. package/src/components/AgentSidebar.tsx +1010 -0
  12. package/src/components/ChatHeader.test.tsx +230 -0
  13. package/src/components/ChatHeader.tsx +436 -0
  14. package/src/components/DockPanelHeader.tsx +69 -0
  15. package/src/components/LocalLlmSettingsModal.test.tsx +372 -0
  16. package/src/components/LocalLlmSettingsModal.tsx +72 -0
  17. package/src/components/TerminalPanel.tsx +68 -0
  18. package/src/components/TerminalView.tsx +228 -0
  19. package/src/components/chat/ApprovalModal.test.tsx +83 -0
  20. package/src/components/chat/ApprovalModal.tsx +229 -0
  21. package/src/components/chat/AskUserModalHost.test.tsx +176 -0
  22. package/src/components/chat/AskUserModalHost.tsx +61 -0
  23. package/src/components/chat/AskUserQuestionMenu.tsx +621 -0
  24. package/src/components/chat/AssistantMessage.test.tsx +68 -0
  25. package/src/components/chat/AssistantMessage.tsx +469 -0
  26. package/src/components/chat/ChatInput.test.tsx +609 -0
  27. package/src/components/chat/ChatInput.tsx +2068 -0
  28. package/src/components/chat/ChatProjectBadge.tsx +362 -0
  29. package/src/components/chat/EmptyChat.tsx +18 -0
  30. package/src/components/chat/ExecPolicyPicker.test.tsx +26 -0
  31. package/src/components/chat/ExecPolicyPicker.tsx +120 -0
  32. package/src/components/chat/ExecutedActionsCard.tsx +216 -0
  33. package/src/components/chat/InterruptedTurnCard.tsx +53 -0
  34. package/src/components/chat/LocalChatPanel.tsx +408 -0
  35. package/src/components/chat/Markdown.test.tsx +170 -0
  36. package/src/components/chat/Markdown.tsx +432 -0
  37. package/src/components/chat/MessageList.tsx +332 -0
  38. package/src/components/chat/ModelPicker.test.tsx +149 -0
  39. package/src/components/chat/ModelPicker.tsx +289 -0
  40. package/src/components/chat/OrchestrationChildrenCard.tsx +40 -0
  41. package/src/components/chat/PlanTodoList.tsx +121 -0
  42. package/src/components/chat/ProjectTrustBanner.tsx +91 -0
  43. package/src/components/chat/SessionTreeModal.test.tsx +161 -0
  44. package/src/components/chat/SessionTreeModal.tsx +221 -0
  45. package/src/components/chat/StreamingStatus.tsx +95 -0
  46. package/src/components/chat/TaskOutcomeCards.test.tsx +97 -0
  47. package/src/components/chat/TaskOutcomeCards.tsx +128 -0
  48. package/src/components/chat/TaskPanelModal.test.tsx +198 -0
  49. package/src/components/chat/TaskPanelModal.tsx +310 -0
  50. package/src/components/chat/TaskProcessPanel.test.tsx +111 -0
  51. package/src/components/chat/TaskProcessPanel.tsx +208 -0
  52. package/src/components/chat/TurnProcessGroup.test.tsx +161 -0
  53. package/src/components/chat/TurnProcessGroup.tsx +193 -0
  54. package/src/components/chat/UnifiedDiff.tsx +31 -0
  55. package/src/components/chat/UserMessage.tsx +155 -0
  56. package/src/components/chat/elapsed.test.ts +42 -0
  57. package/src/components/chat/elapsed.ts +45 -0
  58. package/src/components/chat/executed-actions-model.test.ts +134 -0
  59. package/src/components/chat/executed-actions-model.ts +143 -0
  60. package/src/components/chat/orchestration-children-model.test.ts +115 -0
  61. package/src/components/chat/orchestration-children-model.ts +104 -0
  62. package/src/components/chat/timestamp.ts +24 -0
  63. package/src/components/chat/turn-timeline.test.ts +97 -0
  64. package/src/components/chat/turn-timeline.ts +132 -0
  65. package/src/components/chat/useChatTasks.test.ts +136 -0
  66. package/src/components/chat/useChatTasks.ts +134 -0
  67. package/src/components/chat/useCopy.ts +62 -0
  68. package/src/components/settings/AgentCapabilityFields.tsx +343 -0
  69. package/src/components/settings/AgentsSettingsPanel.test.tsx +229 -0
  70. package/src/components/settings/AgentsSettingsPanel.tsx +412 -0
  71. package/src/components/settings/DiagnoseSettingsPanel.tsx +119 -0
  72. package/src/components/settings/InsightsSettingsPanel.test.tsx +132 -0
  73. package/src/components/settings/InsightsSettingsPanel.tsx +309 -0
  74. package/src/components/settings/LlmSettingsPanel.tsx +875 -0
  75. package/src/components/settings/McpSettingsPanel.tsx +466 -0
  76. package/src/components/settings/ModelCapabilityChips.tsx +46 -0
  77. package/src/components/settings/ModelIdCombobox.test.tsx +88 -0
  78. package/src/components/settings/ModelIdCombobox.tsx +185 -0
  79. package/src/components/settings/SecuritySettingsPanel.test.tsx +122 -0
  80. package/src/components/settings/SecuritySettingsPanel.tsx +169 -0
  81. package/src/components/settings/SettingsSaveButton.tsx +35 -0
  82. package/src/components/settings/SkillsSettingsPanel.test.tsx +62 -0
  83. package/src/components/settings/SkillsSettingsPanel.tsx +334 -0
  84. package/src/components/settings/TelemetrySettingsPanel.tsx +173 -0
  85. package/src/components/settings/UsagePanel.tsx +196 -0
  86. package/src/components/settings/WebSearchSettingsPanel.test.tsx +90 -0
  87. package/src/components/settings/WebSearchSettingsPanel.tsx +183 -0
  88. package/src/components/settings/compat-flags-model.test.ts +71 -0
  89. package/src/components/settings/compat-flags-model.ts +57 -0
  90. package/src/components/settings/llm-vendors.test.ts +171 -0
  91. package/src/components/settings/llm-vendors.ts +275 -0
  92. package/src/components/settings/model-capabilities.test.ts +88 -0
  93. package/src/components/settings/model-capabilities.ts +123 -0
  94. package/src/components/settings/preset-choice-model.test.ts +96 -0
  95. package/src/components/settings/preset-choice-model.ts +75 -0
  96. package/src/fixtures/browser-dev-data.json +315 -0
  97. package/src/hooks/useChatsAndAgents.ts +182 -0
  98. package/src/layouts/AgentLayout.tsx +574 -0
  99. package/src/lib/attachments.ts +78 -0
  100. package/src/lib/browser-dev-electron-mock.ts +640 -0
  101. package/src/lib/chat-title.ts +33 -0
  102. package/src/lib/chat-transport.ts +346 -0
  103. package/src/lib/date-groups.ts +34 -0
  104. package/src/lib/demo-flag.ts +15 -0
  105. package/src/lib/electron-bridge.ts +270 -0
  106. package/src/lib/exec-policy.ts +25 -0
  107. package/src/lib/http-bridge.ts +293 -0
  108. package/src/lib/insights.ts +17 -0
  109. package/src/lib/local-api.ts +714 -0
  110. package/src/lib/pending-first-message.test.ts +17 -0
  111. package/src/lib/pending-first-message.ts +36 -0
  112. package/src/lib/slash-sources.ts +191 -0
  113. package/src/main.tsx +103 -0
  114. package/src/packs/registry.ts +64 -0
  115. package/src/pages/AgentPage.tsx +1226 -0
  116. package/src/pages/ChatPanelPreviewPage.tsx +264 -0
  117. package/src/pages/SettingsPage.test.tsx +102 -0
  118. package/src/pages/SettingsPage.tsx +197 -0
  119. package/src/styles/index.css +156 -0
  120. package/src/vite-env.d.ts +1 -0
  121. package/vite.base.ts +94 -0
@@ -0,0 +1,170 @@
1
+ /**
2
+ * 消息气泡里的 token 渲染契约:发出去的消息要和输入框看到的一致——
3
+ * `@专家` / `@对话` 渲染成提及卡片,`/技能` / `/mcp__srv__tool` 渲染成工具
4
+ * 卡片,而路径、URL 这类带斜杠的普通文本必须保持原样。
5
+ */
6
+ import { cleanup, render, screen } from '@testing-library/react';
7
+ import { afterEach, describe, expect, it } from 'vitest';
8
+ import { Markdown } from './Markdown';
9
+ import type { McpToolItem, SkillItem } from '@/lib/slash-sources';
10
+
11
+ afterEach(() => cleanup());
12
+
13
+ const SKILLS: SkillItem[] = [
14
+ { name: 'loop', id: '11-loop', displayName: '循环执行', description: '重复执行' },
15
+ { name: 'goal', id: '10-goal', displayName: '目标跟踪', description: '目标与验收' },
16
+ { name: 'plan-mode', id: '70-plan-mode', description: '计划模式约束' },
17
+ ];
18
+
19
+ const MCP_TOOLS: McpToolItem[] = [
20
+ {
21
+ token: 'mcp__sqlite__query',
22
+ toolName: 'query',
23
+ serverKey: 'sqlite',
24
+ serverName: 'SQLite',
25
+ description: '执行 SQL',
26
+ },
27
+ ];
28
+
29
+ function renderMessage(content: string) {
30
+ render(
31
+ <Markdown inlineParagraph skills={SKILLS} mcpTools={MCP_TOOLS}>
32
+ {content}
33
+ </Markdown>,
34
+ );
35
+ }
36
+
37
+ describe('Markdown 消息内的工具 token', () => {
38
+ it('把行首的技能 token 渲染成琥珀色工具卡片,后续文字保持原样', () => {
39
+ const { container } = render(
40
+ <Markdown inlineParagraph skills={SKILLS} mcpTools={MCP_TOOLS}>
41
+ {'/loop 5分钟说一次你好'}
42
+ </Markdown>,
43
+ );
44
+
45
+ const chip = container.querySelector('[data-message-tool-chip]');
46
+ expect(chip).not.toBeNull();
47
+ expect(chip!.getAttribute('data-tool-type')).toBe('skill');
48
+ expect(chip!.textContent).toBe('/loop');
49
+ expect(chip!.className).toContain('amber');
50
+ expect(container.textContent).toBe('/loop 5分钟说一次你好');
51
+ });
52
+
53
+ it('把 MCP token 渲染成天蓝色工具卡片', () => {
54
+ const { container } = render(
55
+ <Markdown inlineParagraph skills={SKILLS} mcpTools={MCP_TOOLS}>
56
+ {'/mcp__sqlite__query 查一下用户表'}
57
+ </Markdown>,
58
+ );
59
+
60
+ const chip = container.querySelector('[data-message-tool-chip]');
61
+ expect(chip!.getAttribute('data-tool-type')).toBe('mcp');
62
+ expect(chip!.textContent).toBe('/mcp__sqlite__query');
63
+ expect(chip!.className).toContain('sky');
64
+ });
65
+
66
+ it('句中的技能 token 也成卡片', () => {
67
+ const { container } = render(
68
+ <Markdown inlineParagraph skills={SKILLS} mcpTools={MCP_TOOLS}>
69
+ {'帮我用 /goal 把这件事跟到底'}
70
+ </Markdown>,
71
+ );
72
+
73
+ const chip = container.querySelector('[data-message-tool-chip]');
74
+ expect(chip!.textContent).toBe('/goal');
75
+ expect(container.textContent).toBe('帮我用 /goal 把这件事跟到底');
76
+ });
77
+
78
+ it('路径、URL 与未知 token 保持纯文本', () => {
79
+ const { container } = render(
80
+ <Markdown inlineParagraph skills={SKILLS} mcpTools={MCP_TOOLS}>
81
+ {'看下 /mnt/c 和 /not-a-skill'}
82
+ </Markdown>,
83
+ );
84
+
85
+ expect(container.querySelector('[data-message-tool-chip]')).toBeNull();
86
+ expect(container.textContent).toBe('看下 /mnt/c 和 /not-a-skill');
87
+ });
88
+
89
+ it('引擎内部技能不渲染成卡片', () => {
90
+ const { container } = render(
91
+ <Markdown inlineParagraph skills={SKILLS} mcpTools={MCP_TOOLS}>
92
+ {'/plan-mode 试试'}
93
+ </Markdown>,
94
+ );
95
+
96
+ expect(container.querySelector('[data-message-tool-chip]')).toBeNull();
97
+ });
98
+
99
+ it('@提及与 /工具可以共存', () => {
100
+ const { container } = render(
101
+ <Markdown
102
+ inlineParagraph
103
+ skills={SKILLS}
104
+ mcpTools={MCP_TOOLS}
105
+ agents={[
106
+ {
107
+ id: 'a1',
108
+ slug: 'ops',
109
+ name: '电脑操作员',
110
+ icon: null,
111
+ color: '#2563eb',
112
+ description: '',
113
+ rolePrompt: '',
114
+ isBuiltin: true,
115
+ sortOrder: 1,
116
+ },
117
+ ]}
118
+ >
119
+ {'@电脑操作员 用 /loop 每小时跑一次'}
120
+ </Markdown>,
121
+ );
122
+
123
+ const toolChip = container.querySelector('[data-message-tool-chip]');
124
+ expect(toolChip!.textContent).toBe('/loop');
125
+ const mentionChip = container.querySelector('[data-mention-chip]');
126
+ expect(mentionChip!.getAttribute('data-mention-type')).toBe('agent');
127
+ expect(screen.getByText('@电脑操作员')).toBeTruthy();
128
+ });
129
+
130
+ it('邮箱与认不出的 @token 保持纯文本', () => {
131
+ const { container } = render(
132
+ <Markdown inlineParagraph skills={SKILLS} mcpTools={MCP_TOOLS}>
133
+ {'发到 a@b.com,参数写 @param'}
134
+ </Markdown>,
135
+ );
136
+
137
+ expect(container.querySelector('[data-mention-chip]')).toBeNull();
138
+ expect(container.textContent).toBe('发到 a@b.com,参数写 @param');
139
+ });
140
+
141
+ it('没有传目录时斜杠 token 保持纯文本', () => {
142
+ const { container } = render(<Markdown inlineParagraph>{'/loop 你好'}</Markdown>);
143
+ expect(container.querySelector('[data-message-tool-chip]')).toBeNull();
144
+ expect(container.textContent).toBe('/loop 你好');
145
+ });
146
+ });
147
+
148
+ describe('Markdown 消息内的代码块', () => {
149
+ it('代码块里的斜杠 token 不做替换', () => {
150
+ const { container } = renderFenced();
151
+ expect(container.querySelector('[data-message-tool-chip]')).toBeNull();
152
+ expect(container.textContent).toContain('/loop');
153
+ });
154
+
155
+ function renderFenced() {
156
+ return render(
157
+ <Markdown skills={SKILLS} mcpTools={MCP_TOOLS}>
158
+ {'```\n/loop 5m\n```'}
159
+ </Markdown>,
160
+ );
161
+ }
162
+ });
163
+
164
+ // 保证渲染入口本身没有在无 token 时改写文本节点
165
+ describe('Markdown 普通文本', () => {
166
+ it('不含 token 的段落原样渲染', () => {
167
+ renderMessage('普通一句话,没有任何 token。');
168
+ expect(screen.getByText('普通一句话,没有任何 token。')).toBeTruthy();
169
+ });
170
+ });
@@ -0,0 +1,432 @@
1
+ import {
2
+ cloneElement,
3
+ createElement,
4
+ Fragment,
5
+ isValidElement,
6
+ type ComponentPropsWithoutRef,
7
+ type ElementType,
8
+ type ReactNode,
9
+ } from 'react';
10
+ import ReactMarkdown, { type Components } from 'react-markdown';
11
+ import remarkGfm from 'remark-gfm';
12
+ import rehypeRaw from 'rehype-raw';
13
+ import { LuBlocks, LuMessageSquare, LuPlug } from 'react-icons/lu';
14
+ import type { LocalChat, LocalChatAgent } from '@/lib/local-api';
15
+ import {
16
+ resolveSlashTool,
17
+ type McpToolItem,
18
+ type SkillItem,
19
+ } from '@/lib/slash-sources';
20
+ import { PlanTodoList } from './PlanTodoList';
21
+
22
+ /**
23
+ * Markdown renderer shared by `UserMessage` and `AssistantMessage`.
24
+ *
25
+ * Pinned closely to deeppath's renderer so visual parity holds:
26
+ * - GFM tables / strikethrough via `remark-gfm`
27
+ * - raw HTML escape via `rehype-raw` (agent backends occasionally emit
28
+ * `<sub>` / `<sup>` / `<details>` markup)
29
+ * - links open in new tab with `noopener`
30
+ * - tables wrap inside a scroll container so wide tables don't blow the
31
+ * bubble width
32
+ * - blockquote — the backend tool summary uses
33
+ * `🔧 \`tool\` ✅`
34
+ * `> 参数:…`
35
+ * `> 结果:…`
36
+ * so blockquote gets a subtle gray left border + indent to read as a
37
+ * "tool detail callout" instead of plain text.
38
+ * - lists, headings, hr — explicit margins / typography classes so the
39
+ * agent's multi-step responses don't collapse into a wall of text.
40
+ * - cells get `max-width` + word-break so long unbreakable text doesn't
41
+ * stretch the row
42
+ * - paragraphs preserve `whitespace: pre-line` so single newlines render
43
+ * (matches what users typed)
44
+ */
45
+
46
+ const components: Components = {
47
+ a: (props: ComponentPropsWithoutRef<'a'>) => (
48
+ <a
49
+ {...props}
50
+ className="text-agent-accent hover:underline"
51
+ target="_blank"
52
+ rel="noopener noreferrer"
53
+ />
54
+ ),
55
+
56
+ // ── Block-level typography ────────────────────────────────────────────
57
+ h1: (props: ComponentPropsWithoutRef<'h1'>) => (
58
+ <h1
59
+ {...props}
60
+ className="mb-2 mt-3 text-base font-semibold text-agent-foreground first:mt-0"
61
+ />
62
+ ),
63
+ h2: (props: ComponentPropsWithoutRef<'h2'>) => (
64
+ <h2
65
+ {...props}
66
+ className="mb-1.5 mt-3 text-[14px] font-semibold text-agent-foreground first:mt-0"
67
+ />
68
+ ),
69
+ h3: (props: ComponentPropsWithoutRef<'h3'>) => (
70
+ <h3
71
+ {...props}
72
+ className="mb-1 mt-2 text-[13px] font-semibold text-agent-foreground first:mt-0"
73
+ />
74
+ ),
75
+ h4: (props: ComponentPropsWithoutRef<'h4'>) => (
76
+ <h4
77
+ {...props}
78
+ className="mb-1 mt-2 text-[13px] font-semibold text-agent-foreground first:mt-0"
79
+ />
80
+ ),
81
+
82
+ p: (props: ComponentPropsWithoutRef<'p'>) => (
83
+ <p
84
+ style={{ whiteSpace: 'pre-line' }}
85
+ {...props}
86
+ className="my-1 leading-relaxed"
87
+ />
88
+ ),
89
+
90
+ // ── Lists ─────────────────────────────────────────────────────────────
91
+ ul: (props: ComponentPropsWithoutRef<'ul'>) => (
92
+ <ul {...props} className="my-1.5 list-disc space-y-0.5 pl-5" />
93
+ ),
94
+ ol: (props: ComponentPropsWithoutRef<'ol'>) => (
95
+ <ol {...props} className="my-1.5 list-decimal space-y-0.5 pl-5" />
96
+ ),
97
+ li: (props: ComponentPropsWithoutRef<'li'>) => (
98
+ <li
99
+ {...props}
100
+ className="leading-relaxed marker:text-agent-muted-foreground/60"
101
+ />
102
+ ),
103
+
104
+ // ── Inline emphasis ───────────────────────────────────────────────────
105
+ strong: (props: ComponentPropsWithoutRef<'strong'>) => (
106
+ <strong {...props} className="font-semibold text-agent-foreground" />
107
+ ),
108
+ em: (props: ComponentPropsWithoutRef<'em'>) => (
109
+ <em {...props} className="italic" />
110
+ ),
111
+
112
+ // ── Blockquote (backend uses this for `> 参数:` / `> 结果:` lines) ─
113
+ blockquote: (props: ComponentPropsWithoutRef<'blockquote'>) => (
114
+ <blockquote
115
+ {...props}
116
+ className="my-1.5 border-l-2 border-agent-border bg-agent-muted/40 px-3 py-1 text-[13px] text-agent-muted-foreground [&>p]:my-0"
117
+ />
118
+ ),
119
+
120
+ // ── Horizontal rule ──────────────────────────────────────────────────
121
+ hr: (props: ComponentPropsWithoutRef<'hr'>) => (
122
+ <hr {...props} className="my-3 border-agent-border" />
123
+ ),
124
+
125
+ // ── Tables ───────────────────────────────────────────────────────────
126
+ table: (props: ComponentPropsWithoutRef<'table'>) => (
127
+ <div className="my-2 w-full overflow-x-auto rounded-md border border-agent-border">
128
+ <table {...props} className="w-full border-collapse text-[12px]" />
129
+ </div>
130
+ ),
131
+ thead: (props: ComponentPropsWithoutRef<'thead'>) => (
132
+ <thead {...props} className="bg-agent-muted/60" />
133
+ ),
134
+ th: (props: ComponentPropsWithoutRef<'th'>) => (
135
+ <th
136
+ {...props}
137
+ className="border-b border-agent-border px-2 py-1.5 text-left font-medium text-agent-foreground"
138
+ />
139
+ ),
140
+ td: (props: ComponentPropsWithoutRef<'td'>) => (
141
+ <td
142
+ {...props}
143
+ className="border-b border-agent-border px-2 py-1.5 align-top break-words"
144
+ style={{ maxWidth: '300px' }}
145
+ />
146
+ ),
147
+
148
+ // ── Code (inline + block) ─────────────────────────────────────────────
149
+ code: (props) => {
150
+ const { children, className, ...rest } = props as ComponentPropsWithoutRef<'code'> & {
151
+ // react-markdown v9 dropped the `inline` typed prop. Distinguish by
152
+ // language fence (the only time class is set is for fenced blocks
153
+ // like ```ts which receive `language-ts`).
154
+ inline?: boolean;
155
+ };
156
+ // ```plan fences (emitted by the plan-mode skill) render as a visual
157
+ // todolist card instead of a monospace block. The matching <pre>
158
+ // wrapper is unwrapped below in the `pre` renderer.
159
+ if (isPlanCodeClass(className)) {
160
+ return <PlanTodoList content={childrenToText(children)} />;
161
+ }
162
+ const isBlock = typeof className === 'string' && className.startsWith('language-');
163
+ if (isBlock) {
164
+ return (
165
+ <code {...rest} className={`${className} font-mono text-[12px]`}>
166
+ {children}
167
+ </code>
168
+ );
169
+ }
170
+ return (
171
+ <code
172
+ {...rest}
173
+ className="rounded bg-agent-muted px-1 py-0.5 font-mono text-[0.85em] text-agent-foreground"
174
+ >
175
+ {children}
176
+ </code>
177
+ );
178
+ },
179
+ pre: (props: ComponentPropsWithoutRef<'pre'>) => {
180
+ // A ```plan fence arrives as <pre><code class="language-plan">…</code></pre>.
181
+ // The code renderer above already swaps the inner node for PlanTodoList;
182
+ // here we drop the <pre> shell so the card isn't boxed in a code block.
183
+ if (containsPlanCode(props.children)) {
184
+ return <>{props.children}</>;
185
+ }
186
+ return (
187
+ <pre
188
+ {...props}
189
+ className="my-2 overflow-x-auto rounded-md border border-agent-border bg-agent-muted/60 p-2.5 text-[12px] leading-relaxed text-agent-foreground"
190
+ />
191
+ );
192
+ },
193
+ };
194
+
195
+ function isPlanCodeClass(className: unknown): boolean {
196
+ return typeof className === 'string' && className.split(/\s+/).includes('language-plan');
197
+ }
198
+
199
+ function containsPlanCode(children: ReactNode): boolean {
200
+ const nodes = Array.isArray(children) ? children : [children];
201
+ return nodes.some(
202
+ (node) =>
203
+ isValidElement<{ className?: string }>(node) &&
204
+ isPlanCodeClass(node.props.className),
205
+ );
206
+ }
207
+
208
+ function childrenToText(children: ReactNode): string {
209
+ if (typeof children === 'string') return children;
210
+ if (Array.isArray(children)) return children.map(childrenToText).join('');
211
+ if (isValidElement<{ children?: ReactNode }>(children)) {
212
+ return childrenToText(children.props.children);
213
+ }
214
+ return children == null ? '' : String(children);
215
+ }
216
+
217
+ /** Chip shell shared by @mention and /tool tokens inside a sent message. */
218
+ const CHIP_BASE =
219
+ 'inline-flex items-center gap-1 mx-0.5 px-1.5 py-0.5 rounded font-medium text-xs select-all align-middle';
220
+
221
+ /**
222
+ * Split a text node on both token kinds so a message can carry `@专家` and
223
+ * `/tool` side by side. Both only open at line start or after whitespace, via
224
+ * a lookbehind so the leading space stays in the surrounding text: that keeps
225
+ * paths (`/mnt/c`), URLs and email addresses (`a@b.com`) out of the split.
226
+ */
227
+ const TOKEN_SPLIT_PATTERN = /(?<=^|\s)(?:(@[^\s@]+)|(\/[^\s/]+))/g;
228
+
229
+ /** Already-built chips are opaque: decorating them again would nest chips. */
230
+ function isChip(node: ReactNode): boolean {
231
+ if (!isValidElement(node)) return false;
232
+ const props = node.props as Record<string, unknown>;
233
+ return 'data-message-tool-chip' in props || 'data-mention-chip' in props;
234
+ }
235
+
236
+ function renderTextWithMentions(
237
+ node: ReactNode,
238
+ agents: LocalChatAgent[] = [],
239
+ chats: LocalChat[] = [],
240
+ skills: SkillItem[] = [],
241
+ mcpTools: McpToolItem[] = [],
242
+ ): ReactNode {
243
+ if (typeof node === 'string') {
244
+ const parts = node.split(TOKEN_SPLIT_PATTERN).filter((p) => p !== undefined);
245
+ if (parts.length === 1) return node;
246
+
247
+ return parts.map((part, index) => {
248
+ if (part.startsWith('/')) {
249
+ const tool = resolveSlashTool(part.slice(1), skills, mcpTools);
250
+ if (!tool) return part;
251
+ return tool.type === 'skill' ? (
252
+ <span
253
+ key={index}
254
+ data-message-tool-chip=""
255
+ data-tool-type="skill"
256
+ className={`${CHIP_BASE} bg-amber-500/10 text-amber-600 dark:bg-amber-500/20 dark:text-amber-400 border border-amber-500/20`}
257
+ >
258
+ <LuBlocks className="h-3 w-3 shrink-0 text-amber-500" />
259
+ <span>{part}</span>
260
+ </span>
261
+ ) : (
262
+ <span
263
+ key={index}
264
+ data-message-tool-chip=""
265
+ data-tool-type="mcp"
266
+ className={`${CHIP_BASE} bg-sky-500/10 text-sky-600 dark:bg-sky-500/20 dark:text-sky-400 border border-sky-500/20`}
267
+ >
268
+ <LuPlug className="h-3 w-3 shrink-0 text-sky-500" />
269
+ <span>{part}</span>
270
+ </span>
271
+ );
272
+ }
273
+
274
+ if (part.startsWith('@')) {
275
+ const name = part.slice(1);
276
+ const agent = agents.find((a) => a.name === name || a.slug === name);
277
+ if (agent) {
278
+ return (
279
+ <span
280
+ key={index}
281
+ data-mention-chip=""
282
+ data-mention-type="agent"
283
+ className="inline-flex items-center gap-1.5 mx-0.5 px-1.5 py-0.5 rounded bg-indigo-500/10 text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-400 border border-indigo-500/20 font-medium text-xs select-all align-middle"
284
+ >
285
+ <span
286
+ className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-full text-[8px] font-semibold text-white"
287
+ style={{ backgroundColor: agent.color || '#7c3aed' }}
288
+ >
289
+ {agent.name.trim()[0]?.toUpperCase() ?? 'A'}
290
+ </span>
291
+ <span>{part}</span>
292
+ </span>
293
+ );
294
+ }
295
+
296
+ const chat = chats.find((c) => (c.title || '未命名对话') === name);
297
+ if (chat) {
298
+ return (
299
+ <span
300
+ key={index}
301
+ data-mention-chip=""
302
+ data-mention-type="chat"
303
+ className="inline-flex items-center gap-1 mx-0.5 px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-600 dark:bg-emerald-500/20 dark:text-emerald-400 border border-emerald-500/20 font-medium text-xs select-all align-middle"
304
+ >
305
+ <LuMessageSquare className="h-3 w-3 text-emerald-500 shrink-0" />
306
+ <span>{part}</span>
307
+ </span>
308
+ );
309
+ }
310
+
311
+ // 认不出的 @token 保持纯文本:助手回复里的 `@param`、`@media` 之类
312
+ // 不是提及,套上卡片只会误导。
313
+ return part;
314
+ }
315
+ return part;
316
+ });
317
+ }
318
+
319
+ if (Array.isArray(node)) {
320
+ return node.map((child, idx) => (
321
+ <Fragment key={idx}>
322
+ {renderTextWithMentions(child, agents, chats, skills, mcpTools)}
323
+ </Fragment>
324
+ ));
325
+ }
326
+
327
+ if (isValidElement(node)) {
328
+ const type = node.type;
329
+ // Custom components (`typeof type !== 'string'`) are react-markdown's own
330
+ // renderers; they decorate their children themselves via `withTokens`.
331
+ if (
332
+ type === 'code' ||
333
+ type === 'pre' ||
334
+ type === 'a' ||
335
+ typeof type !== 'string' ||
336
+ isChip(node)
337
+ ) {
338
+ return node;
339
+ }
340
+ const children = (node.props as any).children;
341
+ if (children) {
342
+ return cloneElement(node, {
343
+ ...node.props,
344
+ children: renderTextWithMentions(children, agents, chats, skills, mcpTools),
345
+ } as any);
346
+ }
347
+ }
348
+
349
+ return node;
350
+ }
351
+
352
+ interface MarkdownProps {
353
+ children: string;
354
+ /** Override the `<p>` renderer (UserMessage uses `<span>` to inline). */
355
+ inlineParagraph?: boolean;
356
+ agents?: LocalChatAgent[];
357
+ chats?: LocalChat[];
358
+ /** Local skills behind `/name` chips. Omit to leave slash tokens as text. */
359
+ skills?: SkillItem[];
360
+ /** MCP tools behind `/mcp__srv__tool` chips. */
361
+ mcpTools?: McpToolItem[];
362
+ }
363
+
364
+ /**
365
+ * Tags whose children can hold raw text. Each gets its own decoration pass
366
+ * because react-markdown hands every element to a custom renderer, so walking
367
+ * the tree from the outside stops at the first override.
368
+ */
369
+ const TOKEN_HOST_TAGS = [
370
+ 'p',
371
+ 'li',
372
+ 'td',
373
+ 'th',
374
+ 'h1',
375
+ 'h2',
376
+ 'h3',
377
+ 'h4',
378
+ 'strong',
379
+ 'em',
380
+ ] as const;
381
+
382
+ function withTokens(
383
+ base: Components,
384
+ decorate: (children: ReactNode) => ReactNode,
385
+ ): Components {
386
+ // Indexing `Components` by a 10-tag union blows up TS's union budget
387
+ // (TS2590), so build the overrides against a flat record.
388
+ const wrapped = { ...base } as Record<string, ElementType>;
389
+ for (const tag of TOKEN_HOST_TAGS) {
390
+ const Renderer = (wrapped[tag] ?? tag) as ElementType;
391
+ wrapped[tag] = ({ children, ...rest }: { children?: ReactNode }) =>
392
+ createElement(Renderer, rest, decorate(children));
393
+ }
394
+ return wrapped as Components;
395
+ }
396
+
397
+ export function Markdown({
398
+ children,
399
+ inlineParagraph,
400
+ agents = [],
401
+ chats = [],
402
+ skills = [],
403
+ mcpTools = [],
404
+ }: MarkdownProps) {
405
+ const baseComponents: Components = inlineParagraph
406
+ ? {
407
+ ...components,
408
+ p: (props: ComponentPropsWithoutRef<'p'>) => (
409
+ <span style={{ whiteSpace: 'pre-line' }} {...props} />
410
+ ),
411
+ }
412
+ : components;
413
+
414
+ const finalComponents = withTokens(baseComponents, (node) =>
415
+ renderTextWithMentions(node, agents, chats, skills, mcpTools),
416
+ );
417
+
418
+ return (
419
+ <ReactMarkdown
420
+ // `singleTilde: false` — GFM 删除线只认 `~~text~~`。remark-gfm 默认还把
421
+ // 单个 `~`("约~50%"、"5~10 个"、"~/path")当删除线开闭符,导致普通
422
+ // 文本被莫名划掉。GitHub 本站也只渲染双波浪线。
423
+ remarkPlugins={[[remarkGfm, { singleTilde: false }]]}
424
+ rehypePlugins={[rehypeRaw]}
425
+ components={finalComponents}
426
+ >
427
+ {children}
428
+ </ReactMarkdown>
429
+ );
430
+ }
431
+
432
+ export default Markdown;