visualknowledge 0.2.0 → 0.2.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/src/App.jsx
CHANGED
|
@@ -9,52 +9,70 @@ import { html } from 'htm/react';
|
|
|
9
9
|
import { useState, useEffect, useRef } from 'react';
|
|
10
10
|
import { WidgetContainer } from './WidgetContainer.jsx';
|
|
11
11
|
|
|
12
|
+
const IFRAME_TIMEOUT_MS = 10000;
|
|
13
|
+
|
|
12
14
|
export function HtmlWidget({ html: htmlContent, onFullscreen }) {
|
|
13
15
|
const iframeRef = useRef(null);
|
|
14
|
-
const [iframeReady, setIframeReady] = useState(false);
|
|
15
16
|
const [status, setStatus] = useState('loading');
|
|
17
|
+
const timerRef = useRef(null);
|
|
18
|
+
const stableContentRef = useRef('');
|
|
16
19
|
|
|
17
|
-
//
|
|
20
|
+
// 检测内容是否稳定(连续相同内容说明流结束了)
|
|
18
21
|
useEffect(() => {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
22
|
+
if (!htmlContent) return;
|
|
23
|
+
|
|
24
|
+
// 内容变化时重置状态
|
|
25
|
+
if (htmlContent !== stableContentRef.current) {
|
|
26
|
+
setStatus('loading');
|
|
27
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
23
28
|
}
|
|
24
|
-
}, []);
|
|
25
29
|
|
|
26
|
-
|
|
30
|
+
stableContentRef.current = htmlContent;
|
|
31
|
+
}, [htmlContent]);
|
|
32
|
+
|
|
33
|
+
// iframe 初始化 + 写入内容
|
|
27
34
|
useEffect(() => {
|
|
28
35
|
const iframe = iframeRef.current;
|
|
29
|
-
if (!iframe) return;
|
|
36
|
+
if (!iframe || !htmlContent) return;
|
|
30
37
|
|
|
31
|
-
|
|
32
|
-
|
|
38
|
+
let settled = false;
|
|
39
|
+
|
|
40
|
+
const write = () => {
|
|
33
41
|
_writeHtml(iframe, htmlContent);
|
|
34
42
|
setStatus('done');
|
|
43
|
+
settled = true;
|
|
44
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
45
|
+
};
|
|
35
46
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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) {}
|
|
47
|
+
// 首次加载或 src 变化时等待 load 事件
|
|
48
|
+
const handler = () => {
|
|
49
|
+
write();
|
|
44
50
|
};
|
|
45
51
|
|
|
52
|
+
// 超时保护:即使 load 事件没触发也强制写入
|
|
53
|
+
timerRef.current = setTimeout(() => {
|
|
54
|
+
if (!settled) {
|
|
55
|
+
console.warn('[HtmlWidget] iframe load timeout, forcing write');
|
|
56
|
+
write();
|
|
57
|
+
}
|
|
58
|
+
}, IFRAME_TIMEOUT_MS);
|
|
59
|
+
|
|
46
60
|
iframe.addEventListener('load', handler);
|
|
47
|
-
return () => iframe.removeEventListener('load', handler);
|
|
48
|
-
}, [htmlContent]);
|
|
49
61
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
|
|
62
|
+
// 如果 iframe 已经加载完成,直接写入
|
|
63
|
+
try {
|
|
64
|
+
if (iframe.contentDocument && iframe.contentDocument.readyState === 'complete') {
|
|
65
|
+
write();
|
|
66
|
+
}
|
|
67
|
+
} catch (_) {
|
|
68
|
+
// cross-origin or not ready yet, wait for load event
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return () => {
|
|
72
|
+
iframe.removeEventListener('load', handler);
|
|
73
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
74
|
+
};
|
|
75
|
+
}, [htmlContent]);
|
|
58
76
|
|
|
59
77
|
const handleFullscreen = () => {
|
|
60
78
|
if (htmlContent && onFullscreen) {
|
|
@@ -85,5 +103,13 @@ function _writeHtml(iframe, content) {
|
|
|
85
103
|
doc.open();
|
|
86
104
|
doc.write(content);
|
|
87
105
|
doc.close();
|
|
88
|
-
|
|
106
|
+
|
|
107
|
+
// 调整高度
|
|
108
|
+
try {
|
|
109
|
+
const h = Math.max(doc.body.scrollHeight, doc.documentElement.scrollHeight, 300);
|
|
110
|
+
iframe.style.height = Math.min(h + 20, 1200) + 'px';
|
|
111
|
+
} catch (_) {}
|
|
112
|
+
} catch (e) {
|
|
113
|
+
console.warn('[HtmlWidget] write failed:', e);
|
|
114
|
+
}
|
|
89
115
|
}
|
|
@@ -6,24 +6,37 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { html } from 'htm/react';
|
|
9
|
-
import { useState, useEffect } from 'react';
|
|
9
|
+
import { useState, useEffect, useRef } from 'react';
|
|
10
10
|
import { WidgetContainer } from './WidgetContainer.jsx';
|
|
11
11
|
import { renderMermaid, getChartType } from '../../lib/mermaidRenderer.js';
|
|
12
12
|
|
|
13
|
+
const RENDER_TIMEOUT_MS = 15000;
|
|
14
|
+
|
|
13
15
|
export function MermaidWidget({ code, onZoom }) {
|
|
14
16
|
const [status, setStatus] = useState('loading'); // 'loading' | 'done' | 'error'
|
|
15
17
|
const [svgContent, setSvgContent] = useState('');
|
|
16
18
|
const [error, setError] = useState(null);
|
|
17
19
|
const [chartType, setChartType] = useState('图表');
|
|
20
|
+
const timerRef = useRef(null);
|
|
18
21
|
|
|
19
22
|
useEffect(() => {
|
|
20
23
|
let cancelled = false;
|
|
21
24
|
setStatus('loading');
|
|
22
25
|
setError(null);
|
|
26
|
+
setSvgContent('');
|
|
23
27
|
setChartType(getChartType(code));
|
|
24
28
|
|
|
29
|
+
// 超时保护
|
|
30
|
+
timerRef.current = setTimeout(() => {
|
|
31
|
+
if (!cancelled) {
|
|
32
|
+
setStatus('error');
|
|
33
|
+
setError('渲染超时(' + (RENDER_TIMEOUT_MS / 1000) + 's),可能是图表过于复杂');
|
|
34
|
+
}
|
|
35
|
+
}, RENDER_TIMEOUT_MS);
|
|
36
|
+
|
|
25
37
|
renderMermaid(code).then(result => {
|
|
26
38
|
if (cancelled) return;
|
|
39
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
27
40
|
if (result.error) {
|
|
28
41
|
setStatus('error');
|
|
29
42
|
setError(result.error);
|
|
@@ -31,9 +44,18 @@ export function MermaidWidget({ code, onZoom }) {
|
|
|
31
44
|
setStatus('done');
|
|
32
45
|
setSvgContent(result.svg);
|
|
33
46
|
}
|
|
47
|
+
}).catch(err => {
|
|
48
|
+
if (cancelled) return;
|
|
49
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
50
|
+
console.error('[MermaidWidget] Render failed:', err);
|
|
51
|
+
setStatus('error');
|
|
52
|
+
setError(err.message || String(err));
|
|
34
53
|
});
|
|
35
54
|
|
|
36
|
-
return () => {
|
|
55
|
+
return () => {
|
|
56
|
+
cancelled = true;
|
|
57
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
58
|
+
};
|
|
37
59
|
}, [code]);
|
|
38
60
|
|
|
39
61
|
const handleZoom = () => {
|
|
@@ -14,32 +14,23 @@ import { StreamProcessor } from '../lib/streamProcessor.js';
|
|
|
14
14
|
export function useChat() {
|
|
15
15
|
const { state, dispatch } = useAppState();
|
|
16
16
|
const processorRef = useRef(null);
|
|
17
|
-
const [
|
|
18
|
-
|
|
17
|
+
const [isStreaming, setIsStreaming] = useState(false);
|
|
18
|
+
// 从 context 读取消息(context 在流式过程中会增量更新)
|
|
19
|
+
const messages = state.messages;
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* 发送消息并启动流式接收
|
|
22
23
|
*/
|
|
23
24
|
const sendMessage = useCallback(async (text) => {
|
|
24
|
-
if (!text.trim() ||
|
|
25
|
-
|
|
26
|
-
// 1.
|
|
27
|
-
const userMsg = {
|
|
28
|
-
id: `msg_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`,
|
|
29
|
-
role: 'user',
|
|
30
|
-
content: text,
|
|
31
|
-
segments: [{ id: `seg_txt_user_${Date.now()}`, type: 'text', content: text, renderedContent: null, renderStatus: 'done', renderError: null }],
|
|
32
|
-
status: 'complete',
|
|
33
|
-
error: null,
|
|
34
|
-
timestamp: Date.now(),
|
|
35
|
-
};
|
|
25
|
+
if (!text.trim() || isStreaming) return;
|
|
26
|
+
|
|
27
|
+
// 1. 添加用户消息到 context
|
|
36
28
|
dispatch({ type: ActionTypes.ADD_USER_MESSAGE, content: text });
|
|
37
|
-
setLocalMessages(prev => [...prev, userMsg]);
|
|
38
29
|
|
|
39
30
|
// 2. 创建 AI 消息占位
|
|
40
31
|
const assistantId = `msg_ast_${Date.now()}`;
|
|
41
32
|
dispatch({ type: ActionTypes.START_ASSISTANT_MESSAGE, messageId: assistantId });
|
|
42
|
-
|
|
33
|
+
setIsStreaming(true);
|
|
43
34
|
|
|
44
35
|
// 3. 初始化 StreamProcessor
|
|
45
36
|
const processor = new StreamProcessor();
|
|
@@ -57,13 +48,13 @@ export function useChat() {
|
|
|
57
48
|
dispatch({
|
|
58
49
|
type: ActionTypes.FINALIZE_SEGMENTS,
|
|
59
50
|
messageId: assistantId,
|
|
60
|
-
segments: JSON.parse(JSON.stringify(segments)),
|
|
51
|
+
segments: JSON.parse(JSON.stringify(segments)),
|
|
61
52
|
});
|
|
62
53
|
};
|
|
63
54
|
|
|
64
55
|
try {
|
|
65
|
-
// 5.
|
|
66
|
-
const
|
|
56
|
+
// 5. 构建对话历史(从 context 已有消息 + 当前用户消息)
|
|
57
|
+
const existingMsgs = state.messages.map(m => ({
|
|
67
58
|
role: m.role,
|
|
68
59
|
content: m.content,
|
|
69
60
|
}));
|
|
@@ -72,7 +63,7 @@ export function useChat() {
|
|
|
72
63
|
method: 'POST',
|
|
73
64
|
headers: { 'Content-Type': 'application/json' },
|
|
74
65
|
body: JSON.stringify({
|
|
75
|
-
messages:
|
|
66
|
+
messages: [...existingMsgs, { role: 'user', content: text }],
|
|
76
67
|
model: state.currentModel || undefined,
|
|
77
68
|
}),
|
|
78
69
|
});
|
|
@@ -109,7 +100,7 @@ export function useChat() {
|
|
|
109
100
|
messageId: assistantId,
|
|
110
101
|
error: event.message || 'Unknown error',
|
|
111
102
|
});
|
|
112
|
-
|
|
103
|
+
setIsStreaming(false);
|
|
113
104
|
return;
|
|
114
105
|
}
|
|
115
106
|
}
|
|
@@ -124,21 +115,6 @@ export function useChat() {
|
|
|
124
115
|
});
|
|
125
116
|
dispatch({ type: 'COMPLETE_STREAM', messageId: assistantId });
|
|
126
117
|
|
|
127
|
-
// 更新本地 messages 列表
|
|
128
|
-
const finalText = finalSegments
|
|
129
|
-
.filter(s => s.type === 'text')
|
|
130
|
-
.map(s => s.content)
|
|
131
|
-
.join('');
|
|
132
|
-
setLocalMessages(prev => [...prev, {
|
|
133
|
-
id: assistantId,
|
|
134
|
-
role: 'assistant',
|
|
135
|
-
content: finalText,
|
|
136
|
-
segments: finalSegments,
|
|
137
|
-
status: 'complete',
|
|
138
|
-
error: null,
|
|
139
|
-
timestamp: Date.now(),
|
|
140
|
-
}]);
|
|
141
|
-
|
|
142
118
|
} catch (err) {
|
|
143
119
|
console.error('Chat error:', err);
|
|
144
120
|
dispatch({
|
|
@@ -148,13 +124,13 @@ export function useChat() {
|
|
|
148
124
|
});
|
|
149
125
|
}
|
|
150
126
|
|
|
151
|
-
|
|
127
|
+
setIsStreaming(false);
|
|
152
128
|
processorRef.current = null;
|
|
153
|
-
}, [
|
|
129
|
+
}, [isStreaming, state.currentModel, state.messages, dispatch]);
|
|
154
130
|
|
|
155
131
|
return {
|
|
156
|
-
messages
|
|
157
|
-
isStreaming
|
|
132
|
+
messages,
|
|
133
|
+
isStreaming,
|
|
158
134
|
sendMessage,
|
|
159
135
|
};
|
|
160
136
|
}
|
package/frontend/src/main.js
CHANGED
|
@@ -9,9 +9,55 @@ import { createRoot } from 'react-dom/client';
|
|
|
9
9
|
import { App } from './App.jsx';
|
|
10
10
|
import { AppProvider } from './context/AppContext.jsx';
|
|
11
11
|
|
|
12
|
+
// ====== Error Boundary ======
|
|
13
|
+
|
|
14
|
+
class ErrorBoundary extends React.Component {
|
|
15
|
+
constructor(props) {
|
|
16
|
+
super(props);
|
|
17
|
+
this.state = { error: null, errorInfo: null };
|
|
18
|
+
}
|
|
19
|
+
static getDerivedStateFromError(error) {
|
|
20
|
+
return { error };
|
|
21
|
+
}
|
|
22
|
+
componentDidCatch(error, errorInfo) {
|
|
23
|
+
console.error('[ErrorBoundary] React render error:', error, errorInfo);
|
|
24
|
+
this.setState({ error, errorInfo });
|
|
25
|
+
}
|
|
26
|
+
render() {
|
|
27
|
+
if (this.state.error) {
|
|
28
|
+
return React.createElement('div', {
|
|
29
|
+
style: {
|
|
30
|
+
padding: '24px', color: '#f87171', fontFamily: 'monospace',
|
|
31
|
+
fontSize: '14px', lineHeight: '1.6', whiteSpace: 'pre-wrap',
|
|
32
|
+
overflow: 'auto', height: '100vh', background: '#1c1917',
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
React.createElement('h2', null, 'React Rendering Error'),
|
|
36
|
+
React.createElement('p', null, this.state.error.toString()),
|
|
37
|
+
React.createElement('pre', null, this.state.errorInfo?.componentStack || ''),
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return this.props.children;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ====== Bootstrap ======
|
|
45
|
+
|
|
46
|
+
console.log('[main] Loading app...');
|
|
47
|
+
|
|
12
48
|
const rootEl = document.getElementById('root');
|
|
13
49
|
if (!rootEl) {
|
|
14
50
|
throw new Error('Root element #root not found in DOM');
|
|
15
51
|
}
|
|
16
52
|
|
|
17
|
-
|
|
53
|
+
console.log('[main] Root element found, mounting React...');
|
|
54
|
+
|
|
55
|
+
createRoot(rootEl).render(
|
|
56
|
+
React.createElement(ErrorBoundary, null,
|
|
57
|
+
React.createElement(AppProvider, null,
|
|
58
|
+
React.createElement(App)
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
console.log('[main] App mounted successfully.');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "visualknowledge",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Interactive AI Chat with Visualization - one-click launch via npx",
|
|
5
5
|
"bin": {
|
|
6
6
|
"visualknowledge": "./bin/visualknowledge.js"
|
|
@@ -26,4 +26,4 @@
|
|
|
26
26
|
"type": "git",
|
|
27
27
|
"url": "https://github.com/user/VisualKnowledge"
|
|
28
28
|
}
|
|
29
|
-
}
|
|
29
|
+
}
|
package/server.py
CHANGED
|
@@ -76,13 +76,15 @@ def static_files(path):
|
|
|
76
76
|
|
|
77
77
|
@app.route('/api/models', methods=['GET'])
|
|
78
78
|
def get_models():
|
|
79
|
+
all_models = list(dict.fromkeys([
|
|
80
|
+
MODEL,
|
|
81
|
+
os.environ.get('ANTHROPIC_DEFAULT_SONNET_MODEL', ''),
|
|
82
|
+
os.environ.get('ANTHROPIC_DEFAULT_OPUS_MODEL', ''),
|
|
83
|
+
os.environ.get('ANTHROPIC_DEFAULT_HAIKU_MODEL', ''),
|
|
84
|
+
]))
|
|
79
85
|
models = {
|
|
80
86
|
'current': MODEL,
|
|
81
|
-
'available': [
|
|
82
|
-
MODEL,
|
|
83
|
-
os.environ.get('ANTHROPIC_DEFAULT_HAIKU_MODEL', 'GLM-4.5-air'),
|
|
84
|
-
os.environ.get('ANTHROPIC_DEFAULT_OPUS_MODEL', 'GLM-5.1'),
|
|
85
|
-
]
|
|
87
|
+
'available': [m for m in all_models if m],
|
|
86
88
|
}
|
|
87
89
|
return jsonify(models)
|
|
88
90
|
|