visualknowledge 0.1.0 → 0.1.2
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/frontend/index.html +41 -0
- package/frontend/src/App.jsx +59 -0
- package/frontend/src/components/AssistantMessage.jsx +58 -0
- package/frontend/src/components/ChatArea.jsx +20 -0
- package/frontend/src/components/InputArea.jsx +68 -0
- package/frontend/src/components/Message.jsx +16 -0
- package/frontend/src/components/MessageList.jsx +23 -0
- package/frontend/src/components/TextPart.jsx +45 -0
- package/frontend/src/components/TopBar.jsx +27 -0
- package/frontend/src/components/TypingIndicator.jsx +13 -0
- package/frontend/src/components/UserMessage.jsx +14 -0
- package/frontend/src/components/WelcomeScreen.jsx +15 -0
- package/frontend/src/components/widgets/FullscreenOverlay.jsx +44 -0
- package/frontend/src/components/widgets/HtmlWidget.jsx +89 -0
- package/frontend/src/components/widgets/MermaidWidget.jsx +61 -0
- package/frontend/src/components/widgets/SvgWidget.jsx +59 -0
- package/frontend/src/components/widgets/WidgetContainer.jsx +32 -0
- package/frontend/src/context/AppContext.jsx +151 -0
- package/frontend/src/hooks/useChat.js +160 -0
- package/frontend/src/hooks/useModels.js +52 -0
- package/frontend/src/hooks/useTheme.js +31 -0
- package/frontend/src/lib/markdownRenderer.js +80 -0
- package/frontend/src/lib/mermaidRenderer.js +99 -0
- package/frontend/src/lib/streamProcessor.js +205 -0
- package/frontend/src/main.js +16 -0
- package/frontend/src/utils/escape.js +10 -0
- package/frontend/styles/base.css +7 -0
- package/frontend/styles/chat-area.css +24 -0
- package/frontend/styles/input-area.css +37 -0
- package/frontend/styles/markdown.css +50 -0
- package/frontend/styles/message.css +24 -0
- package/frontend/styles/top-bar.css +32 -0
- package/frontend/styles/variables.css +74 -0
- package/frontend/styles/widget.css +62 -0
- package/package.json +29 -1
- package/server.py +11 -1
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Claude Chat</title>
|
|
7
|
+
|
|
8
|
+
<!-- CSS: variables must load first -->
|
|
9
|
+
<link rel="stylesheet" href="./styles/variables.css">
|
|
10
|
+
<link rel="stylesheet" href="./styles/base.css">
|
|
11
|
+
<link rel="stylesheet" href="./styles/top-bar.css">
|
|
12
|
+
<link rel="stylesheet" href="./styles/chat-area.css">
|
|
13
|
+
<link rel="stylesheet" href="./styles/message.css">
|
|
14
|
+
<link rel="stylesheet" href="./styles/markdown.css">
|
|
15
|
+
<link rel="stylesheet" href="./styles/widget.css">
|
|
16
|
+
<link rel="stylesheet" href="./styles/input-area.css">
|
|
17
|
+
|
|
18
|
+
<!-- CDN: marked (global) -->
|
|
19
|
+
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
|
20
|
+
|
|
21
|
+
<!-- KaTeX 数学公式渲染 -->
|
|
22
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
|
|
23
|
+
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
|
24
|
+
|
|
25
|
+
<!-- ESM importmap for React ecosystem -->
|
|
26
|
+
<script type="importmap">
|
|
27
|
+
{
|
|
28
|
+
"imports": {
|
|
29
|
+
"react": "https://esm.sh/react@18.3.1",
|
|
30
|
+
"react-dom/client": "https://esm.sh/react-dom@18.3.1/client",
|
|
31
|
+
"react/jsx-runtime": "https://esm.sh/react@18.3.1/jsx-runtime",
|
|
32
|
+
"htm": "https://esm.sh/htm@3.1.1"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
</script>
|
|
36
|
+
</head>
|
|
37
|
+
<body>
|
|
38
|
+
<div id="root"></div>
|
|
39
|
+
<script type="module" src="./src/main.js"></script>
|
|
40
|
+
</body>
|
|
41
|
+
</html>
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App - Root Component
|
|
3
|
+
*
|
|
4
|
+
* 布局编排:TopBar + ChatArea(MessageList) + InputArea + FullscreenOverlay
|
|
5
|
+
* 顶层聚合所有 Hooks,将数据通过 props 分发给子组件。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { html } from 'htm/react';
|
|
9
|
+
import { useAppState, ActionTypes } from './context/AppContext.jsx';
|
|
10
|
+
import { TopBar } from './components/TopBar.jsx';
|
|
11
|
+
import { ChatArea } from './components/ChatArea.jsx';
|
|
12
|
+
import { MessageList } from './components/MessageList.jsx';
|
|
13
|
+
import { InputArea } from './components/InputArea.jsx';
|
|
14
|
+
import { FullscreenOverlay } from './components/widgets/FullscreenOverlay.jsx';
|
|
15
|
+
import { useChat } from './hooks/useChat.js';
|
|
16
|
+
import { useTheme } from './hooks/useTheme.js';
|
|
17
|
+
import { useModels } from './hooks/useModels.js';
|
|
18
|
+
|
|
19
|
+
export function App() {
|
|
20
|
+
const { state, dispatch } = useAppState();
|
|
21
|
+
const { messages, isStreaming, sendMessage } = useChat();
|
|
22
|
+
const { theme, toggleTheme } = useTheme();
|
|
23
|
+
const { currentModel, models, setModel } = useModels();
|
|
24
|
+
|
|
25
|
+
// 同步 theme 到 context
|
|
26
|
+
React.useEffect(() => {
|
|
27
|
+
if (state.theme !== theme) {
|
|
28
|
+
dispatch({ type: ActionTypes.SET_THEME, theme });
|
|
29
|
+
}
|
|
30
|
+
}, [theme]);
|
|
31
|
+
|
|
32
|
+
// 全屏 Widget 回调:通过 context dispatch 设置 fullscreenWidget
|
|
33
|
+
const handleFullscreen = (widgetState) => {
|
|
34
|
+
dispatch({ type: ActionTypes.SET_FULLSCREEN_WIDGET, widget: widgetState });
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
return html`
|
|
38
|
+
<div className="app-container" style=${{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
|
|
39
|
+
<${TopBar}
|
|
40
|
+
theme=${theme}
|
|
41
|
+
onThemeToggle=${toggleTheme}
|
|
42
|
+
currentModel=${currentModel}
|
|
43
|
+
models=${models}
|
|
44
|
+
onModelChange=${setModel}
|
|
45
|
+
/>
|
|
46
|
+
<${ChatArea}>
|
|
47
|
+
<${MessageList} messages=${messages} isStreaming=${isStreaming} onFullscreen=${handleFullscreen} />
|
|
48
|
+
</${ChatArea}>
|
|
49
|
+
<${InputArea}
|
|
50
|
+
onSend=${sendMessage}
|
|
51
|
+
disabled=${isStreaming}
|
|
52
|
+
/>
|
|
53
|
+
<${FullscreenOverlay}
|
|
54
|
+
widget=${state.fullscreenWidget}
|
|
55
|
+
onClose=${() => dispatch({ type: ActionTypes.SET_FULLSCREEN_WIDGET, widget: null })}
|
|
56
|
+
/>
|
|
57
|
+
</div>
|
|
58
|
+
`;
|
|
59
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AssistantMessage - AI 消息组件
|
|
3
|
+
*
|
|
4
|
+
* 渲染头像 + ContentSegment 列表(TextPart / MermaidWidget / HtmlWidget / SvgWidget)
|
|
5
|
+
* + TypingIndicator(流式输出中时)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { html } from 'htm/react';
|
|
9
|
+
import { TextPart } from './TextPart.jsx';
|
|
10
|
+
import { TypingIndicator } from './TypingIndicator.jsx';
|
|
11
|
+
import { MermaidWidget } from './widgets/MermaidWidget.jsx';
|
|
12
|
+
import { HtmlWidget } from './widgets/HtmlWidget.jsx';
|
|
13
|
+
import { SvgWidget } from './widgets/SvgWidget.jsx';
|
|
14
|
+
|
|
15
|
+
export function AssistantMessage({ message, isStreaming, onFullscreen }) {
|
|
16
|
+
const segments = message.segments || [];
|
|
17
|
+
|
|
18
|
+
const handleMermaidZoom = (svgContent, chartType) => {
|
|
19
|
+
if (onFullscreen) {
|
|
20
|
+
onFullscreen({ widgetType: 'mermaid', title: chartType, content: svgContent });
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const handleHtmlFullscreen = (html) => {
|
|
25
|
+
if (onFullscreen) {
|
|
26
|
+
onFullscreen({ widgetType: 'html', title: '交互式可视化', content: html });
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const handleSvgZoom = (svgContent) => {
|
|
31
|
+
if (onFullscreen) {
|
|
32
|
+
onFullscreen({ widgetType: 'svg', title: 'SVG 可视化', content: svgContent });
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
return html`
|
|
37
|
+
<div class="message assistant">
|
|
38
|
+
<div class="avatar">C</div>
|
|
39
|
+
<div class="content">
|
|
40
|
+
${segments.map((seg, i) => {
|
|
41
|
+
switch (seg.type) {
|
|
42
|
+
case 'text':
|
|
43
|
+
return html`<${TextPart} key=${seg.id || i} content=${seg.content} isStreaming=${isStreaming && i === segments.length - 1} />`;
|
|
44
|
+
case 'mermaid':
|
|
45
|
+
return html`<${MermaidWidget} key=${seg.id || i} code=${seg.content} onZoom=${handleMermaidZoom} />`;
|
|
46
|
+
case 'html':
|
|
47
|
+
return html`<${HtmlWidget} key=${seg.id || i} html=${seg.content} onFullscreen=${handleHtmlFullscreen} />`;
|
|
48
|
+
case 'svg':
|
|
49
|
+
return html`<${SvgWidget} key=${seg.id || i} svg=${seg.content} onZoom=${handleSvgZoom} />`;
|
|
50
|
+
default:
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
})}
|
|
54
|
+
${isStreaming ? html`<${TypingIndicator} />` : ''}
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
`;
|
|
58
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChatArea - 滚动容器组件
|
|
3
|
+
*
|
|
4
|
+
* 自动滚动到底部(当 messages 变化或 isStreaming 变化时)
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { html } from 'htm/react';
|
|
8
|
+
import { useRef, useEffect } from 'react';
|
|
9
|
+
|
|
10
|
+
export function ChatArea({ children }) {
|
|
11
|
+
const containerRef = useRef(null);
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
if (containerRef.current) {
|
|
15
|
+
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
|
16
|
+
}
|
|
17
|
+
}, [children]);
|
|
18
|
+
|
|
19
|
+
return html`<div className="chat-area" ref=${containerRef}>${children}</div>`;
|
|
20
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* InputArea - 输入区域组件
|
|
3
|
+
*
|
|
4
|
+
* Textarea + 自动高度调整 + 发送按钮
|
|
5
|
+
* Enter 发送 / Shift+Enter 换行
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { html } from 'htm/react';
|
|
9
|
+
import { useState, useRef, useCallback } from 'react';
|
|
10
|
+
|
|
11
|
+
export function InputArea({ onSend, disabled }) {
|
|
12
|
+
const [value, setValue] = useState('');
|
|
13
|
+
const textareaRef = useRef(null);
|
|
14
|
+
|
|
15
|
+
const autoResize = useCallback(() => {
|
|
16
|
+
const ta = textareaRef.current;
|
|
17
|
+
if (!ta) return;
|
|
18
|
+
ta.style.height = 'auto';
|
|
19
|
+
ta.style.height = Math.min(ta.scrollHeight, 200) + 'px';
|
|
20
|
+
}, []);
|
|
21
|
+
|
|
22
|
+
const handleSend = useCallback(() => {
|
|
23
|
+
const text = value.trim();
|
|
24
|
+
if (!text || disabled) return;
|
|
25
|
+
onSend(text);
|
|
26
|
+
setValue('');
|
|
27
|
+
// 重置高度
|
|
28
|
+
if (textareaRef.current) {
|
|
29
|
+
textareaRef.current.style.height = 'auto';
|
|
30
|
+
}
|
|
31
|
+
}, [value, disabled, onSend]);
|
|
32
|
+
|
|
33
|
+
const handleKeyDown = useCallback((e) => {
|
|
34
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
35
|
+
e.preventDefault();
|
|
36
|
+
handleSend();
|
|
37
|
+
}
|
|
38
|
+
}, [handleSend]);
|
|
39
|
+
|
|
40
|
+
const hasContent = value.trim().length > 0;
|
|
41
|
+
|
|
42
|
+
return html`
|
|
43
|
+
<div class="input-area">
|
|
44
|
+
<div class="input-wrapper">
|
|
45
|
+
<textarea
|
|
46
|
+
ref=${textareaRef}
|
|
47
|
+
rows="1"
|
|
48
|
+
placeholder="输入消息... Shift+Enter 换行"
|
|
49
|
+
value=${value}
|
|
50
|
+
onChange=${(e) => { setValue(e.target.value); autoResize(); }}
|
|
51
|
+
onKeyDown=${handleKeyDown}
|
|
52
|
+
disabled=${disabled}
|
|
53
|
+
/>
|
|
54
|
+
<button
|
|
55
|
+
class="send-btn ${hasContent && !disabled ? 'active' : ''}"
|
|
56
|
+
title="发送"
|
|
57
|
+
onClick=${handleSend}
|
|
58
|
+
disabled=${disabled || !hasContent}
|
|
59
|
+
>
|
|
60
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
|
61
|
+
<line x1="22" y1="2" x2="11" y2="13"></line>
|
|
62
|
+
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
|
|
63
|
+
</svg>
|
|
64
|
+
</button>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
`;
|
|
68
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message - 消息分发组件
|
|
3
|
+
*
|
|
4
|
+
* 根据 role 渲染 UserMessage 或 AssistantMessage
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { html } from 'htm/react';
|
|
8
|
+
import { UserMessage } from './UserMessage.jsx';
|
|
9
|
+
import { AssistantMessage } from './AssistantMessage.jsx';
|
|
10
|
+
|
|
11
|
+
export function Message({ message, isStreaming, onFullscreen }) {
|
|
12
|
+
if (message.role === 'user') {
|
|
13
|
+
return html`<${UserMessage} content=${message.content} />`;
|
|
14
|
+
}
|
|
15
|
+
return html`<${AssistantMessage} message=${message} isStreaming=${isStreaming} onFullscreen=${onFullscreen} />`;
|
|
16
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MessageList - 消息列表
|
|
3
|
+
*
|
|
4
|
+
* 为空时显示 WelcomeScreen,否则遍历渲染 Message
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { html } from 'htm/react';
|
|
8
|
+
import { Message } from './Message.jsx';
|
|
9
|
+
import { WelcomeScreen } from './WelcomeScreen.jsx';
|
|
10
|
+
|
|
11
|
+
export function MessageList({ messages, isStreaming, onFullscreen }) {
|
|
12
|
+
if (!messages || messages.length === 0) {
|
|
13
|
+
return html`<${WelcomeScreen} />`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return html`
|
|
17
|
+
<div class="messages">
|
|
18
|
+
${messages.map(msg =>
|
|
19
|
+
html`<${Message} key=${msg.id} message=${msg} isStreaming=${isStreaming && msg.status === 'streaming'} onFullscreen=${onFullscreen} />`
|
|
20
|
+
)}
|
|
21
|
+
</div>
|
|
22
|
+
`;
|
|
23
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TextPart - Markdown 文本段落组件
|
|
3
|
+
*
|
|
4
|
+
* 调用 markdownRenderer.renderMarkdown 生成 HTML,
|
|
5
|
+
* 使用 dangerouslySetInnerHTML 注入 DOM,
|
|
6
|
+
* useEffect 中触发 KaTeX 重新渲染。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { html } from 'htm/react';
|
|
10
|
+
import { useEffect, useRef } from 'react';
|
|
11
|
+
import { renderMarkdown } from '../lib/markdownRenderer.js';
|
|
12
|
+
|
|
13
|
+
export function TextPart({ content, isStreaming }) {
|
|
14
|
+
const elRef = useRef(null);
|
|
15
|
+
|
|
16
|
+
// 渲染 Markdown HTML
|
|
17
|
+
const renderedHtml = renderMarkdown(content || '');
|
|
18
|
+
|
|
19
|
+
// 内容更新后重新渲染 KaTeX
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (elRef.current && typeof katex !== 'undefined') {
|
|
22
|
+
try {
|
|
23
|
+
// 重新扫描并渲染数学公式
|
|
24
|
+
renderMathInElement(elRef.current, {
|
|
25
|
+
delimiters: [
|
|
26
|
+
{ left: '$$', right: '$$', displayMode: true },
|
|
27
|
+
{ left: '$', right: '$', displayMode: false },
|
|
28
|
+
],
|
|
29
|
+
throwOnError: false,
|
|
30
|
+
});
|
|
31
|
+
} catch (e) {
|
|
32
|
+
// 静默失败
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}, [content]);
|
|
36
|
+
|
|
37
|
+
return html`<div class="text-part" ref=${elRef} dangerouslySetInnerHTML=${{ __html: renderedHtml }} />`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 辅助:调用全局 renderMathInElement(katex 提供)
|
|
41
|
+
function renderMathInElement(element, options) {
|
|
42
|
+
if (typeof window !== 'undefined' && window.renderMathInElement) {
|
|
43
|
+
window.renderMathInElement(element, options);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TopBar - 顶部导航栏
|
|
3
|
+
*
|
|
4
|
+
* Logo + "Claude Chat" 标题 + 主题切换按钮 + 模型选择器
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { html } from 'htm/react';
|
|
8
|
+
|
|
9
|
+
export function TopBar({ theme, onThemeToggle, currentModel, models, onModelChange }) {
|
|
10
|
+
return html`
|
|
11
|
+
<div class="top-bar">
|
|
12
|
+
<div class="left">
|
|
13
|
+
<div class="logo">C</div>
|
|
14
|
+
<h1>Claude Chat</h1>
|
|
15
|
+
</div>
|
|
16
|
+
<div style=${{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
|
17
|
+
<button class="theme-toggle" title="切换主题" onClick=${onThemeToggle}>
|
|
18
|
+
<svg class="icon-sun" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
|
19
|
+
<svg class="icon-moon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
|
20
|
+
</button>
|
|
21
|
+
<select class="model-select" value=${currentModel} onChange=${(e) => onModelChange(e.target.value)}>
|
|
22
|
+
${models.map(m => html`<option key=${m} value=${m}>${m}</option>`)}
|
|
23
|
+
</select>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
`;
|
|
27
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UserMessage - 用户消息气泡
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { html } from 'htm/react';
|
|
6
|
+
import { escapeHtml } from '../utils/escape.js';
|
|
7
|
+
|
|
8
|
+
export function UserMessage({ content }) {
|
|
9
|
+
return html`
|
|
10
|
+
<div class="message user">
|
|
11
|
+
<div class="bubble">${escapeHtml(content)}</div>
|
|
12
|
+
</div>
|
|
13
|
+
`;
|
|
14
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WelcomeScreen - 空状态欢迎页
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { html } from 'htm/react';
|
|
6
|
+
|
|
7
|
+
export function WelcomeScreen() {
|
|
8
|
+
return html`
|
|
9
|
+
<div class="welcome">
|
|
10
|
+
<div class="icon">C</div>
|
|
11
|
+
<h2>有什么可以帮你的?</h2>
|
|
12
|
+
<p>支持交互式可视化 · HTML/Mermaid 图表 · 流式响应</p>
|
|
13
|
+
</div>
|
|
14
|
+
`;
|
|
15
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FullscreenOverlay - 全屏覆盖层
|
|
3
|
+
*
|
|
4
|
+
* 用于放大查看 Mermaid 图表或全屏查看 HTML 可视化
|
|
5
|
+
* Escape 键关闭
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { html } from 'htm/react';
|
|
9
|
+
import { useEffect } from 'react';
|
|
10
|
+
|
|
11
|
+
export function FullscreenOverlay({ widget, onClose }) {
|
|
12
|
+
// 全局 Escape 键监听
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
if (!widget) return;
|
|
15
|
+
const handler = (e) => {
|
|
16
|
+
if (e.key === 'Escape') onClose();
|
|
17
|
+
};
|
|
18
|
+
document.addEventListener('keydown', handler);
|
|
19
|
+
return () => document.removeEventListener('keydown', handler);
|
|
20
|
+
}, [widget, onClose]);
|
|
21
|
+
|
|
22
|
+
if (!widget) return null;
|
|
23
|
+
|
|
24
|
+
return html`
|
|
25
|
+
<div class="widget-fullscreen-overlay active">
|
|
26
|
+
<div class="fs-header">
|
|
27
|
+
<div class="left">
|
|
28
|
+
<span style=${{ color: 'var(--accent)', fontSize: '13px', fontWeight: '600' }}>${widget.widgetType === 'mermaid' ? 'diagram' : 'visualize'}</span>
|
|
29
|
+
<span style=${{ color: 'var(--text-muted)', fontSize: '12px' }}>${widget.title}</span>
|
|
30
|
+
</div>
|
|
31
|
+
<button class="fs-close" onClick=${onClose}>✕ 关闭</button>
|
|
32
|
+
</div>
|
|
33
|
+
<div style=${{
|
|
34
|
+
flex: 1,
|
|
35
|
+
overflow: 'auto',
|
|
36
|
+
padding: '24px',
|
|
37
|
+
display: 'flex',
|
|
38
|
+
justifyContent: 'center',
|
|
39
|
+
alignItems: 'flex-start',
|
|
40
|
+
background: 'var(--bg-primary)',
|
|
41
|
+
}} dangerouslySetInnerHTML=${{ __html: widget.content }} />
|
|
42
|
+
</div>
|
|
43
|
+
`;
|
|
44
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HtmlWidget - HTML iframe 可视化组件
|
|
3
|
+
*
|
|
4
|
+
* 通过 sandboxed iframe 实时预览 HTML 内容,
|
|
5
|
+
* 支持流式更新和全屏模式
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { html } from 'htm/react';
|
|
9
|
+
import { useState, useEffect, useRef } from 'react';
|
|
10
|
+
import { WidgetContainer } from './WidgetContainer.jsx';
|
|
11
|
+
|
|
12
|
+
export function HtmlWidget({ html: htmlContent, onFullscreen }) {
|
|
13
|
+
const iframeRef = useRef(null);
|
|
14
|
+
const [iframeReady, setIframeReady] = useState(false);
|
|
15
|
+
const [status, setStatus] = useState('loading');
|
|
16
|
+
|
|
17
|
+
// 初始化 iframe
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
setIframeReady(false);
|
|
20
|
+
setStatus('loading');
|
|
21
|
+
if (iframeRef.current) {
|
|
22
|
+
iframeRef.current.src = 'about:blank';
|
|
23
|
+
}
|
|
24
|
+
}, []);
|
|
25
|
+
|
|
26
|
+
// iframe load 后写入内容
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
const iframe = iframeRef.current;
|
|
29
|
+
if (!iframe) return;
|
|
30
|
+
|
|
31
|
+
const handler = () => {
|
|
32
|
+
setIframeReady(true);
|
|
33
|
+
_writeHtml(iframe, htmlContent);
|
|
34
|
+
setStatus('done');
|
|
35
|
+
|
|
36
|
+
// 调整高度
|
|
37
|
+
try {
|
|
38
|
+
const doc = iframe.contentDocument;
|
|
39
|
+
if (doc && doc.body) {
|
|
40
|
+
const h = Math.max(doc.body.scrollHeight, doc.documentElement.scrollHeight, 300);
|
|
41
|
+
iframe.style.height = Math.min(h + 20, 1200) + 'px';
|
|
42
|
+
}
|
|
43
|
+
} catch (e) {}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
iframe.addEventListener('load', handler);
|
|
47
|
+
return () => iframe.removeEventListener('load', handler);
|
|
48
|
+
}, [htmlContent]);
|
|
49
|
+
|
|
50
|
+
// 追踪内容变化,定时重新写入(支持流式更新)
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!iframeReady || !htmlContent) return;
|
|
53
|
+
const timer = setTimeout(() => {
|
|
54
|
+
_writeHtml(iframeRef.current, htmlContent);
|
|
55
|
+
}, 80);
|
|
56
|
+
return () => clearTimeout(timer);
|
|
57
|
+
}, [htmlContent, iframeReady]);
|
|
58
|
+
|
|
59
|
+
const handleFullscreen = () => {
|
|
60
|
+
if (htmlContent && onFullscreen) {
|
|
61
|
+
onFullscreen(htmlContent);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
return html`
|
|
66
|
+
<${WidgetContainer}
|
|
67
|
+
badge="visualize"
|
|
68
|
+
typeLabel=${status === 'loading' ? '正在生成可视化...' : '交互式可视化'}
|
|
69
|
+
status=${status}
|
|
70
|
+
onZoom=${handleFullscreen}
|
|
71
|
+
>
|
|
72
|
+
<iframe
|
|
73
|
+
ref=${iframeRef}
|
|
74
|
+
sandbox="allow-scripts allow-same-origin"
|
|
75
|
+
style=${{ width: '100%', height: '460px', border: 'none', display: 'block' }}
|
|
76
|
+
/>
|
|
77
|
+
</${WidgetContainer}>
|
|
78
|
+
`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function _writeHtml(iframe, content) {
|
|
82
|
+
try {
|
|
83
|
+
const doc = iframe?.contentDocument;
|
|
84
|
+
if (!doc) return;
|
|
85
|
+
doc.open();
|
|
86
|
+
doc.write(content);
|
|
87
|
+
doc.close();
|
|
88
|
+
} catch (e) {}
|
|
89
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MermaidWidget - Mermaid 图表渲染组件
|
|
3
|
+
*
|
|
4
|
+
* 异步调用 mermaidRenderer.renderMermaid(code),
|
|
5
|
+
* loading → 成功注入 SVG / 显示错误
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { html } from 'htm/react';
|
|
9
|
+
import { useState, useEffect } from 'react';
|
|
10
|
+
import { WidgetContainer } from './WidgetContainer.jsx';
|
|
11
|
+
import { renderMermaid, getChartType } from '../../lib/mermaidRenderer.js';
|
|
12
|
+
|
|
13
|
+
export function MermaidWidget({ code, onZoom }) {
|
|
14
|
+
const [status, setStatus] = useState('loading'); // 'loading' | 'done' | 'error'
|
|
15
|
+
const [svgContent, setSvgContent] = useState('');
|
|
16
|
+
const [error, setError] = useState(null);
|
|
17
|
+
const [chartType, setChartType] = useState('图表');
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
let cancelled = false;
|
|
21
|
+
setStatus('loading');
|
|
22
|
+
setError(null);
|
|
23
|
+
setChartType(getChartType(code));
|
|
24
|
+
|
|
25
|
+
renderMermaid(code).then(result => {
|
|
26
|
+
if (cancelled) return;
|
|
27
|
+
if (result.error) {
|
|
28
|
+
setStatus('error');
|
|
29
|
+
setError(result.error);
|
|
30
|
+
} else {
|
|
31
|
+
setStatus('done');
|
|
32
|
+
setSvgContent(result.svg);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
return () => { cancelled = true; };
|
|
37
|
+
}, [code]);
|
|
38
|
+
|
|
39
|
+
const handleZoom = () => {
|
|
40
|
+
if (svgContent && onZoom) {
|
|
41
|
+
onZoom(svgContent, chartType);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const bodyContent =
|
|
46
|
+
status === 'loading' ? null :
|
|
47
|
+
status === 'error' ?
|
|
48
|
+
html`<div class="widget-error">图表渲染失败: ${error || '未知错误'}</div>` :
|
|
49
|
+
html`<div dangerouslySetInnerHTML=${{ __html: svgContent }} />`;
|
|
50
|
+
|
|
51
|
+
return html`
|
|
52
|
+
<${WidgetContainer}
|
|
53
|
+
badge="diagram"
|
|
54
|
+
typeLabel=${status === 'loading' ? '正在渲染图表...' : chartType}
|
|
55
|
+
status=${status}
|
|
56
|
+
onZoom=${status === 'done' ? handleZoom : null}
|
|
57
|
+
>
|
|
58
|
+
${bodyContent}
|
|
59
|
+
</${WidgetContainer}>
|
|
60
|
+
`;
|
|
61
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SvgWidget - SVG 可视化组件
|
|
3
|
+
*
|
|
4
|
+
* 从 SVG 标记字符串中提取完整 <svg>...</svg> 并渲染,
|
|
5
|
+
* 支持放大查看
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { html } from 'htm/react';
|
|
9
|
+
import { useMemo } from 'react';
|
|
10
|
+
import { WidgetContainer } from './WidgetContainer.jsx';
|
|
11
|
+
|
|
12
|
+
export function SvgWidget({ svg: svgBuffer, onZoom }) {
|
|
13
|
+
const extractedSvg = useMemo(() => {
|
|
14
|
+
return _extractSvg(svgBuffer);
|
|
15
|
+
}, [svgBuffer]);
|
|
16
|
+
|
|
17
|
+
const hasSvg = !!extractedSvg;
|
|
18
|
+
|
|
19
|
+
const handleZoom = () => {
|
|
20
|
+
if (extractedSvg && onZoom) {
|
|
21
|
+
onZoom(extractedSvg);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const bodyContent = hasSvg
|
|
26
|
+
? html`<div dangerouslySetInnerHTML=${{ __html: extractedSvg }} />`
|
|
27
|
+
: html`<div class="widget-error">SVG 内容解析失败</div>`;
|
|
28
|
+
|
|
29
|
+
return html`
|
|
30
|
+
<${WidgetContainer}
|
|
31
|
+
badge="visualize"
|
|
32
|
+
typeLabel="SVG 可视化"
|
|
33
|
+
status=${hasSvg ? 'done' : 'error'}
|
|
34
|
+
onZoom=${hasSvg ? handleZoom : null}
|
|
35
|
+
>
|
|
36
|
+
${bodyContent}
|
|
37
|
+
</${WidgetContainer}>
|
|
38
|
+
`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 从文本中提取完整的 <svg>...</svg> 标签
|
|
43
|
+
*/
|
|
44
|
+
function _extractSvg(text) {
|
|
45
|
+
if (!text) return null;
|
|
46
|
+
const start = text.indexOf('<svg');
|
|
47
|
+
if (start === -1) return null;
|
|
48
|
+
let depth = 0;
|
|
49
|
+
let i = start;
|
|
50
|
+
for (; i < text.length; i++) {
|
|
51
|
+
if (text.substring(i, i + 4) === '<svg') depth++;
|
|
52
|
+
if (text.substring(i, i + 6) === '</svg>') {
|
|
53
|
+
depth--;
|
|
54
|
+
if (depth === 0) { i += 6; break; }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (depth !== 0) return text.substring(start);
|
|
58
|
+
return text.substring(start, i);
|
|
59
|
+
}
|