@zhin.js/client 2.1.9 → 2.1.11
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/README.md +6 -0
- package/client/console-events.ts +37 -0
- package/client/index.ts +44 -2
- package/client/mediaSrc.test.ts +15 -0
- package/client/mediaSrc.ts +59 -12
- package/client/message-content/MarkdownContent.tsx +128 -0
- package/client/message-content/markdown.test.ts +48 -0
- package/client/message-content/markdown.ts +247 -0
- package/client/persistence/idb-store.test.ts +11 -0
- package/client/persistence/idb-store.ts +20 -4
- package/client/websocket/index.ts +6 -1
- package/client/websocket/manager.test.ts +145 -12
- package/client/websocket/manager.ts +189 -17
- package/client/websocket/types.ts +5 -0
- package/client/workroom/WorkroomRunsPage.tsx +248 -0
- package/client/{orchestration/registerOrchestrationConsole.test.ts → workroom/registerWorkroomConsole.test.ts} +8 -8
- package/client/workroom/registerWorkroomConsole.ts +17 -0
- package/dist/console-events.d.ts +7 -0
- package/dist/console-events.d.ts.map +1 -0
- package/dist/console-events.js +26 -0
- package/dist/console-events.js.map +1 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -3
- package/dist/index.js.map +1 -1
- package/dist/mediaSrc.d.ts.map +1 -1
- package/dist/mediaSrc.js +66 -12
- package/dist/mediaSrc.js.map +1 -1
- package/dist/message-content/MarkdownContent.d.ts +11 -0
- package/dist/message-content/MarkdownContent.d.ts.map +1 -0
- package/dist/message-content/MarkdownContent.js +68 -0
- package/dist/message-content/MarkdownContent.js.map +1 -0
- package/dist/message-content/markdown.d.ts +47 -0
- package/dist/message-content/markdown.d.ts.map +1 -0
- package/dist/message-content/markdown.js +219 -0
- package/dist/message-content/markdown.js.map +1 -0
- package/dist/persistence/idb-store.d.ts +3 -0
- package/dist/persistence/idb-store.d.ts.map +1 -1
- package/dist/persistence/idb-store.js +13 -3
- package/dist/persistence/idb-store.js.map +1 -1
- package/dist/websocket/index.d.ts +1 -1
- package/dist/websocket/index.d.ts.map +1 -1
- package/dist/websocket/index.js +1 -1
- package/dist/websocket/index.js.map +1 -1
- package/dist/websocket/manager.d.ts +19 -0
- package/dist/websocket/manager.d.ts.map +1 -1
- package/dist/websocket/manager.js +176 -18
- package/dist/websocket/manager.js.map +1 -1
- package/dist/websocket/types.d.ts +4 -0
- package/dist/websocket/types.d.ts.map +1 -1
- package/dist/websocket/types.js.map +1 -1
- package/dist/workroom/WorkroomRunsPage.d.ts +2 -0
- package/dist/workroom/WorkroomRunsPage.d.ts.map +1 -0
- package/dist/workroom/WorkroomRunsPage.js +64 -0
- package/dist/workroom/WorkroomRunsPage.js.map +1 -0
- package/dist/workroom/registerWorkroomConsole.d.ts +4 -0
- package/dist/workroom/registerWorkroomConsole.d.ts.map +1 -0
- package/dist/workroom/registerWorkroomConsole.js +16 -0
- package/dist/workroom/registerWorkroomConsole.js.map +1 -0
- package/package.json +3 -3
- package/client/orchestration/OrchestrationRunsPage.tsx +0 -217
- package/client/orchestration/registerOrchestrationConsole.ts +0 -17
- package/dist/orchestration/OrchestrationRunsPage.d.ts +0 -2
- package/dist/orchestration/OrchestrationRunsPage.d.ts.map +0 -1
- package/dist/orchestration/OrchestrationRunsPage.js +0 -65
- package/dist/orchestration/OrchestrationRunsPage.js.map +0 -1
- package/dist/orchestration/registerOrchestrationConsole.d.ts +0 -4
- package/dist/orchestration/registerOrchestrationConsole.d.ts.map +0 -1
- package/dist/orchestration/registerOrchestrationConsole.js +0 -16
- package/dist/orchestration/registerOrchestrationConsole.js.map +0 -1
package/README.md
CHANGED
|
@@ -27,6 +27,10 @@ Peer:`react >= 18`(`createPluginRegisterHostApi` 需要 React 引用)。
|
|
|
27
27
|
| `normalizeConsolePushType` / `normalizeConsolePushMessage` | 在 SDK 边界兼容旧 `endpoint:*` 推送并输出规范事件与 payload |
|
|
28
28
|
| `parseConsoleInboxEvent` | 一次完成 Inbox 推送名称、身份别名和 message/request/notice 分类 |
|
|
29
29
|
| `ConsoleEndpointSummary` / `EndpointManagementCapability` | Host 与 Remote Console 共享的 Endpoint wire 类型 |
|
|
30
|
+
| `fetchConsoleEventHistory` | 按 `(runtimeId, eventId)` 拉取有界事件历史 |
|
|
31
|
+
| `WebSocketManager.onConsoleEvent` | 订阅带 `live/history` 投递来源的强类型事件 |
|
|
32
|
+
| `WebSocketManager.onConsoleEventRecoveryGap` | 观察不可续接游标并触发领域全量重同步 |
|
|
33
|
+
| `ConsoleInboxNoticesQuery` / `ConsoleInboxNoticesResult` | 以 `unreadOnly` 从持久 Inbox 重建未读通知 |
|
|
30
34
|
|
|
31
35
|
类型与 Entry 契约来自 `@zhin.js/contract`。
|
|
32
36
|
|
|
@@ -97,6 +101,8 @@ const res = await apiFetch("/api/console/request", {
|
|
|
97
101
|
|
|
98
102
|
辅助:`resolveApiUrl`、`resolveWebSocketUrl`(SSE `/api/events` 等)。
|
|
99
103
|
|
|
104
|
+
Client 在连接 `/api/events` 前先补拉 `/api/events/history`,再携带最新游标进入 SSE;Host 会原子重放 HTTP 与订阅之间产生的事件。游标只在 Inbox 持久化成功后推进,重复的 history/live 投递按 `(runtimeId, eventId)` 幂等写入 IndexedDB。`gap` 不会被吞掉,页面可订阅恢复缺口并改走领域 HTTP 全量投影。
|
|
105
|
+
|
|
100
106
|
## `createPluginRegisterHostApi`
|
|
101
107
|
|
|
102
108
|
将壳层已有的 React 与路由/工具注册函数适配为契约中的 `PluginRegisterHostApi`(含 `addPage` 别名 → `addRoute`):
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ConsoleEventHistoryPage,
|
|
3
|
+
ConsoleEventHistoryQuery,
|
|
4
|
+
} from '@zhin.js/console-protocol';
|
|
5
|
+
import { getStoredToken, resolveApiUrl } from './websocket/remote-settings.js';
|
|
6
|
+
|
|
7
|
+
export interface FetchConsoleEventHistoryOptions {
|
|
8
|
+
readonly signal?: AbortSignal;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Read a typed page from the Host's bounded Console event journal. */
|
|
12
|
+
export async function fetchConsoleEventHistory(
|
|
13
|
+
query: ConsoleEventHistoryQuery = {},
|
|
14
|
+
options: FetchConsoleEventHistoryOptions = {},
|
|
15
|
+
): Promise<ConsoleEventHistoryPage> {
|
|
16
|
+
const params = new URLSearchParams();
|
|
17
|
+
if (query.runtimeId) params.set('runtimeId', query.runtimeId);
|
|
18
|
+
if (query.after !== undefined) params.set('after', String(query.after));
|
|
19
|
+
if (query.limit !== undefined) params.set('limit', String(query.limit));
|
|
20
|
+
const token = getStoredToken();
|
|
21
|
+
const headers: Record<string, string> = { Accept: 'application/json' };
|
|
22
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
23
|
+
const suffix = params.size ? `?${params}` : '';
|
|
24
|
+
const response = await fetch(resolveApiUrl(`/api/events/history${suffix}`), {
|
|
25
|
+
headers,
|
|
26
|
+
signal: options.signal,
|
|
27
|
+
});
|
|
28
|
+
const body = await response.json() as {
|
|
29
|
+
success?: boolean;
|
|
30
|
+
data?: ConsoleEventHistoryPage;
|
|
31
|
+
error?: string;
|
|
32
|
+
};
|
|
33
|
+
if (!response.ok || body.success !== true || !body.data) {
|
|
34
|
+
throw new Error(body.error ?? `Console event history failed: HTTP ${response.status}`);
|
|
35
|
+
}
|
|
36
|
+
return body.data;
|
|
37
|
+
}
|
package/client/index.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
export * from "./types";
|
|
3
3
|
export {
|
|
4
4
|
ENDPOINT_RPC,
|
|
5
|
+
CONSOLE_EVENT_RECOVERY_GAP_EVENT,
|
|
5
6
|
ENDPOINT_MANAGEMENT_CAPABILITIES,
|
|
6
7
|
INBOX_RPC,
|
|
7
8
|
SIDE_EVENT_PUSH,
|
|
@@ -11,14 +12,55 @@ export {
|
|
|
11
12
|
parseConsoleInboxEvent,
|
|
12
13
|
type ConsoleInboxEvent,
|
|
13
14
|
type ConsoleInboxEventKind,
|
|
15
|
+
type ConsoleInboxNoticeRow,
|
|
16
|
+
type ConsoleInboxNoticesQuery,
|
|
17
|
+
type ConsoleInboxNoticesResult,
|
|
14
18
|
type ConsoleEndpointPhase,
|
|
15
19
|
type ConsoleEndpointSummary,
|
|
20
|
+
type ConsoleEventActor,
|
|
21
|
+
type ConsoleEventChannel,
|
|
16
22
|
type EndpointManagementCapability,
|
|
23
|
+
parseConsoleSseFrame,
|
|
24
|
+
type ConsoleEventDelivery,
|
|
25
|
+
type ConsoleEventData,
|
|
26
|
+
type ConsoleEndpointEventData,
|
|
27
|
+
type ConsoleEventEnvelope,
|
|
28
|
+
type ConsoleEventHistoryPage,
|
|
29
|
+
type ConsoleEventHistoryQuery,
|
|
30
|
+
type ConsoleEventPayloadMap,
|
|
31
|
+
type ConsoleMessageEventData,
|
|
32
|
+
type ConsoleNoticeEventData,
|
|
33
|
+
type ConsoleRequestEventData,
|
|
34
|
+
type KnownConsoleEventType,
|
|
35
|
+
type KnownConsoleEventEnvelope,
|
|
36
|
+
type ParsedConsoleSseFrame,
|
|
17
37
|
} from "@zhin.js/console-protocol";
|
|
18
38
|
|
|
39
|
+
export {
|
|
40
|
+
fetchConsoleEventHistory,
|
|
41
|
+
type FetchConsoleEventHistoryOptions,
|
|
42
|
+
} from './console-events.js';
|
|
43
|
+
|
|
19
44
|
// Media URL resolution
|
|
20
45
|
export { resolveMediaSrc, pickMediaRawUrl, type MediaKind } from "./mediaSrc";
|
|
21
46
|
|
|
47
|
+
// Safe rich message rendering (Markdown + code blocks; no raw HTML execution)
|
|
48
|
+
export {
|
|
49
|
+
MarkdownContent,
|
|
50
|
+
CodeBlock,
|
|
51
|
+
type MarkdownContentProps,
|
|
52
|
+
} from "./message-content/MarkdownContent.js";
|
|
53
|
+
export {
|
|
54
|
+
parseMarkdown,
|
|
55
|
+
parseMarkdownInline,
|
|
56
|
+
highlightCodeLine,
|
|
57
|
+
isSafeMarkdownHref,
|
|
58
|
+
type MarkdownBlock,
|
|
59
|
+
type MarkdownInline,
|
|
60
|
+
type CodeToken,
|
|
61
|
+
type CodeTokenKind,
|
|
62
|
+
} from "./message-content/markdown.js";
|
|
63
|
+
|
|
22
64
|
// Segment IM visibility (inbox vs agent panel)
|
|
23
65
|
export { segmentsForImDelivery, segmentsForAgentPanel } from "./segments.js";
|
|
24
66
|
|
|
@@ -67,8 +109,8 @@ export {
|
|
|
67
109
|
type LoadConsoleEntriesOptions,
|
|
68
110
|
} from "./bootstrap/loadConsoleEntries.js";
|
|
69
111
|
|
|
70
|
-
export {
|
|
71
|
-
export { default as
|
|
112
|
+
export { registerWorkroomConsole } from "./workroom/registerWorkroomConsole.js";
|
|
113
|
+
export { default as WorkroomRunsPage } from "./workroom/WorkroomRunsPage.js";
|
|
72
114
|
|
|
73
115
|
export {
|
|
74
116
|
startAgentSession,
|
package/client/mediaSrc.test.ts
CHANGED
|
@@ -31,4 +31,19 @@ describe('pickMediaRawUrl', () => {
|
|
|
31
31
|
expect(pickMediaRawUrl(undefined)).toBeUndefined();
|
|
32
32
|
expect(pickMediaRawUrl({})).toBeUndefined();
|
|
33
33
|
});
|
|
34
|
+
|
|
35
|
+
it('rejects executable data URLs, credentials, and malformed base64', () => {
|
|
36
|
+
expect(resolveMediaSrc('data:text/html;base64,PHNjcmlwdD4=', 'image')).toBeUndefined();
|
|
37
|
+
expect(resolveMediaSrc('data:image/svg+xml;base64,PHN2Zz4=', 'image')).toBeUndefined();
|
|
38
|
+
expect(resolveMediaSrc('https://user:secret@example.com/a.png', 'image')).toBeUndefined();
|
|
39
|
+
expect(resolveMediaSrc('base64://not:base64', 'image')).toBeUndefined();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('accepts only data MIME types matching the rendered media kind', () => {
|
|
43
|
+
expect(resolveMediaSrc('data:image/png;base64,aGVsbG8=', 'image')).toBe(
|
|
44
|
+
'data:image/png;base64,aGVsbG8=',
|
|
45
|
+
);
|
|
46
|
+
expect(resolveMediaSrc('data:image/png;base64,aGVsbG8=', 'video')).toBeUndefined();
|
|
47
|
+
expect(resolveMediaSrc('javascript:alert(1)', 'image')).toBeUndefined();
|
|
48
|
+
});
|
|
34
49
|
});
|
package/client/mediaSrc.ts
CHANGED
|
@@ -3,10 +3,54 @@ export type MediaKind = 'image' | 'video' | 'audio'
|
|
|
3
3
|
|
|
4
4
|
const BASE64_PROTO = 'base64://'
|
|
5
5
|
|
|
6
|
-
const
|
|
6
|
+
const SAFE_DATA_MIME: Readonly<Record<MediaKind, ReadonlySet<string>>> = Object.freeze({
|
|
7
|
+
image: new Set(['image/avif', 'image/gif', 'image/jpeg', 'image/png', 'image/webp']),
|
|
8
|
+
video: new Set(['video/mp4', 'video/ogg', 'video/webm']),
|
|
9
|
+
audio: new Set(['audio/mpeg', 'audio/mp4', 'audio/ogg', 'audio/wav', 'audio/webm']),
|
|
10
|
+
})
|
|
7
11
|
|
|
8
|
-
function
|
|
9
|
-
|
|
12
|
+
function resolveSafeRemoteUrl(value: string): string | undefined {
|
|
13
|
+
try {
|
|
14
|
+
const parsed = new URL(value)
|
|
15
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:' && parsed.protocol !== 'blob:') {
|
|
16
|
+
return undefined
|
|
17
|
+
}
|
|
18
|
+
if (parsed.username || parsed.password) return undefined
|
|
19
|
+
return parsed.href
|
|
20
|
+
} catch {
|
|
21
|
+
return undefined
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function resolveSafeDataUrl(value: string, kind: MediaKind): string | undefined {
|
|
26
|
+
if (!value.startsWith('data:')) return undefined
|
|
27
|
+
const separator = value.indexOf(',')
|
|
28
|
+
if (separator < 0) return undefined
|
|
29
|
+
const header = value.slice('data:'.length, separator).toLowerCase()
|
|
30
|
+
if (!header.endsWith(';base64')) return undefined
|
|
31
|
+
const mime = header.slice(0, -';base64'.length)
|
|
32
|
+
if (!SAFE_DATA_MIME[kind].has(mime)) return undefined
|
|
33
|
+
const payload = compactBase64(value.slice(separator + 1))
|
|
34
|
+
return payload ? `data:${mime};base64,${payload}` : undefined
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function compactBase64(value: string): string | undefined {
|
|
38
|
+
let compact = ''
|
|
39
|
+
for (const character of value) {
|
|
40
|
+
if (character === ' ' || character === '\n' || character === '\r' || character === '\t') continue
|
|
41
|
+
const code = character.charCodeAt(0)
|
|
42
|
+
const allowed = (code >= 48 && code <= 57)
|
|
43
|
+
|| (code >= 65 && code <= 90)
|
|
44
|
+
|| (code >= 97 && code <= 122)
|
|
45
|
+
|| character === '+' || character === '/' || character === '='
|
|
46
|
+
if (!allowed) return undefined
|
|
47
|
+
compact += character
|
|
48
|
+
}
|
|
49
|
+
const padding = compact.indexOf('=')
|
|
50
|
+
if (padding >= 0 && (compact.length - padding > 2 || compact.slice(padding).replace(/=/g, '') !== '')) {
|
|
51
|
+
return undefined
|
|
52
|
+
}
|
|
53
|
+
return compact || undefined
|
|
10
54
|
}
|
|
11
55
|
|
|
12
56
|
/**
|
|
@@ -27,26 +71,29 @@ export function resolveMediaSrc(
|
|
|
27
71
|
const s = raw.trim()
|
|
28
72
|
if (!s) return undefined
|
|
29
73
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
74
|
+
const dataUrl = resolveSafeDataUrl(s, kind)
|
|
75
|
+
if (dataUrl) return dataUrl
|
|
76
|
+
|
|
77
|
+
const remoteUrl = resolveSafeRemoteUrl(s)
|
|
78
|
+
if (remoteUrl) return remoteUrl
|
|
33
79
|
|
|
34
80
|
if (!s.startsWith(BASE64_PROTO)) {
|
|
35
|
-
return
|
|
81
|
+
return undefined
|
|
36
82
|
}
|
|
37
83
|
|
|
38
|
-
const payload = s.slice(BASE64_PROTO.length).
|
|
84
|
+
const payload = s.slice(BASE64_PROTO.length).trimStart()
|
|
39
85
|
if (!payload) return undefined
|
|
40
86
|
|
|
41
87
|
// 已是 type/subtype;base64, 片段(不含 data: 前缀)
|
|
42
|
-
|
|
43
|
-
|
|
88
|
+
const declared = resolveSafeDataUrl(`data:${payload}`, kind)
|
|
89
|
+
if (declared) {
|
|
90
|
+
return declared
|
|
44
91
|
}
|
|
45
92
|
|
|
46
93
|
const defaultMime =
|
|
47
94
|
kind === 'image' ? 'image/png' : kind === 'video' ? 'video/mp4' : 'audio/mpeg'
|
|
48
|
-
const b64 = payload
|
|
49
|
-
return `data:${defaultMime};base64,${b64}`
|
|
95
|
+
const b64 = compactBase64(payload)
|
|
96
|
+
return b64 ? `data:${defaultMime};base64,${b64}` : undefined
|
|
50
97
|
}
|
|
51
98
|
|
|
52
99
|
/** 从 segment.data 取常见媒体字段(优先 canonical MediaRef) */
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { Fragment, useMemo, useState, type ReactNode } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
highlightCodeLine,
|
|
4
|
+
parseMarkdown,
|
|
5
|
+
type MarkdownInline,
|
|
6
|
+
} from './markdown.js';
|
|
7
|
+
|
|
8
|
+
export interface MarkdownContentProps {
|
|
9
|
+
readonly text: string;
|
|
10
|
+
readonly className?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function InlineContent({ content }: { readonly content: readonly MarkdownInline[] }) {
|
|
14
|
+
const nodes: ReactNode[] = [];
|
|
15
|
+
content.forEach((token, index) => {
|
|
16
|
+
const key = `${token.type}-${index}`;
|
|
17
|
+
const value = token.value.split('\n').map((line, lineIndex, lines) => (
|
|
18
|
+
<Fragment key={`${key}-line-${lineIndex}`}>
|
|
19
|
+
{line}
|
|
20
|
+
{lineIndex < lines.length - 1 ? <br /> : null}
|
|
21
|
+
</Fragment>
|
|
22
|
+
));
|
|
23
|
+
if (token.type === 'strong') nodes.push(<strong key={key}>{value}</strong>);
|
|
24
|
+
else if (token.type === 'emphasis') nodes.push(<em key={key}>{value}</em>);
|
|
25
|
+
else if (token.type === 'strike') nodes.push(<del key={key}>{value}</del>);
|
|
26
|
+
else if (token.type === 'code') nodes.push(<code key={key} className="im-inline-code">{token.value}</code>);
|
|
27
|
+
else if (token.type === 'link') nodes.push(
|
|
28
|
+
<a key={key} href={token.href} target={token.href.startsWith('#') ? undefined : '_blank'} rel="noreferrer" className="im-message-link">{value}</a>,
|
|
29
|
+
);
|
|
30
|
+
else nodes.push(<Fragment key={key}>{value}</Fragment>);
|
|
31
|
+
});
|
|
32
|
+
return <>{nodes}</>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function CodeBlock({ code, language = '', closed = true }: { readonly code: string; readonly language?: string; readonly closed?: boolean }) {
|
|
36
|
+
const [copied, setCopied] = useState(false);
|
|
37
|
+
const [wrapped, setWrapped] = useState(false);
|
|
38
|
+
const [expanded, setExpanded] = useState(false);
|
|
39
|
+
const lines = useMemo(() => code.split('\n'), [code]);
|
|
40
|
+
const isLong = lines.length > 18 || code.length > 1800;
|
|
41
|
+
const visibleLines = isLong && !expanded ? lines.slice(0, 18) : lines;
|
|
42
|
+
|
|
43
|
+
const copy = async () => {
|
|
44
|
+
try {
|
|
45
|
+
await navigator.clipboard.writeText(code);
|
|
46
|
+
setCopied(true);
|
|
47
|
+
window.setTimeout(() => setCopied(false), 1600);
|
|
48
|
+
} catch {
|
|
49
|
+
setCopied(false);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<figure className="zhin-code-block">
|
|
55
|
+
<figcaption className="zhin-code-toolbar">
|
|
56
|
+
<span className="zhin-code-language">{language || 'plain text'}</span>
|
|
57
|
+
{!closed ? <span className="zhin-code-streaming">生成中</span> : null}
|
|
58
|
+
<span className="zhin-code-actions">
|
|
59
|
+
<button type="button" onClick={() => setWrapped((value) => !value)} aria-pressed={wrapped}>{wrapped ? '不折行' : '折行'}</button>
|
|
60
|
+
<button type="button" onClick={() => void copy()}>{copied ? '已复制' : '复制'}</button>
|
|
61
|
+
</span>
|
|
62
|
+
</figcaption>
|
|
63
|
+
<pre className={wrapped ? 'zhin-code-pre zhin-code-pre--wrap' : 'zhin-code-pre'}>
|
|
64
|
+
<code>
|
|
65
|
+
{visibleLines.map((line, lineIndex) => (
|
|
66
|
+
<span className="zhin-code-line" key={`${lineIndex}-${line}`}>
|
|
67
|
+
<span className="zhin-code-line-number" aria-hidden="true">{lineIndex + 1}</span>
|
|
68
|
+
<span className="zhin-code-line-content">
|
|
69
|
+
{highlightCodeLine(line, language).map((token, tokenIndex) => (
|
|
70
|
+
<span key={`${token.kind}-${tokenIndex}`} className={`zhin-code-token zhin-code-token--${token.kind}`}>{token.value}</span>
|
|
71
|
+
))}
|
|
72
|
+
{'\n'}
|
|
73
|
+
</span>
|
|
74
|
+
</span>
|
|
75
|
+
))}
|
|
76
|
+
</code>
|
|
77
|
+
</pre>
|
|
78
|
+
{isLong ? (
|
|
79
|
+
<button type="button" className="zhin-code-expand" onClick={() => setExpanded((value) => !value)}>
|
|
80
|
+
{expanded ? '收起代码' : `展开其余 ${lines.length - visibleLines.length} 行`}
|
|
81
|
+
</button>
|
|
82
|
+
) : null}
|
|
83
|
+
</figure>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function MarkdownContent({ text, className }: MarkdownContentProps) {
|
|
88
|
+
const blocks = useMemo(() => parseMarkdown(text), [text]);
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<div className={['im-markdown-body', 'zhin-markdown', className].filter(Boolean).join(' ')}>
|
|
92
|
+
{blocks.map((block, index) => {
|
|
93
|
+
if (block.type === 'heading') {
|
|
94
|
+
const Heading = `h${block.level}` as keyof React.JSX.IntrinsicElements;
|
|
95
|
+
return <Heading key={index} className={`im-markdown-heading im-markdown-heading-${block.level}`}><InlineContent content={block.content} /></Heading>;
|
|
96
|
+
}
|
|
97
|
+
if (block.type === 'paragraph') return <p key={index} className="im-markdown-paragraph"><InlineContent content={block.content} /></p>;
|
|
98
|
+
if (block.type === 'quote') return <blockquote key={index} className="im-markdown-quote"><InlineContent content={block.content} /></blockquote>;
|
|
99
|
+
if (block.type === 'rule') return <hr key={index} className="zhin-markdown-rule" />;
|
|
100
|
+
if (block.type === 'code') return <CodeBlock key={index} code={block.value} language={block.language} closed={block.closed} />;
|
|
101
|
+
if (block.type === 'list') {
|
|
102
|
+
const List = block.ordered ? 'ol' : 'ul';
|
|
103
|
+
return (
|
|
104
|
+
<List key={index} className={`im-markdown-list${block.ordered ? ' im-markdown-list-ordered' : ''}`}>
|
|
105
|
+
{block.items.map((item, itemIndex) => (
|
|
106
|
+
<li key={itemIndex} className={item.checked === undefined ? undefined : 'zhin-markdown-task'}>
|
|
107
|
+
{item.checked === undefined ? null : <input type="checkbox" checked={item.checked} readOnly aria-label={item.checked ? '已完成' : '未完成'} />}
|
|
108
|
+
<InlineContent content={item.content} />
|
|
109
|
+
</li>
|
|
110
|
+
))}
|
|
111
|
+
</List>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
if (block.type === 'table') {
|
|
115
|
+
return (
|
|
116
|
+
<div className="zhin-markdown-table-wrap" key={index}>
|
|
117
|
+
<table className="zhin-markdown-table">
|
|
118
|
+
<thead><tr>{block.header.map((cell, cellIndex) => <th key={cellIndex}><InlineContent content={cell} /></th>)}</tr></thead>
|
|
119
|
+
<tbody>{block.rows.map((row, rowIndex) => <tr key={rowIndex}>{row.map((cell, cellIndex) => <td key={cellIndex}><InlineContent content={cell} /></td>)}</tr>)}</tbody>
|
|
120
|
+
</table>
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
})}
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { highlightCodeLine, isSafeMarkdownHref, parseMarkdown, parseMarkdownInline } from './markdown.js';
|
|
2
|
+
|
|
3
|
+
describe('message markdown', () => {
|
|
4
|
+
it('parses rich blocks including tables and tasks', () => {
|
|
5
|
+
const blocks = parseMarkdown(`# Result
|
|
6
|
+
|
|
7
|
+
- [x] inspect runtime
|
|
8
|
+
- [ ] publish patch
|
|
9
|
+
|
|
10
|
+
| field | value |
|
|
11
|
+
| --- | --- |
|
|
12
|
+
| status | **ready** |`);
|
|
13
|
+
|
|
14
|
+
expect(blocks.map((block) => block.type)).toEqual(['heading', 'list', 'table']);
|
|
15
|
+
expect(blocks[1]).toMatchObject({ type: 'list', items: [{ checked: true }, { checked: false }] });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('keeps incomplete streaming fences renderable', () => {
|
|
19
|
+
expect(parseMarkdown('```ts\nconst ready = true')).toEqual([
|
|
20
|
+
{ type: 'code', language: 'ts', value: 'const ready = true', closed: false },
|
|
21
|
+
]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('rejects unsafe markdown links without emitting active links', () => {
|
|
25
|
+
expect(isSafeMarkdownHref('javascript:alert(1)')).toBe(false);
|
|
26
|
+
expect(isSafeMarkdownHref('https://zhin.dev/docs')).toBe(true);
|
|
27
|
+
expect(parseMarkdownInline('[run](javascript:alert(1))')).toEqual([
|
|
28
|
+
{ type: 'text', value: '[run](javascript:alert(1))' },
|
|
29
|
+
]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('treats repeated unmatched link openers as plain text', () => {
|
|
33
|
+
const value = '['.repeat(20_000);
|
|
34
|
+
expect(parseMarkdownInline(value)).toEqual([{ type: 'text', value }]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('tokenizes common code without producing html', () => {
|
|
38
|
+
expect(highlightCodeLine('const answer = "<safe>" // note', 'ts')).toEqual([
|
|
39
|
+
{ kind: 'keyword', value: 'const' },
|
|
40
|
+
{ kind: 'plain', value: ' ' },
|
|
41
|
+
{ kind: 'plain', value: 'answer' },
|
|
42
|
+
{ kind: 'plain', value: ' = ' },
|
|
43
|
+
{ kind: 'string', value: '"<safe>"' },
|
|
44
|
+
{ kind: 'plain', value: ' ' },
|
|
45
|
+
{ kind: 'comment', value: '// note' },
|
|
46
|
+
]);
|
|
47
|
+
});
|
|
48
|
+
});
|