@kingsoftcloud/agentkit-harness-ui 0.0.0-stage → 0.0.1
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 +201 -0
- package/NOTICE +116 -0
- package/README.md +292 -2
- package/markdown/build/build-markdown.cjs +66 -0
- package/markdown/dist/THIRD_PARTY_LICENSES.txt +2993 -0
- package/markdown/dist/markdown.css +1 -0
- package/markdown/dist/markdown.mjs +325 -0
- package/markdown/source/markdown-factory.mjs +613 -0
- package/markdown/source/markdown.css +288 -0
- package/markdown/source/table-factory.mjs +1588 -0
- package/markdown/source/table.css +487 -0
- package/package.json +96 -4
- package/src/activity-groups.mjs +74 -0
- package/src/browser-response-groups.mjs +57 -0
- package/src/command-output.mjs +57 -0
- package/src/components.mjs +9 -0
- package/src/context-markers.mjs +19 -0
- package/src/context-status.mjs +34 -0
- package/src/diff.mjs +240 -0
- package/src/explore-tool-call.mjs +139 -0
- package/src/file-preview.mjs +50 -0
- package/src/host/native-a2a.mjs +90 -0
- package/src/host/native-child-file-pane.mjs +49 -0
- package/src/host/native-child-pane.mjs +94 -0
- package/src/host/native-command-pane.mjs +64 -0
- package/src/host/native-run-panel.mjs +202 -0
- package/src/host/session-task-events.mjs +247 -0
- package/src/host/state/native-a2a.mjs +122 -0
- package/src/host/state/native-child-observation.mjs +35 -0
- package/src/host/state/native-children.mjs +93 -0
- package/src/host/state/native-outcome.mjs +35 -0
- package/src/host/state/native-recovery.mjs +187 -0
- package/src/host/state/native-runtime-route.mjs +26 -0
- package/src/host/state/native-session-stop.mjs +40 -0
- package/src/host/state/native-status-panel.mjs +23 -0
- package/src/host/state/native-task-read-model.mjs +89 -0
- package/src/host/state/task-links.mjs +40 -0
- package/src/host/task-timeline.mjs +185 -0
- package/src/index.mjs +52 -0
- package/src/input-preview.mjs +44 -0
- package/src/inspector.mjs +40 -0
- package/src/project.mjs +132 -0
- package/src/protocol.mjs +362 -0
- package/src/react-binding.mjs +486 -0
- package/src/reducer.mjs +355 -0
- package/src/renderers.mjs +644 -0
- package/src/status-panel.mjs +1156 -0
- package/src/styles.mjs +278 -0
- package/src/summary-playback.mjs +39 -0
- package/src/summary.mjs +160 -0
- package/src/terminal.mjs +157 -0
- package/src/tool-detail.mjs +208 -0
- package/src/tool-error.mjs +33 -0
- package/src/tool-identity.mjs +40 -0
- package/src/view.mjs +364 -0
- package/src/work-segments.mjs +41 -0
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
// @kingsoftcloud/agentkit-harness-ui/markdown — Markdown rendering layer factory
|
|
2
|
+
//
|
|
3
|
+
// A host-injected-React factory that renders streaming/complete assistant
|
|
4
|
+
// Markdown through a statically bundled Streamdown 2.5.0 + @streamdown/cjk
|
|
5
|
+
// 1.0.3 pipeline, with a STABLE, independent code block that highlights via
|
|
6
|
+
// @shikijs/core 3.23.0 + the JS regex engine + a registered set of common
|
|
7
|
+
// languages and two themes — NOT Streamdown's default code renderer — and a
|
|
8
|
+
// STABLE, independent Markdown table component (table-factory.mjs, ported from
|
|
9
|
+
// the table renderer) that owns the toolbar (copy Markdown / download
|
|
10
|
+
// CSV / fullscreen preview / wide-table expand-collapse), the horizontal
|
|
11
|
+
// scroll viewport with edge shadows, the hover virtual scrollbar and the
|
|
12
|
+
// fullscreen preview dialog — NOT Streamdown's default table renderer.
|
|
13
|
+
//
|
|
14
|
+
// React, ReactDOM and the JSX runtime are NOT bundled: the build
|
|
15
|
+
// (../build/build-markdown.cjs via esbuild) marks `react`, `react-dom` and
|
|
16
|
+
// `react/jsx-runtime` external, so the dist CJS `require()`s them from the
|
|
17
|
+
// host at runtime. The factory uses ONLY the React/ReactDOM instances passed
|
|
18
|
+
// into createMarkdownComponents(), which the host must keep identical to the
|
|
19
|
+
// ones resolved by those `require()` calls (see verifyReactInjection).
|
|
20
|
+
//
|
|
21
|
+
// Streaming vs. complete (desktop MessageResponse / code-block.tsx React #185
|
|
22
|
+
// avoidance): while `streaming` is true, code blocks render raw text with no
|
|
23
|
+
// async highlighter, and Streamdown runs in `mode="streaming"` with
|
|
24
|
+
// `parseIncompleteMarkdown`. When `streaming` flips false, the stable CodeBlock
|
|
25
|
+
// highlights the (now stable) source. No timer fakes text; no setState races
|
|
26
|
+
// between the streaming body and the highlighter.
|
|
27
|
+
//
|
|
28
|
+
// No eval / new Function; no Studio/DSH/Node-only imports at runtime; no wasm;
|
|
29
|
+
// no oniguruma; no second React copy.
|
|
30
|
+
|
|
31
|
+
import { Streamdown, defaultRehypePlugins, defaultRemarkPlugins } from "streamdown";
|
|
32
|
+
import { cjk as cjkPlugin } from "@streamdown/cjk";
|
|
33
|
+
import { createHighlighterCore } from "@shikijs/core";
|
|
34
|
+
import { createJavaScriptRegexEngine } from "@shikijs/engine-javascript";
|
|
35
|
+
import githubLight from "@shikijs/themes/github-light";
|
|
36
|
+
import githubDark from "@shikijs/themes/github-dark";
|
|
37
|
+
import langJavascript from "@shikijs/langs/javascript";
|
|
38
|
+
import langTypescript from "@shikijs/langs/typescript";
|
|
39
|
+
import langJsx from "@shikijs/langs/jsx";
|
|
40
|
+
import langTsx from "@shikijs/langs/tsx";
|
|
41
|
+
import langJson from "@shikijs/langs/json";
|
|
42
|
+
import langPython from "@shikijs/langs/python";
|
|
43
|
+
import langShellscript from "@shikijs/langs/shellscript";
|
|
44
|
+
import langCss from "@shikijs/langs/css";
|
|
45
|
+
import langHtml from "@shikijs/langs/html";
|
|
46
|
+
import langMarkdown from "@shikijs/langs/markdown";
|
|
47
|
+
import langYaml from "@shikijs/langs/yaml";
|
|
48
|
+
import langSql from "@shikijs/langs/sql";
|
|
49
|
+
import langGo from "@shikijs/langs/go";
|
|
50
|
+
import langRust from "@shikijs/langs/rust";
|
|
51
|
+
import langDiff from "@shikijs/langs/diff";
|
|
52
|
+
import langJava from "@shikijs/langs/java";
|
|
53
|
+
import langBash from "@shikijs/langs/bash";
|
|
54
|
+
|
|
55
|
+
import { createMarkdownTableComponents } from "./table-factory.mjs";
|
|
56
|
+
|
|
57
|
+
const LIGHT_THEME = "github-light";
|
|
58
|
+
const DARK_THEME = "github-dark";
|
|
59
|
+
|
|
60
|
+
// Registered grammars. Unknown languages fall back to plain text (no
|
|
61
|
+
// highlighting) so the bundle stays bounded — only these grammars ship.
|
|
62
|
+
const REGISTERED_LANGS = {
|
|
63
|
+
javascript: langJavascript,
|
|
64
|
+
typescript: langTypescript,
|
|
65
|
+
jsx: langJsx,
|
|
66
|
+
tsx: langTsx,
|
|
67
|
+
json: langJson,
|
|
68
|
+
python: langPython,
|
|
69
|
+
shellscript: langShellscript,
|
|
70
|
+
css: langCss,
|
|
71
|
+
html: langHtml,
|
|
72
|
+
markdown: langMarkdown,
|
|
73
|
+
yaml: langYaml,
|
|
74
|
+
sql: langSql,
|
|
75
|
+
go: langGo,
|
|
76
|
+
rust: langRust,
|
|
77
|
+
diff: langDiff,
|
|
78
|
+
java: langJava,
|
|
79
|
+
bash: langBash,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// Aliases map to a registered id. Anything not here and not a registered id
|
|
83
|
+
// resolves to plaintext.
|
|
84
|
+
const LANG_ALIASES = {
|
|
85
|
+
js: "javascript",
|
|
86
|
+
cjs: "javascript",
|
|
87
|
+
mjs: "javascript",
|
|
88
|
+
ts: "typescript",
|
|
89
|
+
py: "python",
|
|
90
|
+
sh: "shellscript",
|
|
91
|
+
shell: "shellscript",
|
|
92
|
+
zsh: "shellscript",
|
|
93
|
+
"sh-session": "shellscript",
|
|
94
|
+
console: "shellscript",
|
|
95
|
+
bash: "bash",
|
|
96
|
+
md: "markdown",
|
|
97
|
+
yml: "yaml",
|
|
98
|
+
rs: "rust",
|
|
99
|
+
htm: "html",
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const LINK_SAFETY = { enabled: false };
|
|
103
|
+
// Streamdown's default `table` component renders its own copy/download/
|
|
104
|
+
// fullscreen toolbar (the `vo` wrapper). The table component override below
|
|
105
|
+
// replaces that renderer entirely, so the default table controls are turned
|
|
106
|
+
// off here as well for clarity (and so they cannot reappear if a future
|
|
107
|
+
// Streamdown version invokes the default wrapper from another path).
|
|
108
|
+
const STREAMDOWN_CONTROLS = { table: false };
|
|
109
|
+
|
|
110
|
+
const DEFAULT_LABELS = {
|
|
111
|
+
copyCode: "Copy code",
|
|
112
|
+
copied: "Copied",
|
|
113
|
+
wrapLines: "Wrap",
|
|
114
|
+
languageLabel: "text",
|
|
115
|
+
// Markdown table labels — ported from the Chinese locale
|
|
116
|
+
// (packages/ui/src/i18n/locales/zh-CN.ts, markdownTable.*). Hosts override
|
|
117
|
+
// per locale through the `labels` prop on MarkdownResponse.
|
|
118
|
+
copyMarkdown: "复制 Markdown",
|
|
119
|
+
downloadCsv: "下载 CSV",
|
|
120
|
+
openPreview: "预览表格",
|
|
121
|
+
expandScrollMode: "展开表格滚动区域",
|
|
122
|
+
collapseScrollMode: "收回表格滚动区域",
|
|
123
|
+
previewTitle: "表格预览",
|
|
124
|
+
previewDescription: "在更大的可滚动视图中查看表格。",
|
|
125
|
+
copySucceeded: "已复制 Markdown 表格",
|
|
126
|
+
copyFailed: "复制表格失败:{error}",
|
|
127
|
+
downloadFailed: "下载表格失败",
|
|
128
|
+
closePreview: "关闭",
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
function isObject(value) {
|
|
132
|
+
return typeof value === "object" && value !== null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function extractText(node) {
|
|
136
|
+
if (typeof node === "string" || typeof node === "number") return String(node);
|
|
137
|
+
if (Array.isArray(node)) return node.map(extractText).join("");
|
|
138
|
+
if (isObject(node) && isObject(node.props)) return extractText(node.props.children);
|
|
139
|
+
return "";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function resolveCodeLanguage(className) {
|
|
143
|
+
if (typeof className !== "string") return "";
|
|
144
|
+
const match = className.match(/(?:^|\s)language-([^\s]+)/);
|
|
145
|
+
return match ? match[1] : "";
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function resolveLangId(language) {
|
|
149
|
+
const norm = String(language || "").trim().toLowerCase();
|
|
150
|
+
if (!norm) return "";
|
|
151
|
+
if (REGISTERED_LANGS[norm]) return norm;
|
|
152
|
+
const alias = LANG_ALIASES[norm];
|
|
153
|
+
if (alias && REGISTERED_LANGS[alias]) return alias;
|
|
154
|
+
return "";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function resolveEffectiveTheme(theme, prefersDark) {
|
|
158
|
+
if (theme === "dark") return "dark";
|
|
159
|
+
if (theme === "light") return "light";
|
|
160
|
+
return prefersDark ? "dark" : "light";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function readPrefersDark() {
|
|
164
|
+
if (typeof window !== "object" || typeof window.matchMedia !== "function") return false;
|
|
165
|
+
try {
|
|
166
|
+
return Boolean(window.matchMedia("(prefers-color-scheme: dark)").matches);
|
|
167
|
+
} catch {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function isExternalHref(href) {
|
|
173
|
+
if (typeof href !== "string") return false;
|
|
174
|
+
return /^https?:\/\//i.test(href);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Bare relative file paths like `src/a.ts` or `README.md` are not known URL
|
|
178
|
+
// schemes, so rehype-harden (in Streamdown's default rehype plugins) replaces
|
|
179
|
+
// them with `[blocked]` before the custom `a` renderer runs. The desktop
|
|
180
|
+
// MessageResponse fixes this with a rehype plugin that normalizes such hrefs
|
|
181
|
+
// to `./…` before harden; this is the minimal equivalent. `./`, `/`, `../`,
|
|
182
|
+
// `#…` and scheme-prefixed hrefs are left alone.
|
|
183
|
+
function shouldRewriteBareFileHref(href) {
|
|
184
|
+
if (typeof href !== "string" || !href) return false;
|
|
185
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(href)) return false;
|
|
186
|
+
if (href.startsWith("#") || href.startsWith("/") || href.startsWith("./") || href.startsWith("../")) return false;
|
|
187
|
+
return /\.[^./\\]+$/.test(href) || href.includes("/") || href.includes("\\");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function rewriteBareFileLinkRehypePlugin() {
|
|
191
|
+
return (tree) => {
|
|
192
|
+
const visit = (node) => {
|
|
193
|
+
if (node && node.type === "element") {
|
|
194
|
+
const tag = node.tagName;
|
|
195
|
+
const prop = tag === "a" ? "href" : tag === "img" ? "src" : null;
|
|
196
|
+
if (prop && node.properties && typeof node.properties[prop] === "string") {
|
|
197
|
+
const href = node.properties[prop];
|
|
198
|
+
if (tag === "a" && routeLink(href) === "file") node.properties["data-native-file-href"] = href;
|
|
199
|
+
if (shouldRewriteBareFileHref(href)) node.properties[prop] = "./" + href;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (node && Array.isArray(node.children)) node.children.forEach(visit);
|
|
203
|
+
};
|
|
204
|
+
visit(tree);
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Classify a link target for the `a` override. External http/https → "external";
|
|
209
|
+
// anything with a scheme (mailto:, tel:, javascript:, data:, …) or an anchor →
|
|
210
|
+
// "muted"; a bare/relative path or filename → "file". Hosts that supply
|
|
211
|
+
// onOpenFileLink get clickable file links; nothing untrusted gets a native <a>.
|
|
212
|
+
function routeLink(href) {
|
|
213
|
+
if (typeof href !== "string" || href === "") return "muted";
|
|
214
|
+
if (isExternalHref(href)) return "external";
|
|
215
|
+
if (href.startsWith("#")) return "muted";
|
|
216
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(href)) return "muted";
|
|
217
|
+
return "file";
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// shiki dual-theme tokens carry light color in `htmlStyle.color` and the dark
|
|
221
|
+
// color in `htmlStyle["--shiki-dark"]`; the bg/fg strings are
|
|
222
|
+
// `"#light;--shiki-dark-bg:#dark"`. We remap color→--sdm-c and
|
|
223
|
+
// background-color→--sdm-tbg so CSS can switch with the wrapper theme without
|
|
224
|
+
// fighting inline `color` (which would otherwise win in both themes).
|
|
225
|
+
function tokenStyle(token) {
|
|
226
|
+
const hs = token && token.htmlStyle ? token.htmlStyle : {};
|
|
227
|
+
const style = {};
|
|
228
|
+
for (const key of Object.keys(hs)) {
|
|
229
|
+
const value = hs[key];
|
|
230
|
+
if (key === "color") style["--sdm-c"] = value;
|
|
231
|
+
else if (key === "background-color") style["--sdm-tbg"] = value;
|
|
232
|
+
else style[key] = value;
|
|
233
|
+
}
|
|
234
|
+
return style;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function applyDual(style, prefix, combined) {
|
|
238
|
+
if (!combined) return;
|
|
239
|
+
const parts = String(combined).split(";");
|
|
240
|
+
if (parts[0]) style[prefix] = parts[0];
|
|
241
|
+
for (let i = 1; i < parts.length; i++) {
|
|
242
|
+
const idx = parts[i].indexOf(":");
|
|
243
|
+
if (idx > 0) style[parts[i].slice(0, idx).trim()] = parts[i].slice(idx + 1).trim();
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Singleton highlighter promise. createHighlighterCore is async even with
|
|
248
|
+
// statically-imported grammars/themes, so we init once and await in the
|
|
249
|
+
// CodeBlock effect. The JS regex engine is synchronous once loaded, so
|
|
250
|
+
// codeToTokens never races the render.
|
|
251
|
+
let highlighterPromise = null;
|
|
252
|
+
function getHighlighter() {
|
|
253
|
+
if (!highlighterPromise) {
|
|
254
|
+
highlighterPromise = createHighlighterCore({
|
|
255
|
+
langs: Object.values(REGISTERED_LANGS),
|
|
256
|
+
themes: [githubLight, githubDark],
|
|
257
|
+
engine: createJavaScriptRegexEngine({ forgiving: true }),
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
return highlighterPromise;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Build the Markdown React components from host-injected React/ReactDOM.
|
|
265
|
+
*
|
|
266
|
+
* const { MarkdownResponse } = createMarkdownComponents(React, ReactDOM);
|
|
267
|
+
* renderText={(text, { streaming }) =>
|
|
268
|
+
* React.createElement(MarkdownResponse, { streaming }, text)}
|
|
269
|
+
*
|
|
270
|
+
* The bundle's only external `require()`s are `react`, `react-dom` and
|
|
271
|
+
* `react/jsx-runtime` (see ../build/build-markdown.cjs). The host must keep
|
|
272
|
+
* those resolved instances identical to the React/ReactDOM passed here.
|
|
273
|
+
*
|
|
274
|
+
* @param {object} React Host React (createElement + hooks).
|
|
275
|
+
* @param {object} [ReactDOM] Host ReactDOM (Streamdown's bundled code
|
|
276
|
+
* requires react-dom for portal-based controls; the host injects it via the
|
|
277
|
+
* external require).
|
|
278
|
+
* @returns {{ MarkdownResponse: object, Markdown: object, cjkPlugin: unknown, registeredLanguages: string[] }}
|
|
279
|
+
*/
|
|
280
|
+
export function createMarkdownComponents(React, ReactDOM) {
|
|
281
|
+
if (!isObject(React) || typeof React.createElement !== "function") {
|
|
282
|
+
throw new TypeError("createMarkdownComponents: React.createElement is required");
|
|
283
|
+
}
|
|
284
|
+
const { createElement, useState, useCallback, useEffect, useMemo, useRef, createContext, useContext } = React;
|
|
285
|
+
|
|
286
|
+
// Context carries the per-message values the stable CodeBlock needs but that
|
|
287
|
+
// Streamdown's `pre` wrapper (which only adds `data-block`) cannot forward.
|
|
288
|
+
const CodeBlockContext = createContext(null);
|
|
289
|
+
const useCodeBlockContext = () => useContext(CodeBlockContext);
|
|
290
|
+
|
|
291
|
+
function MarkdownLink(props) {
|
|
292
|
+
const { href, children, className, node: _node, onOpenExternalUrl, onOpenFileLink, "data-native-file-href": originalHref, ...rest } = props;
|
|
293
|
+
const target = typeof originalHref === "string" ? originalHref : typeof href === "string" ? href : "";
|
|
294
|
+
const kind = routeLink(target);
|
|
295
|
+
const handleClick = useCallback(
|
|
296
|
+
(event) => {
|
|
297
|
+
if (event && typeof event.preventDefault === "function") event.preventDefault();
|
|
298
|
+
if (kind === "external" && typeof onOpenExternalUrl === "function") onOpenExternalUrl(target);
|
|
299
|
+
else if (kind === "file" && typeof onOpenFileLink === "function") {
|
|
300
|
+
onOpenFileLink({ href: target, label: extractText(children) });
|
|
301
|
+
}
|
|
302
|
+
},
|
|
303
|
+
[kind, target, onOpenExternalUrl, onOpenFileLink, children],
|
|
304
|
+
);
|
|
305
|
+
if (kind === "external" && typeof onOpenExternalUrl === "function") {
|
|
306
|
+
return createElement(
|
|
307
|
+
"button",
|
|
308
|
+
{ type: "button", className: `ks-md-link ${className || ""}`.trim(), title: target, onClick: handleClick },
|
|
309
|
+
children,
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
if (kind === "file" && typeof onOpenFileLink === "function") {
|
|
313
|
+
return createElement(
|
|
314
|
+
"button",
|
|
315
|
+
{ type: "button", className: `ks-md-link ks-md-file-link ${className || ""}`.trim(), title: target, onClick: handleClick },
|
|
316
|
+
children,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
return createElement(
|
|
320
|
+
"span",
|
|
321
|
+
{ className: `ks-md-link-muted ${className || ""}`.trim(), title: target || undefined },
|
|
322
|
+
children,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function MarkdownCopyButton(props) {
|
|
327
|
+
const { code, labels, onCopyCode } = props;
|
|
328
|
+
const [copied, setCopied] = useState(false);
|
|
329
|
+
const timerRef = useRef(null);
|
|
330
|
+
useEffect(
|
|
331
|
+
() => () => {
|
|
332
|
+
if (timerRef.current && typeof clearTimeout === "function") clearTimeout(timerRef.current);
|
|
333
|
+
},
|
|
334
|
+
[],
|
|
335
|
+
);
|
|
336
|
+
const handleClick = useCallback(async () => {
|
|
337
|
+
let handled = false;
|
|
338
|
+
if (typeof onCopyCode === "function") {
|
|
339
|
+
try { await onCopyCode(code); handled = true; } catch { handled = false; }
|
|
340
|
+
} else if (typeof navigator === "object" && navigator && navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
|
|
341
|
+
try { await navigator.clipboard.writeText(code); handled = true; } catch { handled = false; }
|
|
342
|
+
}
|
|
343
|
+
if (handled) {
|
|
344
|
+
setCopied(true);
|
|
345
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
346
|
+
timerRef.current = setTimeout(() => setCopied(false), 2000);
|
|
347
|
+
}
|
|
348
|
+
}, [code, onCopyCode]);
|
|
349
|
+
return createElement(
|
|
350
|
+
"button",
|
|
351
|
+
{
|
|
352
|
+
type: "button",
|
|
353
|
+
className: "ks-md-copy",
|
|
354
|
+
"aria-label": copied ? labels.copied : labels.copyCode,
|
|
355
|
+
"data-copied": copied ? "true" : "false",
|
|
356
|
+
onClick: handleClick,
|
|
357
|
+
},
|
|
358
|
+
copied ? labels.copied : labels.copyCode,
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Stable, independent code block (desktop code-block.tsx / code-viewer.tsx
|
|
363
|
+
// semantics). Raw text while streaming or before the highlighter resolves;
|
|
364
|
+
// shiki tokens when complete. The wrap toggle is local presentation state;
|
|
365
|
+
// copy goes through the host callback or navigator.clipboard.
|
|
366
|
+
function MarkdownCodeBlock(props) {
|
|
367
|
+
const { code, language, streaming, theme, wrapLongLines, labels, onCopyCode } = props;
|
|
368
|
+
const [highlight, setHighlight] = useState(null);
|
|
369
|
+
const tokens = !streaming && highlight?.code === code && highlight?.language === language ? highlight.tokens : null;
|
|
370
|
+
const [wrapped, setWrapped] = useState(Boolean(wrapLongLines));
|
|
371
|
+
useEffect(() => { setWrapped(Boolean(wrapLongLines)); }, [wrapLongLines]);
|
|
372
|
+
useEffect(() => {
|
|
373
|
+
if (streaming) { setHighlight(null); return; }
|
|
374
|
+
const langId = resolveLangId(language);
|
|
375
|
+
if (!langId) { setHighlight(null); return; }
|
|
376
|
+
let cancelled = false;
|
|
377
|
+
getHighlighter().then((h) => {
|
|
378
|
+
if (cancelled) return;
|
|
379
|
+
try {
|
|
380
|
+
const result = h.codeToTokens(code, {
|
|
381
|
+
lang: langId,
|
|
382
|
+
themes: { light: LIGHT_THEME, dark: DARK_THEME },
|
|
383
|
+
});
|
|
384
|
+
if (!cancelled) setHighlight({ code, language, tokens: result });
|
|
385
|
+
} catch {
|
|
386
|
+
if (!cancelled) setHighlight(null);
|
|
387
|
+
}
|
|
388
|
+
}).catch(() => { if (!cancelled) setHighlight(null); });
|
|
389
|
+
return () => { cancelled = true; };
|
|
390
|
+
}, [code, language, streaming]);
|
|
391
|
+
|
|
392
|
+
const toggleWrap = useCallback(() => setWrapped((v) => !v), []);
|
|
393
|
+
const langId = resolveLangId(language);
|
|
394
|
+
const displayLang = language || labels.languageLabel;
|
|
395
|
+
|
|
396
|
+
const bodyChildren = [];
|
|
397
|
+
if (tokens && tokens.tokens) {
|
|
398
|
+
tokens.tokens.forEach((line, lineIdx) => {
|
|
399
|
+
if (lineIdx > 0) bodyChildren.push("\n");
|
|
400
|
+
if (line.length === 0) return;
|
|
401
|
+
line.forEach((tok, tokIdx) => {
|
|
402
|
+
bodyChildren.push(
|
|
403
|
+
createElement("span", { key: `${lineIdx}-${tokIdx}`, className: "ks-md-token", style: tokenStyle(tok) }, tok.content),
|
|
404
|
+
);
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
} else {
|
|
408
|
+
bodyChildren.push(code);
|
|
409
|
+
}
|
|
410
|
+
const bodyStyle = {};
|
|
411
|
+
if (tokens && tokens.bg) applyDual(bodyStyle, "--sdm-bg", tokens.bg);
|
|
412
|
+
if (tokens && tokens.fg) applyDual(bodyStyle, "--sdm-fg", tokens.fg);
|
|
413
|
+
|
|
414
|
+
return createElement(
|
|
415
|
+
"div",
|
|
416
|
+
{
|
|
417
|
+
className: "ks-md-code",
|
|
418
|
+
"data-language": displayLang,
|
|
419
|
+
"data-streaming": streaming ? "true" : "false",
|
|
420
|
+
"data-highlighted": tokens ? "true" : "false",
|
|
421
|
+
},
|
|
422
|
+
createElement(
|
|
423
|
+
"div",
|
|
424
|
+
{ className: "ks-md-code-header" },
|
|
425
|
+
createElement("span", { className: "ks-md-code-lang" }, displayLang),
|
|
426
|
+
createElement(
|
|
427
|
+
"span",
|
|
428
|
+
{ className: "ks-md-code-actions" },
|
|
429
|
+
createElement(
|
|
430
|
+
"button",
|
|
431
|
+
{
|
|
432
|
+
type: "button",
|
|
433
|
+
className: "ks-md-wrap",
|
|
434
|
+
"aria-label": labels.wrapLines,
|
|
435
|
+
"aria-pressed": wrapped ? "true" : "false",
|
|
436
|
+
"data-wrapped": wrapped ? "true" : "false",
|
|
437
|
+
onClick: toggleWrap,
|
|
438
|
+
},
|
|
439
|
+
labels.wrapLines,
|
|
440
|
+
),
|
|
441
|
+
createElement(MarkdownCopyButton, { code, labels, onCopyCode }),
|
|
442
|
+
),
|
|
443
|
+
),
|
|
444
|
+
createElement(
|
|
445
|
+
"pre",
|
|
446
|
+
{ className: `ks-md-pre ${wrapped ? "ks-md-pre-wrap" : ""}`.trim(), style: bodyStyle },
|
|
447
|
+
createElement("code", null, bodyChildren),
|
|
448
|
+
),
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// `code` override (desktop message.tsx ~1535-1582): inline code renders a
|
|
453
|
+
// bare <code>; block code (detected via the `data-block` flag Streamdown's
|
|
454
|
+
// `pre` renderer adds) routes to the stable CodeBlock using the per-message
|
|
455
|
+
// context. This disables Streamdown's default code renderer (and its async
|
|
456
|
+
// highlighter), which is what avoids React #185.
|
|
457
|
+
function codeComponent(props) {
|
|
458
|
+
const { children, className, node: _node, ...rest } = props;
|
|
459
|
+
const ctx = useCodeBlockContext() || {};
|
|
460
|
+
const isBlock = "data-block" in rest;
|
|
461
|
+
if (!isBlock) {
|
|
462
|
+
return createElement(
|
|
463
|
+
"code",
|
|
464
|
+
{ className: `ks-md-inline-code ${className || ""}`.trim() },
|
|
465
|
+
children,
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
const codeText = extractText(children).replace(/\n+$/, "");
|
|
469
|
+
const language = resolveCodeLanguage(className);
|
|
470
|
+
return createElement(MarkdownCodeBlock, {
|
|
471
|
+
code: codeText,
|
|
472
|
+
language,
|
|
473
|
+
streaming: ctx.streaming === true,
|
|
474
|
+
wrapLongLines: ctx.wrapLongLines === true,
|
|
475
|
+
theme: ctx.theme,
|
|
476
|
+
labels: ctx.labels || DEFAULT_LABELS,
|
|
477
|
+
onCopyCode: ctx.onCopyCode,
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function buildComponents(opts) {
|
|
482
|
+
const { onOpenExternalUrl, onOpenFileLink, renderImage, tableSlots } = opts;
|
|
483
|
+
return {
|
|
484
|
+
img: ({ src, alt, title }) => typeof renderImage === "function"
|
|
485
|
+
? renderImage({ src, alt, title })
|
|
486
|
+
: createElement("span", { role: "img", "aria-label": alt || "Image", title: src }, alt || "Image"),
|
|
487
|
+
code: codeComponent,
|
|
488
|
+
a: (props) =>
|
|
489
|
+
createElement(MarkdownLink, { ...props, onOpenExternalUrl, onOpenFileLink }),
|
|
490
|
+
// Custom table renderer + parts (table-factory.mjs). Replaces Streamdown's
|
|
491
|
+
// default `table` wrapper and its built-in copy/download/fullscreen toolbar.
|
|
492
|
+
table: tableSlots.table,
|
|
493
|
+
thead: tableSlots.thead,
|
|
494
|
+
tbody: tableSlots.tbody,
|
|
495
|
+
tr: tableSlots.tr,
|
|
496
|
+
th: tableSlots.th,
|
|
497
|
+
td: tableSlots.td,
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function MarkdownResponse(props) {
|
|
502
|
+
const {
|
|
503
|
+
children,
|
|
504
|
+
streaming = false,
|
|
505
|
+
theme = "system",
|
|
506
|
+
scopeKey = "",
|
|
507
|
+
wrapLongLines = false,
|
|
508
|
+
showLineNumbers = false,
|
|
509
|
+
className,
|
|
510
|
+
labels,
|
|
511
|
+
onCopyCode,
|
|
512
|
+
onNotify,
|
|
513
|
+
onOpenExternalUrl,
|
|
514
|
+
onOpenFileLink,
|
|
515
|
+
renderImage,
|
|
516
|
+
} = props || {};
|
|
517
|
+
// Memoize so the table slots and code-block context stay referentially
|
|
518
|
+
// stable across renders when the host keeps `labels` stable.
|
|
519
|
+
const mergedLabels = useMemo(() => ({ ...DEFAULT_LABELS, ...(labels || {}) }), [labels]);
|
|
520
|
+
const markdown = typeof children === "string" ? children : extractText(children);
|
|
521
|
+
const [prefersDark, setPrefersDark] = useState(readPrefersDark);
|
|
522
|
+
useEffect(() => {
|
|
523
|
+
if (typeof window !== "object" || typeof window.matchMedia !== "function") return;
|
|
524
|
+
const query = window.matchMedia("(prefers-color-scheme: dark)");
|
|
525
|
+
const update = () => setPrefersDark(Boolean(query.matches));
|
|
526
|
+
update();
|
|
527
|
+
if (typeof query.addEventListener === "function") {
|
|
528
|
+
query.addEventListener("change", update);
|
|
529
|
+
return () => query.removeEventListener("change", update);
|
|
530
|
+
}
|
|
531
|
+
query.addListener(update);
|
|
532
|
+
return () => query.removeListener(update);
|
|
533
|
+
}, []);
|
|
534
|
+
const effective = resolveEffectiveTheme(theme, prefersDark);
|
|
535
|
+
const tableSlots = useMemo(
|
|
536
|
+
() => createMarkdownTableComponents(React, ReactDOM, { labels: mergedLabels, onNotify, theme: effective }),
|
|
537
|
+
[mergedLabels, onNotify, effective],
|
|
538
|
+
);
|
|
539
|
+
const components = useMemo(
|
|
540
|
+
() => buildComponents({ onOpenExternalUrl, onOpenFileLink, renderImage, tableSlots }),
|
|
541
|
+
[onOpenExternalUrl, onOpenFileLink, renderImage, tableSlots],
|
|
542
|
+
);
|
|
543
|
+
const codeBlockCtx = useMemo(
|
|
544
|
+
() => ({ streaming, wrapLongLines, theme: effective, labels: mergedLabels, onCopyCode }),
|
|
545
|
+
[streaming, wrapLongLines, effective, mergedLabels, onCopyCode],
|
|
546
|
+
);
|
|
547
|
+
const wrapperClass = ["ks-markdown", `ks-theme-${effective}`, className || ""]
|
|
548
|
+
.filter(Boolean)
|
|
549
|
+
.join(" ");
|
|
550
|
+
return createElement(
|
|
551
|
+
CodeBlockContext.Provider,
|
|
552
|
+
{ value: codeBlockCtx },
|
|
553
|
+
createElement(
|
|
554
|
+
"div",
|
|
555
|
+
{
|
|
556
|
+
className: wrapperClass,
|
|
557
|
+
"data-streaming": streaming ? "true" : "false",
|
|
558
|
+
"data-theme": effective,
|
|
559
|
+
},
|
|
560
|
+
createElement(
|
|
561
|
+
Streamdown,
|
|
562
|
+
{
|
|
563
|
+
key: `${effective}:${scopeKey}`,
|
|
564
|
+
className: "ks-markdown-body",
|
|
565
|
+
mode: streaming ? "streaming" : "static",
|
|
566
|
+
parseIncompleteMarkdown: streaming,
|
|
567
|
+
components,
|
|
568
|
+
plugins: { cjk: cjkPlugin },
|
|
569
|
+
controls: STREAMDOWN_CONTROLS,
|
|
570
|
+
linkSafety: LINK_SAFETY,
|
|
571
|
+
animated: false,
|
|
572
|
+
isAnimating: false,
|
|
573
|
+
lineNumbers: showLineNumbers,
|
|
574
|
+
rehypePlugins: [defaultRehypePlugins.raw, defaultRehypePlugins.sanitize, rewriteBareFileLinkRehypePlugin, defaultRehypePlugins.harden],
|
|
575
|
+
remarkPlugins: Object.values(defaultRemarkPlugins),
|
|
576
|
+
},
|
|
577
|
+
markdown,
|
|
578
|
+
),
|
|
579
|
+
),
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
return {
|
|
584
|
+
MarkdownResponse,
|
|
585
|
+
Markdown: MarkdownResponse,
|
|
586
|
+
cjkPlugin,
|
|
587
|
+
registeredLanguages: Object.keys(REGISTERED_LANGS),
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Dev-time guard: confirms the host's resolved `react` matches the React the
|
|
593
|
+
* caller passed in, so Streamdown's internal `require("react")` and the
|
|
594
|
+
* factory's injected React are the same instance (no second React copy).
|
|
595
|
+
* Throws on mismatch. Pass the host's `require` (or `createRequire`).
|
|
596
|
+
*
|
|
597
|
+
* @param {object} React The React instance passed to createMarkdownComponents.
|
|
598
|
+
* @param {(id: string) => any} [requireFn] Host require; omit to skip.
|
|
599
|
+
*/
|
|
600
|
+
export function verifyReactInjection(React, requireFn) {
|
|
601
|
+
if (typeof requireFn !== "function") return;
|
|
602
|
+
let resolved;
|
|
603
|
+
try { resolved = requireFn("react"); } catch { return; }
|
|
604
|
+
if (!resolved) return;
|
|
605
|
+
const sameCreateElement = React.createElement === resolved.createElement;
|
|
606
|
+
const sameHook = React.useState === resolved.useState;
|
|
607
|
+
if (!sameCreateElement || !sameHook) {
|
|
608
|
+
throw new Error(
|
|
609
|
+
"createMarkdownComponents: the injected React does not match the host's require('react'). " +
|
|
610
|
+
"A second React copy would break Streamdown's JSX runtime. Ensure the host resolves a single react instance.",
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
}
|