visualknowledge 0.2.2 → 0.2.4

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.
@@ -1,19 +1,38 @@
1
1
  /**
2
2
  * ChatArea - 滚动容器组件
3
3
  *
4
- * 自动滚动到底部(当 messages 变化或 isStreaming 变化时)
4
+ * 智能自动滚动:仅在用户处于底部附近时自动跟随,
5
+ * 用户向上滚动时保持位置不被拉回。
5
6
  */
6
7
 
7
8
  import { html } from 'htm/react';
8
9
  import { useRef, useEffect } from 'react';
9
10
 
11
+ const STICKY_THRESHOLD = 80;
12
+
10
13
  export function ChatArea({ children }) {
11
14
  const containerRef = useRef(null);
15
+ const userScrolledUp = useRef(false);
16
+
17
+ // 监听用户手动滚动
18
+ useEffect(() => {
19
+ const el = containerRef.current;
20
+ if (!el) return;
21
+
22
+ const onScroll = () => {
23
+ const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
24
+ userScrolledUp.current = distFromBottom > STICKY_THRESHOLD;
25
+ };
26
+
27
+ el.addEventListener('scroll', onScroll, { passive: true });
28
+ return () => el.removeEventListener('scroll', onScroll);
29
+ }, []);
12
30
 
31
+ // 内容变化时:仅在用户没有主动上滑时自动滚到底部
13
32
  useEffect(() => {
14
- if (containerRef.current) {
15
- containerRef.current.scrollTop = containerRef.current.scrollHeight;
16
- }
33
+ const el = containerRef.current;
34
+ if (!el || userScrolledUp.current) return;
35
+ el.scrollTop = el.scrollHeight;
17
36
  }, [children]);
18
37
 
19
38
  return html`<div className="chat-area" ref=${containerRef}>${children}</div>`;
@@ -6,72 +6,18 @@
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
- const IFRAME_TIMEOUT_MS = 10000;
13
-
14
12
  export function HtmlWidget({ html: htmlContent, onFullscreen }) {
15
13
  const iframeRef = useRef(null);
16
- const [status, setStatus] = useState('loading');
17
- const timerRef = useRef(null);
18
- const stableContentRef = useRef('');
19
14
 
20
- // 检测内容是否稳定(连续相同内容说明流结束了)
15
+ // 流式写入:内容到达即写入 iframe
21
16
  useEffect(() => {
22
17
  if (!htmlContent) return;
23
-
24
- // 内容变化时重置状态
25
- if (htmlContent !== stableContentRef.current) {
26
- setStatus('loading');
27
- if (timerRef.current) clearTimeout(timerRef.current);
28
- }
29
-
30
- stableContentRef.current = htmlContent;
31
- }, [htmlContent]);
32
-
33
- // iframe 初始化 + 写入内容
34
- useEffect(() => {
35
18
  const iframe = iframeRef.current;
36
- if (!iframe || !htmlContent) return;
37
-
38
- let settled = false;
39
-
40
- const write = () => {
41
- _writeHtml(iframe, htmlContent);
42
- setStatus('done');
43
- settled = true;
44
- if (timerRef.current) clearTimeout(timerRef.current);
45
- };
46
-
47
- // 首次加载或 src 变化时等待 load 事件
48
- const handler = () => {
49
- write();
50
- };
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
-
60
- iframe.addEventListener('load', handler);
61
-
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
- };
19
+ if (!iframe) return;
20
+ _writeHtml(iframe, htmlContent);
75
21
  }, [htmlContent]);
76
22
 
77
23
  const handleFullscreen = () => {
@@ -80,17 +26,19 @@ export function HtmlWidget({ html: htmlContent, onFullscreen }) {
80
26
  }
81
27
  };
82
28
 
29
+ const hasContent = htmlContent && htmlContent.trim().length > 0;
30
+
83
31
  return html`
84
32
  <${WidgetContainer}
85
33
  badge="visualize"
86
- typeLabel=${status === 'loading' ? '正在生成可视化...' : '交互式可视化'}
87
- status=${status}
34
+ typeLabel=${hasContent ? '交互式可视化' : '正在生成可视化...'}
35
+ status=${hasContent ? 'done' : 'loading'}
88
36
  onZoom=${handleFullscreen}
89
37
  >
90
38
  <iframe
91
39
  ref=${iframeRef}
92
40
  sandbox="allow-scripts allow-same-origin"
93
- style=${{ width: '100%', height: '460px', border: 'none', display: 'block' }}
41
+ style=${{ width: '100%', height: hasContent ? undefined : '0px', border: 'none', display: 'block' }}
94
42
  />
95
43
  </${WidgetContainer}>
96
44
  `;
@@ -104,10 +52,10 @@ function _writeHtml(iframe, content) {
104
52
  doc.write(content);
105
53
  doc.close();
106
54
 
107
- // 调整高度
55
+ // 根据实际内容调整高度,随内容增长逐步扩大
108
56
  try {
109
- const h = Math.max(doc.body.scrollHeight, doc.documentElement.scrollHeight, 300);
110
- iframe.style.height = Math.min(h + 20, 1200) + 'px';
57
+ const h = Math.max(doc.body.scrollHeight, doc.documentElement.scrollHeight, 0);
58
+ iframe.style.height = Math.min(h + 16, 1200) + 'px';
111
59
  } catch (_) {}
112
60
  } catch (e) {
113
61
  console.warn('[HtmlWidget] write failed:', e);
@@ -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.2",
3
+ "version": "0.2.4",
4
4
  "description": "Interactive AI Chat with Visualization - one-click launch via npx",
5
5
  "bin": {
6
6
  "visualknowledge": "./bin/visualknowledge.js"
@@ -14,6 +14,11 @@ SKILLS = {
14
14
  "trigger": ["qkv", "query key value", "注意力计算", "注意力权重", "softmax"],
15
15
  "description": "详细展示 Q/K/V 计算过程"
16
16
  },
17
+ "ring_graph": {
18
+ "name": "环形/链表可视化",
19
+ "trigger": ["环形", "链表", "环", "cycle", "ring", "floyd", "判圈", "龟兔赛跑", "快慢指针", "循环链表", "环形链表", "约瑟夫"],
20
+ "description": "展示环形链表、Floyd判圈算法、循环队列等需要闭合回路的图"
21
+ },
17
22
  "generic_pipeline": {
18
23
  "name": "通用流程图",
19
24
  "trigger": ["流程", "步骤", "过程", "pipeline", "workflow"],
@@ -152,8 +157,170 @@ def get_skill_prompt():
152
157
  - **Transformer 架构图** (transformer_architecture): transformer, attention, 自注意力
153
158
  - **神经网络流程图** (neural_network_flow): 神经网络, CNN, 卷积, 池化
154
159
  - **注意力机制详解** (attention_detail): QKV, 注意力权重, softmax
160
+ - **环形/链表可视化** (ring_graph): 环形链表、判圈算法、快慢指针、循环队列
155
161
  - **通用流程图** (generic_pipeline): 流程, 步骤, pipeline
156
162
 
163
+ ---
164
+
165
+ ### ⚠️ 环形/链表可视化规范(ring_graph)
166
+
167
+ 环形链表、判圈算法、循环队列等**必须用内嵌 SVG**,不要用 flex+文字箭头,因为环路无法用 → ↓ 闭合。
168
+
169
+ #### 核心原则
170
+ 1. **节点位置用三角函数计算**:把环上的节点均匀放在圆上,用 `(cx + r*cos(θ), cy + r*sin(θ))` 定位
171
+ 2. **非环节点排成一条直线**(tail 部分水平排列),最后一个非环节点指向环节点
172
+ 3. **箭头用 SVG `<line>` + `<marker>` 箭头标记**
173
+ 4. **整个 SVG 放在一个 HTML 容器 `<div>` 中**,配合标题和图例
174
+
175
+ #### 布局方法
176
+ ```
177
+ tail 节点(水平直线) 环上的节点(圆形排列)
178
+ 3 → 2 → 1 → 5 ← 4
179
+ ↓ ↑
180
+ 6 → 7 → 8
181
+ ```
182
+
183
+ - tail 部分:从左到右水平排列,间距固定
184
+ - 环部分:用圆排列,节点均匀分布在圆周上
185
+ - 连线:SVG `<line>` 元素 + 箭头 marker
186
+
187
+ #### 节点和箭头样式
188
+ ```css
189
+ /* 节点 */
190
+ .node {
191
+ fill: #1e293b; /* 深色填充 */
192
+ stroke: #64748b; /* 边框 */
193
+ stroke-width: 2;
194
+ r: 22; /* 半径 */
195
+ }
196
+ .node-label {
197
+ fill: #f1f5f9; /* 白色文字 */
198
+ font-size: 14px;
199
+ font-weight: bold;
200
+ text-anchor: middle;
201
+ dominant-baseline: central;
202
+ }
203
+
204
+ /* 箭头连线 */
205
+ .edge {
206
+ stroke: #94a3b8;
207
+ stroke-width: 2;
208
+ marker-end: url(#arrow);
209
+ }
210
+
211
+ /* 特殊高亮 */
212
+ .node-entry { fill: #16a34a20; stroke: #16a34a; stroke-width: 3; } /* 环入口 - 绿 */
213
+ .node-meet { fill: #e11d4820; stroke: #e11d48; stroke-width: 3; } /* 相遇点 - 红 */
214
+ .node-slow { stroke: #d97706; stroke-width: 2.5; stroke-dasharray: 5,3; } /* 慢指针 - 橙 */
215
+ .node-fast { stroke: #2563eb; stroke-width: 2.5; stroke-dasharray: 8,4; } /* 快指针 - 蓝 */
216
+ ```
217
+
218
+ #### 高质量示例:Floyd 判圈算法
219
+
220
+ ```html
221
+ <div style="max-width:860px;margin:0 auto;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:inherit;background:transparent;padding:20px">
222
+ <style>
223
+ .ring-legend{display:flex;gap:16px;justify-content:center;flex-wrap:wrap;margin-top:16px;font-size:12px}
224
+ .ring-legend span{display:flex;align-items:center;gap:4px}
225
+ .ring-dot{width:12px;height:12px;border-radius:50%;display:inline-block}
226
+ </style>
227
+
228
+ <h3 style="text-align:center;font-size:16px;margin-bottom:4px">Floyd 判圈算法 — 龟兔赛跑</h3>
229
+ <p style="text-align:center;font-size:12px;opacity:0.5;margin-bottom:16px">slow 走 1 步 · fast 走 2 步 · 必在环内相遇</p>
230
+
231
+ <svg viewBox="0 0 700 380" style="width:100%;max-width:700px;margin:0 auto;display:block">
232
+ <defs>
233
+ <marker id="arrow" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
234
+ <path d="M0,0 L8,3 L0,6" fill="#94a3b8"/>
235
+ </marker>
236
+ <marker id="arrow-green" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
237
+ <path d="M0,0 L8,3 L0,6" fill="#16a34a"/>
238
+ </marker>
239
+ </defs>
240
+
241
+ <!-- Tail 部分 (非环节点) -->
242
+ <circle cx="80" cy="80" r="22" fill="#1e293b" stroke="#64748b" stroke-width="2"/>
243
+ <text x="80" y="80" fill="#f1f5f9" font-size="14" font-weight="bold" text-anchor="middle" dominant-baseline="central">3</text>
244
+ <line x1="102" y1="80" x2="148" y2="80" stroke="#94a3b8" stroke-width="2" marker-end="url(#arrow)"/>
245
+
246
+ <circle cx="170" cy="80" r="22" fill="#1e293b" stroke="#64748b" stroke-width="2"/>
247
+ <text x="170" y="80" fill="#f1f5f9" font-size="14" font-weight="bold" text-anchor="middle" dominant-baseline="central">2</text>
248
+ <line x1="192" y1="80" x2="238" y2="80" stroke="#94a3b8" stroke-width="2" marker-end="url(#arrow)"/>
249
+
250
+ <circle cx="260" cy="80" r="22" fill="#1e293b" stroke="#64748b" stroke-width="2"/>
251
+ <text x="260" y="80" fill="#f1f5f9" font-size="14" font-weight="bold" text-anchor="middle" dominant-baseline="central">1</text>
252
+ <line x1="282" y1="80" x2="418" y2="240" stroke="#94a3b8" stroke-width="2" marker-end="url(#arrow)"/>
253
+
254
+ <!-- Tail → Entry 标注 -->
255
+ <text x="360" y="140" fill="#16a34a" font-size="11" text-anchor="middle">a 步(到入口)</text>
256
+
257
+ <!-- 环上的节点 (圆形排列,圆心 440,260 半径 100) -->
258
+ <!-- 5: θ=270° → (440, 160) -->
259
+ <circle cx="440" cy="160" r="22" fill="#16a34a20" stroke="#16a34a" stroke-width="3"/>
260
+ <text x="440" y="160" fill="#f1f5f9" font-size="14" font-weight="bold" text-anchor="middle" dominant-baseline="central">5</text>
261
+ <text x="440" y="130" fill="#16a34a" font-size="10" text-anchor="middle">入口</text>
262
+
263
+ <!-- 4: θ=342° → (535, 229) -->
264
+ <circle cx="535" cy="229" r="22" fill="#1e293b" stroke="#64748b" stroke-width="2"/>
265
+ <text x="535" y="229" fill="#f1f5f9" font-size="14" font-weight="bold" text-anchor="middle" dominant-baseline="central">4</text>
266
+
267
+ <!-- 6: θ=54° → (499, 341) -->
268
+ <circle cx="499" cy="341" r="22" fill="#1e293b" stroke="#64748b" stroke-width="2"/>
269
+ <text x="499" y="341" fill="#f1f5f9" font-size="14" font-weight="bold" text-anchor="middle" dominant-baseline="central">6</text>
270
+
271
+ <!-- 7: θ=126° → (381, 341) -->
272
+ <circle cx="381" cy="341" r="22" fill="#e11d4820" stroke="#e11d48" stroke-width="3"/>
273
+ <text x="381" y="341" fill="#f1f5f9" font-size="14" font-weight="bold" text-anchor="middle" dominant-baseline="central">7</text>
274
+ <text x="381" y="374" fill="#e11d48" font-size="10" text-anchor="middle">相遇点</text>
275
+
276
+ <!-- 8: θ=198° → (345, 229) -->
277
+ <circle cx="345" cy="229" r="22" fill="#1e293b" stroke="#64748b" stroke-width="2"/>
278
+ <text x="345" y="229" fill="#f1f5f9" font-size="14" font-weight="bold" text-anchor="middle" dominant-baseline="central">8</text>
279
+
280
+ <!-- 环上的箭头连线 (5→4→6→7→8→5) -->
281
+ <line x1="461" y1="152" x2="514" y2="219" stroke="#94a3b8" stroke-width="2" marker-end="url(#arrow)"/>
282
+ <line x1="555" y1="238" x2="518" y2="328" stroke="#94a3b8" stroke-width="2" marker-end="url(#arrow)"/>
283
+ <line x1="489" y1="357" x2="395" y2="353" stroke="#94a3b8" stroke-width="2" marker-end="url(#arrow)"/>
284
+ <line x1="363" y1="333" x2="328" y2="241" stroke="#94a3b8" stroke-width="2" marker-end="url(#arrow)"/>
285
+ <line x1="348" y1="211" x2="418" y2="171" stroke="#94a3b8" stroke-width="2" marker-end="url(#arrow)"/>
286
+
287
+ <!-- 距离标注 -->
288
+ <text x="388" y="310" fill="#94a3b8" font-size="10" text-anchor="middle">b</text>
289
+ <text x="330" y="276" fill="#94a3b8" font-size="10" text-anchor="middle">c</text>
290
+
291
+ <!-- 图例说明 -->
292
+ <text x="440" y="260" fill="#94a3b8" font-size="11" text-anchor="middle" font-style="italic">环长 c = b + 剩余</text>
293
+
294
+ <!-- 指针 -->
295
+ <text x="100" y="110" fill="#d97706" font-size="11" font-weight="bold">🐢 slow</text>
296
+ <text x="100" y="124" fill="#2563eb" font-size="11" font-weight="bold">🐇 fast</text>
297
+ <line x1="140" y1="107" x2="170" y2="90" stroke="#d97706" stroke-width="1.5" stroke-dasharray="4,2"/>
298
+ <line x1="140" y1="120" x2="170" y2="90" stroke="#2563eb" stroke-width="1.5" stroke-dasharray="6,3"/>
299
+ </svg>
300
+
301
+ <div class="ring-legend">
302
+ <span><span class="ring-dot" style="background:#16a34a"></span> 环入口</span>
303
+ <span><span class="ring-dot" style="background:#e11d48"></span> 相遇点</span>
304
+ <span><span class="ring-dot" style="background:#d97706"></span> 🐢 慢指针</span>
305
+ <span><span class="ring-dot" style="background:#2563eb"></span> 🐇 快指针</span>
306
+ </div>
307
+
308
+ <p style="text-align:center;font-size:13px;margin-top:16px;opacity:0.7">
309
+ <strong>关键公式:</strong>a + b = k·c → <strong>a = k·c − b</strong><br/>
310
+ 从起点走 a 步 = 从相遇点走 a 步 → 必在<strong style="color:#16a34a">入口</strong>汇合
311
+ </p>
312
+ </div>
313
+ ```
314
+
315
+ #### 环形图绘制要点(必读)
316
+ 1. **先算坐标再画**:确定圆心 `(cx, cy)` 和半径 `r`,用 `θ = i × 360°/n` 计算每个节点位置
317
+ 2. **箭头要指向圆心方向偏移**:`marker-end` 自动朝向,但起点要从节点边缘出发(不是圆心),终点停在目标节点边缘
318
+ 3. **Tail 节点水平排列**,最后一个 tail 节点画一条指向环节点的箭头线
319
+ 4. **闭合箭头**:最后一个环节点必须画箭头回到第一个环节点(这是关键!)
320
+ 5. **节点数量 ≤ 8**:太多节点看不清,适当合并或简化
321
+ 6. **用 `<text>` 标注距离**(a, b, c)和关键位置(入口、相遇点)
322
+ 7. **viewBox 要足够大**:确保所有节点和标注都在可视区域内
323
+
157
324
  ### 使用规则
158
325
  1. 涉及架构/流程/数据变换/神经网络结构/算法步骤 → **必须用 ```html**
159
326
  2. 简单关系图/时序图/饼图 → 用 ```mermaid