ai-chat-ui-kit 0.1.0
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/.eslintrc.cjs +74 -0
- package/.github/actions/screenshot/action.yml +35 -0
- package/.github/workflows/pages.yml +46 -0
- package/README.md +285 -0
- package/docs/README.md +176 -0
- package/docs/api/components.md +344 -0
- package/docs/api/core.md +349 -0
- package/docs/chat-style-1-minimal.html +78 -0
- package/docs/chat-style-2-neon.html +74 -0
- package/docs/chat-style-3-glass.html +73 -0
- package/docs/chat-style-4-terminal.html +84 -0
- package/docs/chat-style-5-gradient.html +69 -0
- package/docs/chat-style-6-corporate.html +116 -0
- package/docs/examples/basic-chat.md +291 -0
- package/docs/examples/custom-plugins.md +431 -0
- package/docs/examples/multi-model.md +466 -0
- package/docs/guide/api-adapters.md +431 -0
- package/docs/guide/getting-started.md +244 -0
- package/docs/guide/headless-mode.md +508 -0
- package/docs/guide/plugins.md +416 -0
- package/docs/guide/themes.md +327 -0
- package/docs/index.html +256 -0
- package/docs/theme-preview-1-minimal.html +74 -0
- package/docs/theme-preview-2-neon.html +73 -0
- package/docs/theme-preview-3-glass.html +77 -0
- package/docs/theme-preview-4-terminal.html +86 -0
- package/docs/theme-preview-5-gradient.html +79 -0
- package/docs/theme-preview-6-corporate.html +71 -0
- package/examples/index.html +414 -0
- package/examples/react-app/App.tsx +131 -0
- package/examples/react-app/index.html +12 -0
- package/examples/react-app/main.tsx +15 -0
- package/examples/react-app/package.json +24 -0
- package/examples/vue-app/index.html +12 -0
- package/examples/vue-app/package.json +22 -0
- package/examples/vue-app/src/App.vue +145 -0
- package/examples/vue-app/src/main.ts +9 -0
- package/package.json +44 -0
- package/packages/components/package.json +25 -0
- package/packages/components/src/chat/chat.css +80 -0
- package/packages/components/src/chat/chat.ts +236 -0
- package/packages/components/src/index.ts +36 -0
- package/packages/components/src/input/input.css +52 -0
- package/packages/components/src/input/input.ts +116 -0
- package/packages/components/src/markdown/markdown.css +118 -0
- package/packages/components/src/markdown/markdown.ts +229 -0
- package/packages/components/src/message/message.css +56 -0
- package/packages/components/src/message/message.ts +72 -0
- package/packages/components/src/styles/global.css +43 -0
- package/packages/components/src/tool-call/tool-call.css +98 -0
- package/packages/components/src/tool-call/tool-call.ts +171 -0
- package/packages/components/src/types.ts +55 -0
- package/packages/components/src/utils/helpers.ts +128 -0
- package/packages/components/tsconfig.json +25 -0
- package/packages/components/tsup.config.ts +18 -0
- package/packages/core/package.json +47 -0
- package/packages/core/pnpm-lock.yaml +2032 -0
- package/packages/core/pnpm-workspace.yaml +2 -0
- package/packages/core/src/api/adapters.ts +717 -0
- package/packages/core/src/api/base.ts +210 -0
- package/packages/core/src/api/index.ts +54 -0
- package/packages/core/src/index.ts +93 -0
- package/packages/core/src/parser/latex.ts +274 -0
- package/packages/core/src/parser/markdown.test.ts +58 -0
- package/packages/core/src/parser/markdown.ts +206 -0
- package/packages/core/src/parser/mermaid.ts +276 -0
- package/packages/core/src/plugins/PluginManager.ts +232 -0
- package/packages/core/src/plugins/builtin.ts +406 -0
- package/packages/core/src/store/ChatStore.ts +163 -0
- package/packages/core/src/store/ModelConfigStore.ts +136 -0
- package/packages/core/src/store/ToolCallStore.ts +164 -0
- package/packages/core/src/store/base.ts +75 -0
- package/packages/core/src/types/index.ts +133 -0
- package/packages/core/tsup.config.ts +18 -0
- package/packages/themes/package.json +33 -0
- package/packages/themes/src/corporate/index.ts +52 -0
- package/packages/themes/src/corporate/theme.css +228 -0
- package/packages/themes/src/glass/index.ts +52 -0
- package/packages/themes/src/glass/theme.css +237 -0
- package/packages/themes/src/gradient/index.ts +53 -0
- package/packages/themes/src/gradient/theme.css +218 -0
- package/packages/themes/src/index.ts +13 -0
- package/packages/themes/src/minimal/index.ts +52 -0
- package/packages/themes/src/minimal/theme.css +198 -0
- package/packages/themes/src/neon/index.ts +52 -0
- package/packages/themes/src/neon/theme.css +233 -0
- package/packages/themes/src/terminal/index.ts +52 -0
- package/packages/themes/src/terminal/theme.css +235 -0
- package/packages/themes/src/types.ts +10 -0
- package/packages/themes/src/vite-env.d.ts +9 -0
- package/packages/themes/tsup.config.ts +21 -0
- package/pnpm-workspace.yaml +4 -0
- package/tsconfig.json +27 -0
- package/vite.config.ts +25 -0
- package/vitest.config.ts +28 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @generated-by AI: edenxpzhang
|
|
3
|
+
* @generated-date 2026-05-13
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { APIAdapter, Message, ModelConfig, ChatConfig, ToolCall } from '../types/index.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* API 适配器基类
|
|
10
|
+
* 提供通用的 SSE 流式处理、AbortController 管理等
|
|
11
|
+
*/
|
|
12
|
+
export abstract class BaseAPIAdapter implements APIAdapter {
|
|
13
|
+
protected abortController: AbortController | null = null;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 发送消息(由子类实现)
|
|
17
|
+
*/
|
|
18
|
+
abstract sendMessage(params: {
|
|
19
|
+
messages: Message[];
|
|
20
|
+
modelConfig: ModelConfig;
|
|
21
|
+
chatConfig: ChatConfig;
|
|
22
|
+
onChunk?: (chunk: string) => void;
|
|
23
|
+
onToolCall?: (toolCall: ToolCall) => void;
|
|
24
|
+
onComplete?: (message: Message) => void;
|
|
25
|
+
onError?: (error: Error) => void;
|
|
26
|
+
}): Promise<void>;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 中止请求
|
|
30
|
+
*/
|
|
31
|
+
abort(): void {
|
|
32
|
+
if (this.abortController) {
|
|
33
|
+
this.abortController.abort();
|
|
34
|
+
this.abortController = null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 创建 AbortController
|
|
40
|
+
*/
|
|
41
|
+
protected createAbortController(): AbortController {
|
|
42
|
+
this.abort(); // 取消之前的请求
|
|
43
|
+
this.abortController = new AbortController();
|
|
44
|
+
return this.abortController;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 转换为 OpenAI 格式的消息
|
|
49
|
+
*/
|
|
50
|
+
protected convertToOpenAIFormat(messages: Message[]): Array<{ role: string; content: string }> {
|
|
51
|
+
return messages.map(msg => ({
|
|
52
|
+
role: msg.role,
|
|
53
|
+
content: msg.content
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 处理 SSE 流式响应
|
|
59
|
+
* @param response Fetch Response 对象
|
|
60
|
+
* @param onChunk 接收文本块的回调
|
|
61
|
+
* @returns 完整的响应文本
|
|
62
|
+
*/
|
|
63
|
+
protected async handleStreamResponse(
|
|
64
|
+
response: Response,
|
|
65
|
+
onChunk: (chunk: string) => void
|
|
66
|
+
): Promise<string> {
|
|
67
|
+
if (!response.body) {
|
|
68
|
+
throw new Error('Response body is null, cannot read stream');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const reader = response.body.getReader();
|
|
72
|
+
const decoder = new TextDecoder('utf-8');
|
|
73
|
+
let fullContent = '';
|
|
74
|
+
let buffer = '';
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
while (true) {
|
|
78
|
+
const { done, value } = await reader.read();
|
|
79
|
+
|
|
80
|
+
if (done) {
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 解码并添加到缓冲区
|
|
85
|
+
buffer += decoder.decode(value, { stream: true });
|
|
86
|
+
|
|
87
|
+
// 按行处理 SSE 数据
|
|
88
|
+
const lines = buffer.split('\n');
|
|
89
|
+
// 保留最后一行(可能不完整)
|
|
90
|
+
buffer = lines.pop() || '';
|
|
91
|
+
|
|
92
|
+
for (const line of lines) {
|
|
93
|
+
const trimmedLine = line.trim();
|
|
94
|
+
|
|
95
|
+
if (!trimmedLine || trimmedLine === 'data: [DONE]') {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (trimmedLine.startsWith('data: ')) {
|
|
100
|
+
const data = trimmedLine.slice(6);
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const parsed = JSON.parse(data);
|
|
104
|
+
const content = this.extractContentFromChunk(parsed);
|
|
105
|
+
|
|
106
|
+
if (content) {
|
|
107
|
+
fullContent += content;
|
|
108
|
+
onChunk(content);
|
|
109
|
+
}
|
|
110
|
+
} catch (e) {
|
|
111
|
+
// 忽略解析错误,继续处理下一行
|
|
112
|
+
console.warn('Failed to parse SSE chunk:', data);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 处理缓冲区剩余内容
|
|
119
|
+
if (buffer.trim()) {
|
|
120
|
+
const trimmedLine = buffer.trim();
|
|
121
|
+
if (trimmedLine.startsWith('data: ')) {
|
|
122
|
+
const data = trimmedLine.slice(6);
|
|
123
|
+
try {
|
|
124
|
+
const parsed = JSON.parse(data);
|
|
125
|
+
const content = this.extractContentFromChunk(parsed);
|
|
126
|
+
if (content) {
|
|
127
|
+
fullContent += content;
|
|
128
|
+
onChunk(content);
|
|
129
|
+
}
|
|
130
|
+
} catch (e) {
|
|
131
|
+
// 忽略
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
} finally {
|
|
136
|
+
reader.releaseLock();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return fullContent;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 从 SSE chunk 中提取文本内容(由子类重写)
|
|
144
|
+
*/
|
|
145
|
+
protected extractContentFromChunk(chunk: unknown): string {
|
|
146
|
+
// OpenAI 格式
|
|
147
|
+
if (typeof chunk === 'object' && chunk !== null) {
|
|
148
|
+
const data = chunk as Record<string, unknown>;
|
|
149
|
+
|
|
150
|
+
// OpenAI 格式: choices[0].delta.content
|
|
151
|
+
if (data.choices && Array.isArray(data.choices) && data.choices.length > 0) {
|
|
152
|
+
const choice = data.choices[0] as Record<string, unknown>;
|
|
153
|
+
const delta = choice.delta as Record<string, unknown> | undefined;
|
|
154
|
+
if (delta && typeof delta.content === 'string') {
|
|
155
|
+
return delta.content;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return '';
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 处理工具调用(由子类重写)
|
|
165
|
+
*/
|
|
166
|
+
protected extractToolCallsFromChunk(chunk: unknown): ToolCall[] {
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 构建请求头
|
|
172
|
+
*/
|
|
173
|
+
protected buildHeaders(modelConfig: ModelConfig): Record<string, string> {
|
|
174
|
+
const headers: Record<string, string> = {
|
|
175
|
+
'Content-Type': 'application/json',
|
|
176
|
+
...modelConfig.headers
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
return headers;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* 构建请求体(基础实现,子类可重写)
|
|
184
|
+
*/
|
|
185
|
+
protected buildRequestBody(
|
|
186
|
+
messages: Message[],
|
|
187
|
+
modelConfig: ModelConfig,
|
|
188
|
+
chatConfig: ChatConfig
|
|
189
|
+
): Record<string, unknown> {
|
|
190
|
+
return {
|
|
191
|
+
model: modelConfig.model,
|
|
192
|
+
messages: this.convertToOpenAIFormat(messages),
|
|
193
|
+
stream: chatConfig.stream !== false,
|
|
194
|
+
...modelConfig.params
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* 处理错误
|
|
200
|
+
*/
|
|
201
|
+
protected handleError(error: unknown, onError?: (error: Error) => void): void {
|
|
202
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
203
|
+
|
|
204
|
+
if (onError) {
|
|
205
|
+
onError(err);
|
|
206
|
+
} else {
|
|
207
|
+
console.error('API Adapter Error:', err);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @generated-by AI: edenxpzhang
|
|
3
|
+
* @generated-date 2026-05-13
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { APIAdapter, ModelConfig } from '../types/index.js';
|
|
7
|
+
import { OpenAIAdapter } from './adapters.js';
|
|
8
|
+
import { ERNIEAdapter } from './adapters.js';
|
|
9
|
+
import { QwenAdapter } from './adapters.js';
|
|
10
|
+
import { SparkAdapter } from './adapters.js';
|
|
11
|
+
import { HunyuanAdapter } from './adapters.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 创建 API 适配器
|
|
15
|
+
* @param provider 模型提供商
|
|
16
|
+
* @returns 对应的 API 适配器实例
|
|
17
|
+
*/
|
|
18
|
+
export function createAPIAdapter(provider: ModelConfig['provider']): APIAdapter {
|
|
19
|
+
switch (provider) {
|
|
20
|
+
case 'openai':
|
|
21
|
+
return new OpenAIAdapter();
|
|
22
|
+
|
|
23
|
+
case 'ernie':
|
|
24
|
+
return new ERNIEAdapter();
|
|
25
|
+
|
|
26
|
+
case 'qwen':
|
|
27
|
+
return new QwenAdapter();
|
|
28
|
+
|
|
29
|
+
case 'spark':
|
|
30
|
+
return new SparkAdapter();
|
|
31
|
+
|
|
32
|
+
case 'hunyuan':
|
|
33
|
+
return new HunyuanAdapter();
|
|
34
|
+
|
|
35
|
+
case 'custom':
|
|
36
|
+
// 自定义提供商默认使用 OpenAI 兼容格式
|
|
37
|
+
console.warn('Custom provider using OpenAI-compatible format by default');
|
|
38
|
+
return new OpenAIAdapter();
|
|
39
|
+
|
|
40
|
+
default:
|
|
41
|
+
throw new Error(`Unsupported provider: ${provider}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 适配器类导出(供需要直接实例化的场景使用)
|
|
47
|
+
*/
|
|
48
|
+
export {
|
|
49
|
+
OpenAIAdapter,
|
|
50
|
+
ERNIEAdapter,
|
|
51
|
+
QwenAdapter,
|
|
52
|
+
SparkAdapter,
|
|
53
|
+
HunyuanAdapter
|
|
54
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @generated-by AI: edenxpzhang
|
|
3
|
+
* @generated-date 2026-05-13
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// ============================================================================
|
|
7
|
+
// 类型导出
|
|
8
|
+
// ============================================================================
|
|
9
|
+
export {
|
|
10
|
+
MessageRole,
|
|
11
|
+
type Message,
|
|
12
|
+
type ToolCall,
|
|
13
|
+
type ModelConfig,
|
|
14
|
+
type ChatConfig,
|
|
15
|
+
type APIAdapter,
|
|
16
|
+
type MessagePlugin,
|
|
17
|
+
type RenderContext,
|
|
18
|
+
type ThemeConfig,
|
|
19
|
+
type ChatState
|
|
20
|
+
} from './types/index.js';
|
|
21
|
+
|
|
22
|
+
// ============================================================================
|
|
23
|
+
// Store 导出
|
|
24
|
+
// ============================================================================
|
|
25
|
+
export { BaseStore } from './store/base.js';
|
|
26
|
+
export { ChatStore } from './store/ChatStore.js';
|
|
27
|
+
export { ModelConfigStore } from './store/ModelConfigStore.js';
|
|
28
|
+
export { ToolCallStore } from './store/ToolCallStore.js';
|
|
29
|
+
|
|
30
|
+
// ============================================================================
|
|
31
|
+
// API 适配器导出
|
|
32
|
+
// ============================================================================
|
|
33
|
+
export { BaseAPIAdapter } from './api/base.js';
|
|
34
|
+
export {
|
|
35
|
+
OpenAIAdapter,
|
|
36
|
+
ERNIEAdapter,
|
|
37
|
+
QwenAdapter,
|
|
38
|
+
SparkAdapter,
|
|
39
|
+
HunyuanAdapter,
|
|
40
|
+
createAPIAdapter
|
|
41
|
+
} from './api/index.js';
|
|
42
|
+
|
|
43
|
+
// ============================================================================
|
|
44
|
+
// Parser 导出
|
|
45
|
+
// ============================================================================
|
|
46
|
+
// Markdown 解析器
|
|
47
|
+
export {
|
|
48
|
+
parseMarkdown,
|
|
49
|
+
detectMarkdown,
|
|
50
|
+
registerCodeCopyHandler,
|
|
51
|
+
renderMarkdownToContainer
|
|
52
|
+
} from './parser/markdown.js';
|
|
53
|
+
|
|
54
|
+
// Mermaid 检测器
|
|
55
|
+
export {
|
|
56
|
+
type MermaidChartType,
|
|
57
|
+
type MermaidDetectionResult,
|
|
58
|
+
detectMermaid,
|
|
59
|
+
detectMermaidChartType,
|
|
60
|
+
extractMermaidFromMarkdown,
|
|
61
|
+
renderMermaid,
|
|
62
|
+
renderAllMermaidInContainer,
|
|
63
|
+
validateMermaid
|
|
64
|
+
} from './parser/mermaid.js';
|
|
65
|
+
|
|
66
|
+
// Latex 检测器
|
|
67
|
+
export {
|
|
68
|
+
type LatexDetectionResult,
|
|
69
|
+
detectLatex,
|
|
70
|
+
hasLatex,
|
|
71
|
+
replaceLatexWithPlaceholders,
|
|
72
|
+
renderLatex,
|
|
73
|
+
renderLatexWithKaTeX,
|
|
74
|
+
extractAllLatex
|
|
75
|
+
} from './parser/latex.js';
|
|
76
|
+
|
|
77
|
+
// ============================================================================
|
|
78
|
+
// Plugin 导出
|
|
79
|
+
// ============================================================================
|
|
80
|
+
export { PluginManager, getDefaultPluginManager } from './plugins/PluginManager.js';
|
|
81
|
+
export {
|
|
82
|
+
CodeBlockPlugin,
|
|
83
|
+
MermaidPlugin,
|
|
84
|
+
LatexPlugin,
|
|
85
|
+
ToolCallPlugin,
|
|
86
|
+
registerBuiltinPlugins
|
|
87
|
+
} from './plugins/builtin.js';
|
|
88
|
+
|
|
89
|
+
// ============================================================================
|
|
90
|
+
// 版本信息
|
|
91
|
+
// ============================================================================
|
|
92
|
+
export const VERSION = '0.1.0';
|
|
93
|
+
export const PACKAGE_NAME = '@ai-chat-ui-kit/core';
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @generated-by AI: edenxpzhang
|
|
3
|
+
* @generated-date 2026-05-13
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Latex 检测结果的接口
|
|
8
|
+
*/
|
|
9
|
+
export interface LatexDetectionResult {
|
|
10
|
+
// 行内公式
|
|
11
|
+
inline: string[];
|
|
12
|
+
// 块级公式
|
|
13
|
+
block: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 检测内容中的 Latex 公式
|
|
18
|
+
* @param content 待检测的内容
|
|
19
|
+
* @returns 包含行内和块级公式的对象
|
|
20
|
+
*/
|
|
21
|
+
export function detectLatex(content: string): LatexDetectionResult {
|
|
22
|
+
if (!content || typeof content !== 'string') {
|
|
23
|
+
return { inline: [], block: [] };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const result: LatexDetectionResult = {
|
|
27
|
+
inline: [],
|
|
28
|
+
block: []
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// 块级公式模式:
|
|
32
|
+
// 1. $$...$$ (双美元符号)
|
|
33
|
+
// 2. \[...\] (方括号)
|
|
34
|
+
// 3. \begin{equation}...\end{equation}
|
|
35
|
+
// 4. \begin{align}...\end{align}
|
|
36
|
+
// 5. \begin{gather}...\end{gather}
|
|
37
|
+
|
|
38
|
+
const blockPatterns = [
|
|
39
|
+
/\$\$([\s\S]*?)\$\$/g, // $$...$$
|
|
40
|
+
/\\\[([\s\S]*?)\\\]/g, // \[...\]
|
|
41
|
+
/\\begin\{(equation|align|gather|multline)\}([\s\S]*?)\\end\{\1\}/g // \begin{...}...\end{...}
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
for (const pattern of blockPatterns) {
|
|
45
|
+
let match: RegExpExecArray | null;
|
|
46
|
+
const regex = new RegExp(pattern.source, pattern.flags);
|
|
47
|
+
|
|
48
|
+
while ((match = regex.exec(content)) !== null) {
|
|
49
|
+
const formula = match[1] || match[0];
|
|
50
|
+
if (formula.trim()) {
|
|
51
|
+
result.block.push(formula.trim());
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 行内公式模式:
|
|
57
|
+
// 1. $...$ (单美元符号,但不能是 $$)
|
|
58
|
+
// 2. \(...\) (括号)
|
|
59
|
+
|
|
60
|
+
const inlinePatterns = [
|
|
61
|
+
/(?<!\$)\$(?!\$)(.*?)(?<!\$)\$(?!\$)/g, // $...$ (不匹配 $$)
|
|
62
|
+
/\\\((.*?)\\\)/g // \(...\)
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
for (const pattern of inlinePatterns) {
|
|
66
|
+
let match: RegExpExecArray | null;
|
|
67
|
+
const regex = new RegExp(pattern.source, pattern.flags);
|
|
68
|
+
|
|
69
|
+
while ((match = regex.exec(content)) !== null) {
|
|
70
|
+
const formula = match[1] || match[0];
|
|
71
|
+
if (formula.trim()) {
|
|
72
|
+
result.inline.push(formula.trim());
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 去重
|
|
78
|
+
result.inline = [...new Set(result.inline)];
|
|
79
|
+
result.block = [...new Set(result.block)];
|
|
80
|
+
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 检测内容是否包含 Latex 公式
|
|
86
|
+
* @param content 待检测的内容
|
|
87
|
+
* @returns 是否包含 Latex
|
|
88
|
+
*/
|
|
89
|
+
export function hasLatex(content: string): boolean {
|
|
90
|
+
if (!content) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 快速检测:查找常见的 Latex 标记
|
|
95
|
+
const quickChecks = [
|
|
96
|
+
'$$', // 块级公式
|
|
97
|
+
'\\(', // 行内公式开始
|
|
98
|
+
'\\)', // 行内公式结束
|
|
99
|
+
'\\[', // 块级公式开始
|
|
100
|
+
'\\]', // 块级公式结束
|
|
101
|
+
'\\begin{', // 环境开始
|
|
102
|
+
'\\end{', // 环境结束
|
|
103
|
+
'\\frac', // 分数
|
|
104
|
+
'\\sum', // 求和
|
|
105
|
+
'\\int', // 积分
|
|
106
|
+
'\\alpha', // 希腊字母
|
|
107
|
+
'\\beta',
|
|
108
|
+
'\\gamma',
|
|
109
|
+
'\\delta',
|
|
110
|
+
'\\pi',
|
|
111
|
+
'\\infty', // 无穷符号
|
|
112
|
+
'\\overrightarrow', // 向量
|
|
113
|
+
'\\begin{matrix}', // 矩阵
|
|
114
|
+
'\\begin{pmatrix}' // 括号矩阵
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
return quickChecks.some(check => content.includes(check));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 将 Latex 公式替换为占位符
|
|
122
|
+
* @param content 原始内容
|
|
123
|
+
* @returns 替换后的内容和占位符映射
|
|
124
|
+
*/
|
|
125
|
+
export function replaceLatexWithPlaceholders(content: string): {
|
|
126
|
+
processed: string;
|
|
127
|
+
placeholders: Array<{ id: string; formula: string; display: boolean }>;
|
|
128
|
+
} {
|
|
129
|
+
if (!content) {
|
|
130
|
+
return { processed: '', placeholders: [] };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const placeholders: Array<{ id: string; formula: string; display: boolean }> = [];
|
|
134
|
+
let processed = content;
|
|
135
|
+
let counter = 0;
|
|
136
|
+
|
|
137
|
+
// 先替换块级公式
|
|
138
|
+
const blockReplacements = [
|
|
139
|
+
{ regex: /\$\$([\s\S]*?)\$\$/g, display: true },
|
|
140
|
+
{ regex: /\\\[([\s\S]*?)\\\]/g, display: true },
|
|
141
|
+
{ regex: /\\begin\{(equation|align|gather|multline)\}([\s\S]*?)\\end\{\1\}/g, display: true }
|
|
142
|
+
];
|
|
143
|
+
|
|
144
|
+
for (const { regex, display } of blockReplacements) {
|
|
145
|
+
processed = processed.replace(regex, (match, ...args) => {
|
|
146
|
+
const formula = args[0] || match;
|
|
147
|
+
const id = `__LATEX_PLACEHOLDER_${counter++}__`;
|
|
148
|
+
placeholders.push({ id, formula: formula.trim(), display });
|
|
149
|
+
return id;
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 再替换行内公式
|
|
154
|
+
const inlineReplacements = [
|
|
155
|
+
{ regex: /(?<!\$)\$(?!\$)(.*?)(?<!\$)\$(?!\$)/g, display: false },
|
|
156
|
+
{ regex: /\\\((.*?)\\\)/g, display: false }
|
|
157
|
+
];
|
|
158
|
+
|
|
159
|
+
for (const { regex, display } of inlineReplacements) {
|
|
160
|
+
processed = processed.replace(regex, (match, ...args) => {
|
|
161
|
+
const formula = args[0] || match;
|
|
162
|
+
const id = `__LATEX_PLACEHOLDER_${counter++}__`;
|
|
163
|
+
placeholders.push({ id, formula: formula.trim(), display });
|
|
164
|
+
return id;
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return { processed, placeholders };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 渲染 Latex 公式(返回 HTML 字符串,供外部如 KaTeX 渲染)
|
|
173
|
+
* @param content 包含 Latex 的内容
|
|
174
|
+
* @returns 处理后的 HTML 字符串
|
|
175
|
+
*/
|
|
176
|
+
export function renderLatex(content: string): string {
|
|
177
|
+
if (!content) {
|
|
178
|
+
return '';
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let processed = content;
|
|
182
|
+
|
|
183
|
+
// 替换块级公式
|
|
184
|
+
processed = processed.replace(/\$\$([\s\S]*?)\$\$/g, (_, formula) => {
|
|
185
|
+
return `<div class="latex-block">$$${formula}$$</div>`;
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
processed = processed.replace(/\\\[([\s\S]*?)\\\]/g, (_, formula) => {
|
|
189
|
+
return `<div class="latex-block">\\[${formula}\\]</div>`;
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// 替换行内公式
|
|
193
|
+
processed = processed.replace(/(?<!\$)\$(?!\$)(.*?)(?<!\$)\$(?!\$)/g, (_, formula) => {
|
|
194
|
+
return `<span class="latex-inline">$${formula}$</span>`;
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
processed = processed.replace(/\\\((.*?)\\\)/g, (_, formula) => {
|
|
198
|
+
return `<span class="latex-inline">\\(${formula}\\)</span>`;
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
return processed;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* 使用 KaTeX 渲染 Latex 公式
|
|
206
|
+
* @param content 包含 Latex 的内容
|
|
207
|
+
* @param container 目标容器元素
|
|
208
|
+
* @returns 渲染后的 HTML 字符串
|
|
209
|
+
*/
|
|
210
|
+
export function renderLatexWithKaTeX(content: string, container: HTMLElement): string {
|
|
211
|
+
if (!content || !container) {
|
|
212
|
+
return '';
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (typeof window === 'undefined') {
|
|
216
|
+
console.error('renderLatexWithKaTeX: only works in browser environment');
|
|
217
|
+
return '';
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// 动态导入 KaTeX
|
|
221
|
+
import('katex')
|
|
222
|
+
.then((katex) => {
|
|
223
|
+
// 渲染块级公式
|
|
224
|
+
const blockRegex = /\$\$([\s\S]*?)\$\$/g;
|
|
225
|
+
let match: RegExpExecArray | null;
|
|
226
|
+
|
|
227
|
+
// 先处理块级公式
|
|
228
|
+
container.innerHTML = container.innerHTML.replace(blockRegex, (_, formula) => {
|
|
229
|
+
try {
|
|
230
|
+
const tempDiv = document.createElement('div');
|
|
231
|
+
katex.default.render(formula, tempDiv, {
|
|
232
|
+
displayMode: true,
|
|
233
|
+
throwOnError: false
|
|
234
|
+
});
|
|
235
|
+
return tempDiv.innerHTML;
|
|
236
|
+
} catch (err) {
|
|
237
|
+
console.error('KaTeX block render error:', err);
|
|
238
|
+
return `$$${formula}$$`;
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// 处理行内公式
|
|
243
|
+
const inlineRegex = /(?<!\$)\$(?!\$)(.*?)(?<!\$)\$(?!\$)/g;
|
|
244
|
+
container.innerHTML = container.innerHTML.replace(inlineRegex, (_, formula) => {
|
|
245
|
+
try {
|
|
246
|
+
const tempDiv = document.createElement('span');
|
|
247
|
+
katex.default.render(formula, tempDiv, {
|
|
248
|
+
displayMode: false,
|
|
249
|
+
throwOnError: false
|
|
250
|
+
});
|
|
251
|
+
return tempDiv.innerHTML;
|
|
252
|
+
} catch (err) {
|
|
253
|
+
console.error('KaTeX inline render error:', err);
|
|
254
|
+
return `$${formula}$`;
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
})
|
|
258
|
+
.catch((err) => {
|
|
259
|
+
console.error('Failed to load KaTeX:', err);
|
|
260
|
+
container.innerHTML = `<pre style="color:red;">无法加载 KaTeX 库</pre>`;
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
return container.innerHTML;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* 提取所有 Latex 公式(用于预处理)
|
|
268
|
+
* @param content 原始内容
|
|
269
|
+
* @returns 所有公式的数组
|
|
270
|
+
*/
|
|
271
|
+
export function extractAllLatex(content: string): string[] {
|
|
272
|
+
const result = detectLatex(content);
|
|
273
|
+
return [...result.block, ...result.inline];
|
|
274
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @generated-by AI: edenxpzhang
|
|
3
|
+
* @generated-date 2026-05-13
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, expect } from 'vitest';
|
|
6
|
+
import { parseMarkdown, detectMarkdown } from './markdown';
|
|
7
|
+
|
|
8
|
+
describe('Markdown Parser', () => {
|
|
9
|
+
describe('parseMarkdown', () => {
|
|
10
|
+
it('should parse basic markdown', () => {
|
|
11
|
+
const result = parseMarkdown('# Hello World');
|
|
12
|
+
expect(result).toContain('Hello World');
|
|
13
|
+
expect(result).toContain('<h1>');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('should handle empty input', () => {
|
|
17
|
+
const result = parseMarkdown('');
|
|
18
|
+
expect(result).toBe('');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('should parse code blocks with highlight.js', () => {
|
|
22
|
+
const code = '```javascript\nconst a = 1;\n```';
|
|
23
|
+
const result = parseMarkdown(code);
|
|
24
|
+
expect(result).toContain('code-block');
|
|
25
|
+
expect(result).toContain('javascript');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should parse tables', () => {
|
|
29
|
+
const table = '| Header |\n| ------ |\n| Cell |';
|
|
30
|
+
const result = parseMarkdown(table);
|
|
31
|
+
expect(result).toContain('<table>');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should parse bold and italic text', () => {
|
|
35
|
+
expect(parseMarkdown('**bold**')).toContain('<strong>bold</strong>');
|
|
36
|
+
expect(parseMarkdown('*italic*')).toContain('<em>italic</em>');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('detectMarkdown', () => {
|
|
41
|
+
it('should detect headings', () => {
|
|
42
|
+
expect(detectMarkdown('# Heading')).toBe(true);
|
|
43
|
+
expect(detectMarkdown('## H2')).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should detect code blocks', () => {
|
|
47
|
+
expect(detectMarkdown('```code```')).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should detect bold text', () => {
|
|
51
|
+
expect(detectMarkdown('**bold**')).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should return false for plain text', () => {
|
|
55
|
+
expect(detectMarkdown('plain text without markdown')).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
});
|