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 { useState, useEffect, useRef } from 'react';
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
- // 初始化 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 后写入内容
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=${status === 'loading' ? '正在生成可视化...' : '交互式可视化'}
69
- status=${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
- } catch (e) {}
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 () => { 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
  }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "visualknowledge",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
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