visualknowledge 0.2.1 → 0.2.3
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.
|
@@ -6,67 +6,33 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { html } from 'htm/react';
|
|
9
|
-
import {
|
|
9
|
+
import { useEffect, useRef } from 'react';
|
|
10
10
|
import { WidgetContainer } from './WidgetContainer.jsx';
|
|
11
11
|
|
|
12
12
|
export function HtmlWidget({ html: htmlContent, onFullscreen }) {
|
|
13
13
|
const iframeRef = useRef(null);
|
|
14
|
-
const [iframeReady, setIframeReady] = useState(false);
|
|
15
|
-
const [status, setStatus] = useState('loading');
|
|
16
14
|
|
|
17
|
-
//
|
|
18
|
-
useEffect(() => {
|
|
19
|
-
setIframeReady(false);
|
|
20
|
-
setStatus('loading');
|
|
21
|
-
if (iframeRef.current) {
|
|
22
|
-
iframeRef.current.src = 'about:blank';
|
|
23
|
-
}
|
|
24
|
-
}, []);
|
|
25
|
-
|
|
26
|
-
// iframe load 后写入内容
|
|
15
|
+
// 流式写入:内容到达即写入 iframe
|
|
27
16
|
useEffect(() => {
|
|
17
|
+
if (!htmlContent) return;
|
|
28
18
|
const iframe = iframeRef.current;
|
|
29
19
|
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);
|
|
20
|
+
_writeHtml(iframe, htmlContent);
|
|
48
21
|
}, [htmlContent]);
|
|
49
22
|
|
|
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
23
|
const handleFullscreen = () => {
|
|
60
24
|
if (htmlContent && onFullscreen) {
|
|
61
25
|
onFullscreen(htmlContent);
|
|
62
26
|
}
|
|
63
27
|
};
|
|
64
28
|
|
|
29
|
+
const hasContent = htmlContent && htmlContent.trim().length > 0;
|
|
30
|
+
|
|
65
31
|
return html`
|
|
66
32
|
<${WidgetContainer}
|
|
67
33
|
badge="visualize"
|
|
68
|
-
typeLabel=${
|
|
69
|
-
status=${
|
|
34
|
+
typeLabel=${hasContent ? '交互式可视化' : '正在生成可视化...'}
|
|
35
|
+
status=${hasContent ? 'done' : 'loading'}
|
|
70
36
|
onZoom=${handleFullscreen}
|
|
71
37
|
>
|
|
72
38
|
<iframe
|
|
@@ -85,5 +51,13 @@ function _writeHtml(iframe, content) {
|
|
|
85
51
|
doc.open();
|
|
86
52
|
doc.write(content);
|
|
87
53
|
doc.close();
|
|
88
|
-
|
|
54
|
+
|
|
55
|
+
// 调整高度
|
|
56
|
+
try {
|
|
57
|
+
const h = Math.max(doc.body.scrollHeight, doc.documentElement.scrollHeight, 300);
|
|
58
|
+
iframe.style.height = Math.min(h + 20, 1200) + 'px';
|
|
59
|
+
} catch (_) {}
|
|
60
|
+
} catch (e) {
|
|
61
|
+
console.warn('[HtmlWidget] write failed:', e);
|
|
62
|
+
}
|
|
89
63
|
}
|
|
@@ -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
|
}
|
|
@@ -126,6 +126,14 @@ export class StreamProcessor {
|
|
|
126
126
|
// 返回 segments 的快照,包含当前活跃段
|
|
127
127
|
const result = [...this._segments];
|
|
128
128
|
|
|
129
|
+
// 更新活跃的 codeblock segment,将流式缓冲区内容暴露给组件
|
|
130
|
+
if (this._phase === 'codeblock') {
|
|
131
|
+
const lastSeg = result[result.length - 1];
|
|
132
|
+
if (lastSeg && (lastSeg.type === 'mermaid' || lastSeg.type === 'html' || lastSeg.type === 'svg')) {
|
|
133
|
+
lastSeg.content = this._buffer;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
129
137
|
// 如果有活跃的文字段且不在 segments 中,追加它
|
|
130
138
|
if (this._phase === 'text' && this._activeText) {
|
|
131
139
|
// 检查是否已有未完成的 text segment(最后一个 segment 可能是待更新的 text)
|
package/package.json
CHANGED
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
|
|