visualknowledge 0.2.1 → 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.
@@ -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
- // 初始化 iframe
20
+ // 检测内容是否稳定(连续相同内容说明流结束了)
18
21
  useEffect(() => {
19
- setIframeReady(false);
20
- setStatus('loading');
21
- if (iframeRef.current) {
22
- iframeRef.current.src = 'about:blank';
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
- // iframe load 后写入内容
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
- const handler = () => {
32
- setIframeReady(true);
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
- 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) {}
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
- useEffect(() => {
52
- if (!iframeReady || !htmlContent) return;
53
- const timer = setTimeout(() => {
54
- _writeHtml(iframeRef.current, htmlContent);
55
- }, 80);
56
- return () => clearTimeout(timer);
57
- }, [htmlContent, iframeReady]);
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
- } catch (e) {}
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 () => { cancelled = true; };
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 [localMessages, setLocalMessages] = useState([]);
18
- const [localStreaming, setLocalStreaming] = useState(false);
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() || localStreaming) return;
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
- setLocalStreaming(true);
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)), // deep clone for React re-render
51
+ segments: JSON.parse(JSON.stringify(segments)),
61
52
  });
62
53
  };
63
54
 
64
55
  try {
65
- // 5. 发起 SSE 请求
66
- const conversationHistory = [...localMessages, userMsg].map(m => ({
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: conversationHistory,
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
- setLocalStreaming(false);
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
- setLocalStreaming(false);
127
+ setIsStreaming(false);
152
128
  processorRef.current = null;
153
- }, [localStreaming, state.currentModel, localMessages, dispatch]);
129
+ }, [isStreaming, state.currentModel, state.messages, dispatch]);
154
130
 
155
131
  return {
156
- messages: localMessages,
157
- isStreaming: localStreaming,
132
+ messages,
133
+ isStreaming,
158
134
  sendMessage,
159
135
  };
160
136
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "visualknowledge",
3
- "version": "0.2.1",
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"
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