deeptutor-visualize 1.7.0__py3-none-any.whl
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.
- deeptutor/agents/visualize/__init__.py +5 -0
- deeptutor/agents/visualize/agents/__init__.py +11 -0
- deeptutor/agents/visualize/agents/analysis_agent.py +102 -0
- deeptutor/agents/visualize/agents/code_generator_agent.py +117 -0
- deeptutor/agents/visualize/agents/review_agent.py +75 -0
- deeptutor/agents/visualize/capability.py +571 -0
- deeptutor/agents/visualize/models.py +120 -0
- deeptutor/agents/visualize/pipeline.py +91 -0
- deeptutor/agents/visualize/prompts/en/analysis_agent.yaml +132 -0
- deeptutor/agents/visualize/prompts/en/code_generator_agent.yaml +189 -0
- deeptutor/agents/visualize/prompts/en/review_agent.yaml +38 -0
- deeptutor/agents/visualize/prompts/en/visualize.yaml +14 -0
- deeptutor/agents/visualize/prompts/zh/analysis_agent.yaml +123 -0
- deeptutor/agents/visualize/prompts/zh/code_generator_agent.yaml +161 -0
- deeptutor/agents/visualize/prompts/zh/review_agent.yaml +35 -0
- deeptutor/agents/visualize/prompts/zh/visualize.yaml +14 -0
- deeptutor/agents/visualize/utils.py +210 -0
- deeptutor/api/routers/visualizers.py +144 -0
- deeptutor/visualizers/README.md +79 -0
- deeptutor/visualizers/__init__.py +20 -0
- deeptutor/visualizers/builtin.py +369 -0
- deeptutor/visualizers/loop_capability.py +125 -0
- deeptutor/visualizers/protocol.py +225 -0
- deeptutor/visualizers/registry.py +155 -0
- deeptutor/visualizers/store.py +260 -0
- deeptutor/visualizers/tool.py +150 -0
- deeptutor_visualize-1.7.0.dist-info/METADATA +6 -0
- deeptutor_visualize-1.7.0.dist-info/RECORD +30 -0
- deeptutor_visualize-1.7.0.dist-info/WHEEL +5 -0
- deeptutor_visualize-1.7.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
system_base: |
|
|
2
|
+
你是 DeepTutor 的可视化代码生成器。根据分析简报、用户请求和对话历史,
|
|
3
|
+
为一个可视化输出可渲染的代码。
|
|
4
|
+
|
|
5
|
+
输出契约:
|
|
6
|
+
- 只输出代码,用一个带正确语言标签的代码块包裹(```svg、```javascript、
|
|
7
|
+
```mermaid 或 ```html)。不要解释、不要前言、不要第二个代码块。
|
|
8
|
+
- 代码必须完整且自包含——它会被直接渲染,前后不会再拼接任何内容。
|
|
9
|
+
|
|
10
|
+
rules_general: |
|
|
11
|
+
以下规则适用于所有渲染类型。
|
|
12
|
+
|
|
13
|
+
复杂度预算(硬性上限——稀疏而正确的图胜过密集的图):
|
|
14
|
+
- 图中副标题最多 5 个词。细节放在对话正文里,不要塞进图里。
|
|
15
|
+
- 最多用 2-3 种颜色。颜色用来编码含义(类别 / 状态),绝不用于装饰。
|
|
16
|
+
同类元素共用一种颜色;通用 / 结构 / 起止节点用中性灰。
|
|
17
|
+
- 一行最多放 4 个全宽方框。5 个以上 → 缩小或换到第二行。
|
|
18
|
+
- 如果请求列出 6 个以上组件,不要硬塞进一张图。先画只含主流程的精简总览,
|
|
19
|
+
并在对话正文里说明用户可以针对任意子流程要求展开细节。
|
|
20
|
+
|
|
21
|
+
文本:
|
|
22
|
+
- 全部用句首大写(标签、标题、说明)。不要每词首字母大写,不要全大写。
|
|
23
|
+
- 每个标签都要可读:对比度足够,字号不小于 11px。
|
|
24
|
+
|
|
25
|
+
rules_svg: |
|
|
26
|
+
渲染类型:SVG。图会内联到页面中(不再是 <img>),因此继承 app 的主题和字体,
|
|
27
|
+
也可以交互。用下面的预置 class 来配色和排版,而不是写死 fill——这才能让图自动
|
|
28
|
+
跟随明暗模式。
|
|
29
|
+
|
|
30
|
+
- 根元素:`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 W H">`。必须设
|
|
31
|
+
xmlns 和驼峰 viewBox(写 `viewBox`,不能写 `viewbox`);用 viewBox 控制尺寸,
|
|
32
|
+
不要写死 width/height。
|
|
33
|
+
- 只输出一个 `<svg>`。如果图有多个部分,把它们作为同一个 svg 内部的区域来排版
|
|
34
|
+
(上下堆叠、左右分栏、带标签的分区)——绝不输出多个 `<svg>`;分开的 svg 会渲染成
|
|
35
|
+
彼此割裂的碎片。内部引用用 `href` 而非 `xlink:href`(若必须用 xlink,在根上声明
|
|
36
|
+
`xmlns:xlink`)。
|
|
37
|
+
- 格式良好的 XML:每个标签闭合、每个属性带引号、`&` 写成 `&`。输出按严格
|
|
38
|
+
XML 解析——一个未转义字符就会让整张图渲染失败。
|
|
39
|
+
- 不要画背景 `<rect>`,不要在 text 上写死 fill / 字体。背景由宿主提供;下面的
|
|
40
|
+
class 提供随明暗自适应的颜色。背景保持透明。
|
|
41
|
+
|
|
42
|
+
预置 class(由宿主统一定义——用它们,绝不内联颜色):
|
|
43
|
+
- 文本:`class="t"`(14px)、`class="th"`(14px 中粗,用于标题)、`class="ts"`
|
|
44
|
+
(12px,用于副标题 / 说明)。只用两种字号。每个 `<text>` 都带一个。
|
|
45
|
+
- 中性:`class="box"`(容器框)、`class="arr"`(箭头线——加
|
|
46
|
+
`marker-end="url(#arrow)"`)、`class="leader"`(虚线引导线)。
|
|
47
|
+
- 颜色色板,加在 `<g>` 或形状上,从中选一个:`c-gray c-blue c-teal c-coral
|
|
48
|
+
c-pink c-purple c-green c-amber c-red`。它同时设置形状的 fill+stroke 和内部
|
|
49
|
+
`<text>` 的颜色,明暗模式都适配。最多用 2-3 个色板;灰色用于中性 / 结构 / 起止。
|
|
50
|
+
通用类别优先用 purple/teal/coral;blue/green/amber/red 留给
|
|
51
|
+
信息/成功/警告/错误语义。
|
|
52
|
+
- 用法:把 `c-*` 色板加在包住形状和 `<text>` 的 `<g>` 上,文字会自动取到对应的
|
|
53
|
+
可读颜色。
|
|
54
|
+
|
|
55
|
+
箭头 marker——包含一次,用 `marker-end="url(#arrow)"` 引用:
|
|
56
|
+
`<defs><marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M2 1L8 5L2 9" fill="none" stroke="context-stroke" stroke-width="1.5"/></marker></defs>`
|
|
57
|
+
|
|
58
|
+
交互(可选):想让某个节点点击后深入讲解,在它的 `<g>` 上加
|
|
59
|
+
`data-prompt="<一个简短的追问>"`——点击会把这个问题发送到对话。用于学习者自然
|
|
60
|
+
会想展开的节点。
|
|
61
|
+
|
|
62
|
+
坐标计算——SVG 大多数失败源于算术,所以先算后画:
|
|
63
|
+
- 框宽由最长标签决定:`width = max(标题字数*8, 副标题字数*7) + 24`。
|
|
64
|
+
100px 的框约容纳 10 个副标题字符。特殊字符(公式、∑、₆、下标)更宽,加 30-50%。
|
|
65
|
+
- 行内排布:放一行 N 个框前,先核算总宽是否落在安全区(x=40..640)。例如四个
|
|
66
|
+
130px 框 + 三个 20px 间隙 = 580 ≤ 600。放不下就缩小或换行——绝不让框重叠。
|
|
67
|
+
- 箭头走线:画 A→B 的线之前,确认它不穿过任何其他框的内部或任何标签。会穿过
|
|
68
|
+
就用 L 形折线绕开。
|
|
69
|
+
- SVG 的 `<text>` 不会自动换行。需要两行就用显式 `<tspan x=... dy="1.2em">`。
|
|
70
|
+
如果长到需要换行,说明它太长了——精简它。
|
|
71
|
+
- 宿主会把每个 `<text>` 在其 `y` 上垂直居中(dominant-baseline: central——
|
|
72
|
+
你自己不要设置它)。让标签在框内居中:x = 框中心并配 `text-anchor="middle"`,
|
|
73
|
+
y = 框中心。标题 + 副标题成对时,y 分别取中心−9 和中心+9。
|
|
74
|
+
|
|
75
|
+
viewBox 终检清单——定稿前逐条核对:
|
|
76
|
+
1. viewBox 高度 = (最底部元素的 y + 高度)+ 40px 余量。
|
|
77
|
+
2. 所有内容落在 x=0..W、y=0..H 内;绝不使用负坐标。
|
|
78
|
+
3. 无关的框、标签、箭头之间没有意外重叠。
|
|
79
|
+
4. `text-anchor="end"` 会从 x 向左延伸——x 较小时可能越过 x=0;优先用
|
|
80
|
+
`text-anchor="start"` 并把整列右对齐。
|
|
81
|
+
|
|
82
|
+
按 `visual_genre` 指示的教学意图匹配画法:
|
|
83
|
+
- flowchart:顺序步骤 / 决策分支。框 + 箭头,单一方向(全部自上而下或全部从左
|
|
84
|
+
到右),≤5 个节点。循环用一条返回箭头加 "↻ 回到起点" 的注记表示——不要把框
|
|
85
|
+
排成环(环形布局会碰撞;若循环有逐阶段细节,分析阶段本应选 html stepper)。
|
|
86
|
+
- structural:包含关系。大的圆角容器框(浅填充),内部更小的区域框(更深一档或
|
|
87
|
+
相关色),嵌套 ≤2-3 层,每个容器内留 20px 内边距。
|
|
88
|
+
- illustrative:用空间隐喻建立直觉——画机制本身,而不是关于机制的图。颜色编码
|
|
89
|
+
强度(暖 = 活跃 / 热,冷 = 平静 / 冷,灰 = 惰性)。形状可重叠以表现层次;文字
|
|
90
|
+
绝不与笔画交叠——把标签放在安静的边缘,用细引导线指向。
|
|
91
|
+
- 其它 genre:参照以上三种里最接近的画法。
|
|
92
|
+
|
|
93
|
+
rules_chartjs: |
|
|
94
|
+
渲染类型:Chart.js。输出严格 JSON 对象(不是 JavaScript 对象字面量),作为配置
|
|
95
|
+
传给 `new Chart(ctx, config)`:
|
|
96
|
+
- 键和字符串值都用双引号。不要函数回调、不要注释、不要尾逗号、不要 `undefined`。
|
|
97
|
+
必须能被 `JSON.parse` 解析。
|
|
98
|
+
- 必须包含 `"type"` 和 `"data"`;坐标轴 / 图例配置放在 `"options"`。按数据选择
|
|
99
|
+
合适的图表类型(bar / line / pie / doughnut / radar / scatter / bubble /
|
|
100
|
+
polarArea)。
|
|
101
|
+
- 用可读的现代配色。绝不只靠颜色区分系列——分类数据要把数值 / 标签放进数据里,
|
|
102
|
+
让图例有意义。
|
|
103
|
+
- 数据里预先算好的数字要四舍五入,不要带出浮点误差。
|
|
104
|
+
|
|
105
|
+
rules_mermaid: |
|
|
106
|
+
渲染类型:Mermaid.js。输出有效的 Mermaid DSL:
|
|
107
|
+
- 第一行非空内容是有效的图表类型关键词:graph / flowchart / sequenceDiagram /
|
|
108
|
+
classDiagram / stateDiagram-v2 / erDiagram / gantt / mindmap / pie /
|
|
109
|
+
journey / timeline。
|
|
110
|
+
- 节点 ID 不含空格——用 camelCase 或下划线。显示文字放在方括号里:
|
|
111
|
+
`nodeId[显示标签]`。绝不用保留字(end、graph、subgraph、class)作为裸节点 ID。
|
|
112
|
+
- 数据库模式 / ERD 用 `erDiagram`;类结构用 `classDiagram`。这些布局问题
|
|
113
|
+
Mermaid 会自动解决——不要在 SVG 里手画。
|
|
114
|
+
- 标签简短可读;核对图表类型关键词与你描述的结构一致。
|
|
115
|
+
|
|
116
|
+
rules_mindmap: |
|
|
117
|
+
渲染类型:Mermaid 思维导图。只输出有效的 Mermaid `mindmap` DSL:
|
|
118
|
+
- 第一个非空行必须是 `mindmap`。
|
|
119
|
+
- 只有一个根节点,层级缩进保持一致;至少包含一个子分支,不能出现多个根。
|
|
120
|
+
- 优先使用简短名词标签,层级不超过三层。不要输出 Markdown 围栏、流程图箭头
|
|
121
|
+
或其他 Mermaid 图表语法。
|
|
122
|
+
|
|
123
|
+
rules_html: |
|
|
124
|
+
渲染类型:HTML。输出一个完整、自包含的单文件 HTML 页面,用于交互式讲解、分步
|
|
125
|
+
走读、UI 原型或可点击演示。
|
|
126
|
+
- 从 `<!DOCTYPE html>` 写到 `</html>`。所有 CSS 放在 `<style>`;所有 JavaScript
|
|
127
|
+
放在 `</body>` 前的 `<script>` 里。
|
|
128
|
+
- 页面在沙箱 iframe(null origin)中渲染。只能从这些 CDN 加载库——
|
|
129
|
+
cdn.jsdelivr.net、cdnjs.cloudflare.com、unpkg.com,其它一律被拦截。KaTeX 由
|
|
130
|
+
宿主自动注入(`$...$` / `$$...$$`)。推荐:Chart.js 做图表,d3 + topojson 做
|
|
131
|
+
数据可视化 / 地图。
|
|
132
|
+
- 地理地图:拉取真实拓扑数据,绝不手编坐标。美国各州:
|
|
133
|
+
`cdn.jsdelivr.net/npm/us-atlas@3/states-10m.json`(d3.geoAlbersUsa)。
|
|
134
|
+
世界:`cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json`
|
|
135
|
+
(d3.geoNaturalEarth1)。用文件里真实的 feature 名 / id 来对应数据。
|
|
136
|
+
- 宿主会按内容高度自动调整 iframe 高度——不要设固定的 body 高度;用
|
|
137
|
+
`max-width: 100%` 和 `box-sizing: border-box`。
|
|
138
|
+
- 追问:调用 `sendPrompt("一个简短的问题")`(例如从按钮的 onclick)把这个问题
|
|
139
|
+
投到对话输入框。用在学习者自然会想深入的地方。
|
|
140
|
+
- 对循环或分阶段流程,构建 stepper:每个阶段一个面板,用圆点 / 药丸显示位置,
|
|
141
|
+
Next 按钮从最后一个阶段绕回第一个。每个面板自带内容,互不碰撞。
|
|
142
|
+
- 在以上约束内自由决定布局、配色和交互,让页面清晰、美观、易学。
|
|
143
|
+
|
|
144
|
+
user_template: |
|
|
145
|
+
用户请求:
|
|
146
|
+
{user_input}
|
|
147
|
+
|
|
148
|
+
对话历史:
|
|
149
|
+
{history_context}
|
|
150
|
+
|
|
151
|
+
渲染类型:{render_type}
|
|
152
|
+
|
|
153
|
+
分析简报(注意 `visual_genre`——它告诉你要生成哪种风格的 {render_type}):
|
|
154
|
+
{analysis_json}
|
|
155
|
+
|
|
156
|
+
现在生成可视化代码,作为一个代码块:
|
|
157
|
+
- SVG 用:```svg ... ```
|
|
158
|
+
- Chart.js 用:```javascript ... ```
|
|
159
|
+
- Mermaid 用:```mermaid ... ```
|
|
160
|
+
- 思维导图用:```mermaid ... ```
|
|
161
|
+
- HTML 用:```html ... ```
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
repair_system: |
|
|
2
|
+
你是一个可视化代码修复器。生成的代码未通过确定性的本地校验,并附上了确切的错误。
|
|
3
|
+
用最小的改动修复"那个具体缺陷",使代码有效且可渲染——不要重新设计、重新配色,
|
|
4
|
+
也不要重新评判其余部分。
|
|
5
|
+
|
|
6
|
+
- svg:返回一个格式良好的 `<svg>`,含 xmlns 和驼峰 `viewBox`。如果有多个 `<svg>`,
|
|
7
|
+
合并成一个(各部分作为内部区域)。用预置 class(t/ts/th、box、arr、c-*)来配色和
|
|
8
|
+
排版文本,不要写死 fill。
|
|
9
|
+
- chartjs:返回严格 JSON(键用双引号;无函数、注释或尾逗号),包含 "type" 和 "data"。
|
|
10
|
+
- mermaid:以有效的图表类型关键词开头;节点 ID 不含空格。
|
|
11
|
+
- 保留原有意图和内容。只改必要的部分。
|
|
12
|
+
|
|
13
|
+
返回一个 JSON 对象,不要包含 JSON 以外的任何文本。
|
|
14
|
+
|
|
15
|
+
repair_user_template: |
|
|
16
|
+
用户请求:
|
|
17
|
+
{user_input}
|
|
18
|
+
|
|
19
|
+
渲染类型:{render_type}
|
|
20
|
+
|
|
21
|
+
必须修复的校验错误:
|
|
22
|
+
{error}
|
|
23
|
+
|
|
24
|
+
分析简报:
|
|
25
|
+
{analysis_json}
|
|
26
|
+
|
|
27
|
+
未通过校验的代码:
|
|
28
|
+
{code}
|
|
29
|
+
|
|
30
|
+
返回 JSON:
|
|
31
|
+
{{
|
|
32
|
+
"optimized_code": "修正后、可渲染的代码",
|
|
33
|
+
"changed": true 或 false,
|
|
34
|
+
"review_notes": "修复了什么"
|
|
35
|
+
}}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Visualize capability status messages (Chinese)
|
|
2
|
+
# Loaded by StatusI18n from the `status:` section.
|
|
3
|
+
status:
|
|
4
|
+
analyzing: "正在分析可视化需求..."
|
|
5
|
+
generating: "正在生成可视化代码..."
|
|
6
|
+
manim_code_prepared: "Manim 代码已准备好。"
|
|
7
|
+
manim_rendering: "正在渲染 {mode},质量={quality}。"
|
|
8
|
+
manim_retry: "重试 {attempt}:{error}"
|
|
9
|
+
manim_artifacts_one: "已准备 {count} 个产物。"
|
|
10
|
+
manim_artifacts_many: "已准备 {count} 个产物。"
|
|
11
|
+
llm_call_failed: "LLM 调用失败。"
|
|
12
|
+
tool_calling_disabled: "{target} 未启用工具调用,而可视化只能通过 submit_visualization 工具调用提交到画布。请在 设置 → 模型 → LLM → 能力 → 工具调用 中选择「已支持」。"
|
|
13
|
+
no_payload: "可视化智能体结束时没有产出有效的画布内容。推理模型可能在思考阶段就耗尽了大部分输出预算,导致 submit_visualization 调用还没写完就被截断。如果反复出现,请在 设置 → 能力 → 可视化 中调高「最大 token 数」。"
|
|
14
|
+
no_payload_tool_calling_disabled: "本次没有提交任何可视化:{target} 未启用工具调用,模型无法调用 submit_visualization,而这是把内容交给画布的唯一途径。本地服务(LM Studio、Ollama、vLLM、llama.cpp)默认关闭工具调用;请在 设置 → 模型 → LLM → 能力 → 工具调用 中把该模型声明为「已支持」,然后重新生成。"
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Utility helpers for the visualize pipeline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
import defusedxml.ElementTree as ET
|
|
9
|
+
|
|
10
|
+
from deeptutor.agents._shared.json_output import extract_json_object
|
|
11
|
+
|
|
12
|
+
_MERMAID_KEYWORDS = (
|
|
13
|
+
"graph",
|
|
14
|
+
"flowchart",
|
|
15
|
+
"sequenceDiagram",
|
|
16
|
+
"classDiagram",
|
|
17
|
+
"stateDiagram-v2",
|
|
18
|
+
"stateDiagram",
|
|
19
|
+
"erDiagram",
|
|
20
|
+
"gantt",
|
|
21
|
+
"mindmap",
|
|
22
|
+
"pie",
|
|
23
|
+
"journey",
|
|
24
|
+
"gitGraph",
|
|
25
|
+
"timeline",
|
|
26
|
+
"quadrantChart",
|
|
27
|
+
"requirementDiagram",
|
|
28
|
+
"sankey-beta",
|
|
29
|
+
"xychart-beta",
|
|
30
|
+
"block-beta",
|
|
31
|
+
"C4Context",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def extract_code_block(text: str, language: str = "") -> str:
|
|
36
|
+
"""Extract a fenced code block from LLM output.
|
|
37
|
+
|
|
38
|
+
If *language* is given the block must start with that tag;
|
|
39
|
+
otherwise any triple-backtick fence is accepted.
|
|
40
|
+
"""
|
|
41
|
+
# Closing fence may sit on the same line as the last content line.
|
|
42
|
+
if language:
|
|
43
|
+
pattern = rf"```{re.escape(language)}\s*\n([\s\S]*?)\n?```"
|
|
44
|
+
else:
|
|
45
|
+
pattern = r"```[A-Za-z]*\s*\n([\s\S]*?)\n?```"
|
|
46
|
+
match = re.search(pattern, text or "", re.IGNORECASE)
|
|
47
|
+
if match:
|
|
48
|
+
return match.group(1).strip()
|
|
49
|
+
return (text or "").strip()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def is_valid_html_document(html: str) -> bool:
|
|
53
|
+
"""Heuristic check that *html* looks like a renderable HTML fragment."""
|
|
54
|
+
if not html:
|
|
55
|
+
return False
|
|
56
|
+
lowered = html.lower()
|
|
57
|
+
return "<html" in lowered or "<!doctype" in lowered or "<body" in lowered or "<div" in lowered
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def build_fallback_html(*, title: str, summary: str = "", note: str = "") -> str:
|
|
61
|
+
"""Build a minimal, self-contained fallback HTML page.
|
|
62
|
+
|
|
63
|
+
Used when the model fails to produce a renderable HTML document, so the
|
|
64
|
+
user still gets *something* shown in the iframe instead of a blank panel.
|
|
65
|
+
"""
|
|
66
|
+
safe_title = (title or "Visualization").strip() or "Visualization"
|
|
67
|
+
safe_summary = (summary or "").replace("\n", "<br>") or (
|
|
68
|
+
"The model did not return a renderable HTML document."
|
|
69
|
+
)
|
|
70
|
+
safe_note = (note or "").replace("\n", "<br>")
|
|
71
|
+
|
|
72
|
+
note_block = (
|
|
73
|
+
f'<div class="note"><strong>Note:</strong><br>{safe_note}</div>' if safe_note else ""
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
return f"""<!DOCTYPE html>
|
|
77
|
+
<html lang="en">
|
|
78
|
+
<head>
|
|
79
|
+
<meta charset="UTF-8">
|
|
80
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
81
|
+
<title>{safe_title}</title>
|
|
82
|
+
<style>
|
|
83
|
+
*{{margin:0;padding:0;box-sizing:border-box;}}
|
|
84
|
+
body{{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
|
|
85
|
+
background:linear-gradient(135deg,#F8FAFC 0%,#EFF6FF 100%);
|
|
86
|
+
min-height:100vh;padding:2rem;color:#1E293B;}}
|
|
87
|
+
.card{{max-width:760px;margin:0 auto;background:#fff;border-radius:16px;
|
|
88
|
+
padding:1.75rem 2rem;box-shadow:0 4px 6px -1px rgba(0,0,0,.08);}}
|
|
89
|
+
h1{{color:#1E40AF;font-size:1.4rem;margin-bottom:1rem;}}
|
|
90
|
+
.summary{{line-height:1.7;color:#475569;}}
|
|
91
|
+
.note{{margin-top:1rem;padding:0.9rem 1rem;background:#FEF3C7;
|
|
92
|
+
border-left:4px solid #F59E0B;border-radius:0 8px 8px 0;color:#92400E;}}
|
|
93
|
+
</style>
|
|
94
|
+
</head>
|
|
95
|
+
<body>
|
|
96
|
+
<div class="card">
|
|
97
|
+
<h1>{safe_title}</h1>
|
|
98
|
+
<div class="summary">{safe_summary}</div>
|
|
99
|
+
{note_block}
|
|
100
|
+
</div>
|
|
101
|
+
</body>
|
|
102
|
+
</html>"""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _strip_outer_fence(text: str) -> str:
|
|
106
|
+
"""Drop a single wrapping triple-backtick fence, if present."""
|
|
107
|
+
stripped = (text or "").strip()
|
|
108
|
+
match = re.match(r"^```[A-Za-z]*\s*\n?([\s\S]*?)\n?```$", stripped)
|
|
109
|
+
return match.group(1).strip() if match else stripped
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def validate_visualization(code: str, render_type: str) -> tuple[bool, str]:
|
|
113
|
+
"""Cheap, deterministic, local render-ability check.
|
|
114
|
+
|
|
115
|
+
Returns ``(ok, error)``. When ``ok`` is False, ``error`` is a short,
|
|
116
|
+
LLM-actionable message used to drive a single repair pass — none of these
|
|
117
|
+
failures need an LLM call to *discover*. This replaces the generic LLM
|
|
118
|
+
review for the text render types: only when local validation fails do we
|
|
119
|
+
spend a model call (a targeted repair, not an open-ended review).
|
|
120
|
+
"""
|
|
121
|
+
text = (code or "").strip()
|
|
122
|
+
if not text:
|
|
123
|
+
return False, "Generated code is empty."
|
|
124
|
+
|
|
125
|
+
if render_type == "svg":
|
|
126
|
+
if "<svg" not in text.lower():
|
|
127
|
+
return False, "SVG must contain a root <svg> element."
|
|
128
|
+
try:
|
|
129
|
+
root = ET.fromstring(text)
|
|
130
|
+
except ET.ParseError as exc:
|
|
131
|
+
return False, f"SVG is not well-formed XML: {exc}"
|
|
132
|
+
tag = root.tag.split("}")[-1].lower()
|
|
133
|
+
if tag != "svg":
|
|
134
|
+
return False, f"Root element must be <svg>, found <{tag}>."
|
|
135
|
+
# Case-sensitive: SVG only honors the camelCase ``viewBox``; a
|
|
136
|
+
# lowercase ``viewbox`` is ignored by the browser and collapses the
|
|
137
|
+
# figure, so it must NOT pass validation.
|
|
138
|
+
if "viewBox" not in root.attrib:
|
|
139
|
+
return False, (
|
|
140
|
+
"SVG root is missing a viewBox attribute (must be camelCase "
|
|
141
|
+
"`viewBox`, required for responsive scaling)."
|
|
142
|
+
)
|
|
143
|
+
return True, ""
|
|
144
|
+
|
|
145
|
+
if render_type == "chartjs":
|
|
146
|
+
candidate = _strip_outer_fence(text)
|
|
147
|
+
try:
|
|
148
|
+
config = json.loads(candidate)
|
|
149
|
+
except (json.JSONDecodeError, TypeError):
|
|
150
|
+
return False, (
|
|
151
|
+
"Chart.js config must be strict JSON: double-quoted keys, no "
|
|
152
|
+
"function callbacks, no comments, no trailing commas."
|
|
153
|
+
)
|
|
154
|
+
if not isinstance(config, dict):
|
|
155
|
+
return False, "Chart.js config must be a JSON object."
|
|
156
|
+
missing = [field for field in ("type", "data") if field not in config]
|
|
157
|
+
if missing:
|
|
158
|
+
return False, f"Chart.js config is missing required field(s): {', '.join(missing)}."
|
|
159
|
+
return True, ""
|
|
160
|
+
|
|
161
|
+
if render_type == "mermaid":
|
|
162
|
+
first_line = next((ln.strip() for ln in text.splitlines() if ln.strip()), "")
|
|
163
|
+
# `---` front-matter and `%%{init}` directives are valid lead-ins.
|
|
164
|
+
if (
|
|
165
|
+
first_line.startswith(_MERMAID_KEYWORDS)
|
|
166
|
+
or first_line.startswith("%%")
|
|
167
|
+
or first_line.startswith("---")
|
|
168
|
+
):
|
|
169
|
+
return True, ""
|
|
170
|
+
return False, (
|
|
171
|
+
"Mermaid code must start with a valid diagram keyword (graph, "
|
|
172
|
+
"flowchart, sequenceDiagram, classDiagram, stateDiagram-v2, "
|
|
173
|
+
"erDiagram, gantt, mindmap, ...)."
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
if render_type == "mindmap":
|
|
177
|
+
if text.startswith("```"):
|
|
178
|
+
return False, "Mermaid mindmap output must be raw DSL without a Markdown fence."
|
|
179
|
+
lines = [
|
|
180
|
+
(line, len(line) - len(line.lstrip()))
|
|
181
|
+
for line in text.splitlines()
|
|
182
|
+
if line.strip() and not line.lstrip().startswith("%%")
|
|
183
|
+
]
|
|
184
|
+
if not lines or lines[0][0].strip() != "mindmap":
|
|
185
|
+
return False, "Mermaid mindmap output must start with the `mindmap` keyword."
|
|
186
|
+
if len(lines) < 2:
|
|
187
|
+
return False, "Mermaid mindmap output must contain exactly one non-empty root node."
|
|
188
|
+
root_indent = lines[1][1]
|
|
189
|
+
if any(indent <= root_indent for _, indent in lines[2:]):
|
|
190
|
+
return False, "Mermaid mindmap output must contain exactly one non-empty root node."
|
|
191
|
+
if not any(indent > root_indent for _, indent in lines[2:]):
|
|
192
|
+
return False, "Mermaid mindmap output needs at least one child branch."
|
|
193
|
+
return True, ""
|
|
194
|
+
|
|
195
|
+
if render_type == "html":
|
|
196
|
+
if is_valid_html_document(text):
|
|
197
|
+
return True, ""
|
|
198
|
+
return False, "Output does not look like a renderable HTML document."
|
|
199
|
+
|
|
200
|
+
# Unknown render types are not gated.
|
|
201
|
+
return True, ""
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
__all__ = [
|
|
205
|
+
"build_fallback_html",
|
|
206
|
+
"extract_code_block",
|
|
207
|
+
"extract_json_object",
|
|
208
|
+
"is_valid_html_document",
|
|
209
|
+
"validate_visualization",
|
|
210
|
+
]
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Per-user visualizer catalog, lifecycle and sandbox asset routes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import tempfile
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, File, HTTPException, UploadFile
|
|
10
|
+
from fastapi.responses import FileResponse
|
|
11
|
+
|
|
12
|
+
from deeptutor.visualizers.registry import get_visualizer_registry
|
|
13
|
+
from deeptutor.visualizers.store import VisualizerStoreError
|
|
14
|
+
|
|
15
|
+
router = APIRouter()
|
|
16
|
+
|
|
17
|
+
_MAX_UPLOAD_BYTES = 25 * 1024 * 1024
|
|
18
|
+
_IFRAME_CSP = (
|
|
19
|
+
"default-src 'self'; "
|
|
20
|
+
"script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; "
|
|
21
|
+
"style-src 'self' 'unsafe-inline'; "
|
|
22
|
+
"img-src 'self' data: blob:; "
|
|
23
|
+
"font-src 'self' data:; "
|
|
24
|
+
"media-src 'self' data: blob:; "
|
|
25
|
+
"connect-src 'none'; frame-src 'none'; object-src 'none'; "
|
|
26
|
+
"base-uri 'none'; form-action 'none'; frame-ancestors 'self'"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _http_error(exc: VisualizerStoreError) -> HTTPException:
|
|
31
|
+
detail = str(exc)
|
|
32
|
+
if "not found" in detail:
|
|
33
|
+
return HTTPException(status_code=404, detail=detail)
|
|
34
|
+
if "already installed" in detail or "reserved" in detail:
|
|
35
|
+
return HTTPException(status_code=409, detail=detail)
|
|
36
|
+
return HTTPException(status_code=400, detail=detail)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@router.get("/list")
|
|
40
|
+
async def list_visualizers() -> dict[str, object]:
|
|
41
|
+
registry = get_visualizer_registry()
|
|
42
|
+
return {
|
|
43
|
+
"schema_version": "deeptutor.visualizer-catalog/v1",
|
|
44
|
+
"visualizers": registry.public_catalog(),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@router.post("/bundled/{visualizer_id}/install")
|
|
49
|
+
async def install_bundled(visualizer_id: str) -> dict[str, object]:
|
|
50
|
+
registry = get_visualizer_registry()
|
|
51
|
+
try:
|
|
52
|
+
registry.install_bundled(visualizer_id)
|
|
53
|
+
except VisualizerStoreError as exc:
|
|
54
|
+
raise _http_error(exc) from exc
|
|
55
|
+
plugin = registry.get(visualizer_id)
|
|
56
|
+
return {"status": "installed", "visualizer": plugin.manifest.id if plugin else visualizer_id}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@router.post("/{visualizer_id}/enable")
|
|
60
|
+
async def enable_visualizer(visualizer_id: str) -> dict[str, str]:
|
|
61
|
+
registry = get_visualizer_registry()
|
|
62
|
+
try:
|
|
63
|
+
registry.set_enabled(visualizer_id, True)
|
|
64
|
+
except VisualizerStoreError as exc:
|
|
65
|
+
raise _http_error(exc) from exc
|
|
66
|
+
return {"status": "enabled", "visualizer": visualizer_id}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@router.post("/{visualizer_id}/disable")
|
|
70
|
+
async def disable_visualizer(visualizer_id: str) -> dict[str, str]:
|
|
71
|
+
registry = get_visualizer_registry()
|
|
72
|
+
try:
|
|
73
|
+
registry.set_enabled(visualizer_id, False)
|
|
74
|
+
except VisualizerStoreError as exc:
|
|
75
|
+
raise _http_error(exc) from exc
|
|
76
|
+
return {"status": "disabled", "visualizer": visualizer_id}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@router.delete("/{visualizer_id}")
|
|
80
|
+
async def uninstall_visualizer(visualizer_id: str) -> dict[str, str]:
|
|
81
|
+
registry = get_visualizer_registry()
|
|
82
|
+
try:
|
|
83
|
+
registry.uninstall(visualizer_id)
|
|
84
|
+
except VisualizerStoreError as exc:
|
|
85
|
+
raise _http_error(exc) from exc
|
|
86
|
+
return {"status": "uninstalled", "visualizer": visualizer_id}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@router.post("/import")
|
|
90
|
+
async def import_visualizer(
|
|
91
|
+
file: UploadFile = File(...), # noqa: B008
|
|
92
|
+
) -> dict[str, object]:
|
|
93
|
+
"""Install one declarative ``visualizer.json`` + iframe bundle zip."""
|
|
94
|
+
|
|
95
|
+
if not str(file.filename or "").lower().endswith(".zip"):
|
|
96
|
+
raise HTTPException(status_code=400, detail="visualizer package must be a zip file")
|
|
97
|
+
|
|
98
|
+
fd, temp_name = tempfile.mkstemp(prefix="deeptutor-visualizer-", suffix=".zip")
|
|
99
|
+
os.close(fd)
|
|
100
|
+
temp_path = Path(temp_name)
|
|
101
|
+
total = 0
|
|
102
|
+
try:
|
|
103
|
+
with temp_path.open("wb") as sink:
|
|
104
|
+
while chunk := await file.read(1 << 20):
|
|
105
|
+
total += len(chunk)
|
|
106
|
+
if total > _MAX_UPLOAD_BYTES:
|
|
107
|
+
raise HTTPException(
|
|
108
|
+
status_code=413,
|
|
109
|
+
detail="visualizer package exceeds 25 MB",
|
|
110
|
+
)
|
|
111
|
+
sink.write(chunk)
|
|
112
|
+
registry = get_visualizer_registry()
|
|
113
|
+
try:
|
|
114
|
+
plugin = registry.install_archive(temp_path)
|
|
115
|
+
except VisualizerStoreError as exc:
|
|
116
|
+
raise _http_error(exc) from exc
|
|
117
|
+
catalog = registry.public_catalog()
|
|
118
|
+
public_manifest = next(item for item in catalog if item["id"] == plugin.manifest.id)
|
|
119
|
+
return {
|
|
120
|
+
"status": "installed",
|
|
121
|
+
"visualizer": plugin.manifest.id,
|
|
122
|
+
"manifest": public_manifest,
|
|
123
|
+
}
|
|
124
|
+
finally:
|
|
125
|
+
temp_path.unlink(missing_ok=True)
|
|
126
|
+
await file.close()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@router.get("/{visualizer_id}/assets/{asset_path:path}")
|
|
130
|
+
async def get_visualizer_asset(visualizer_id: str, asset_path: str) -> FileResponse:
|
|
131
|
+
registry = get_visualizer_registry()
|
|
132
|
+
try:
|
|
133
|
+
path = registry.asset_path(visualizer_id, asset_path)
|
|
134
|
+
except VisualizerStoreError as exc:
|
|
135
|
+
raise _http_error(exc) from exc
|
|
136
|
+
return FileResponse(
|
|
137
|
+
path,
|
|
138
|
+
headers={
|
|
139
|
+
"Cache-Control": "private, max-age=300",
|
|
140
|
+
"Content-Security-Policy": _IFRAME_CSP,
|
|
141
|
+
"Cross-Origin-Resource-Policy": "same-origin",
|
|
142
|
+
"X-Content-Type-Options": "nosniff",
|
|
143
|
+
},
|
|
144
|
+
)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# DeepTutor visualizer package protocol
|
|
2
|
+
|
|
3
|
+
A visualizer type is a declarative agent contract plus a canvas renderer. User
|
|
4
|
+
packages are zip archives with this shape:
|
|
5
|
+
|
|
6
|
+
```text
|
|
7
|
+
fraction_tiles.zip
|
|
8
|
+
├── visualizer.json
|
|
9
|
+
├── index.html
|
|
10
|
+
├── renderer.js
|
|
11
|
+
└── renderer.css
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
`visualizer.json` describes when the agent should choose the type, the payload
|
|
15
|
+
it must generate, and the iframe entry point:
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
{
|
|
19
|
+
"id": "fraction_tiles",
|
|
20
|
+
"version": "1.0.0",
|
|
21
|
+
"display_name": "Fraction Tiles",
|
|
22
|
+
"description": "Interactive comparison of fractions.",
|
|
23
|
+
"subjects": ["mathematics"],
|
|
24
|
+
"intents": ["compare", "explore"],
|
|
25
|
+
"render_target": "iframe",
|
|
26
|
+
"renderer_entry": "index.html",
|
|
27
|
+
"payload_format": "application/json",
|
|
28
|
+
"payload_kind": "json",
|
|
29
|
+
"payload_schema": {
|
|
30
|
+
"type": "object",
|
|
31
|
+
"required": ["fractions"],
|
|
32
|
+
"properties": {
|
|
33
|
+
"fractions": {
|
|
34
|
+
"type": "array",
|
|
35
|
+
"items": {"type": "string"},
|
|
36
|
+
"minItems": 1
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"additionalProperties": false
|
|
40
|
+
},
|
|
41
|
+
"prompt": "Return equivalent fractions that directly answer the learner's request."
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Imported packages are intentionally declarative: they cannot ship Python or
|
|
46
|
+
register backend code. Their renderer runs in a script-only sandbox with no
|
|
47
|
+
network access. Local JSON Schema is validated before a payload reaches the
|
|
48
|
+
canvas; remote schema references are rejected.
|
|
49
|
+
|
|
50
|
+
When the iframe loads, the host sends:
|
|
51
|
+
|
|
52
|
+
```js
|
|
53
|
+
{
|
|
54
|
+
type: "deeptutor:visualization:render",
|
|
55
|
+
schema_version: "deeptutor.visualization/v1",
|
|
56
|
+
renderer,
|
|
57
|
+
payload,
|
|
58
|
+
presentation,
|
|
59
|
+
interaction
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The renderer may send two messages back to its parent:
|
|
64
|
+
|
|
65
|
+
```js
|
|
66
|
+
parent.postMessage({
|
|
67
|
+
type: "deeptutor:visualization:resize",
|
|
68
|
+
height: document.documentElement.scrollHeight
|
|
69
|
+
}, "*");
|
|
70
|
+
|
|
71
|
+
parent.postMessage({
|
|
72
|
+
type: "deeptutor:visualization:prompt",
|
|
73
|
+
text: "Why are these fractions equivalent?"
|
|
74
|
+
}, "*");
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Core native renderers and bundled optional types use the same versioned canvas
|
|
78
|
+
envelope. Their trusted validators and native React components live in the
|
|
79
|
+
host, while installation state remains per user.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Pluggable visualization types used by chat and the visualize capability."""
|
|
2
|
+
|
|
3
|
+
from .protocol import (
|
|
4
|
+
VISUALIZATION_RESULT_KEY,
|
|
5
|
+
VISUALIZE_MODE_KEY,
|
|
6
|
+
VisualizationEnvelope,
|
|
7
|
+
VisualizerManifest,
|
|
8
|
+
VisualizerPlugin,
|
|
9
|
+
)
|
|
10
|
+
from .registry import VisualizerRegistry, get_visualizer_registry
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"VISUALIZATION_RESULT_KEY",
|
|
14
|
+
"VISUALIZE_MODE_KEY",
|
|
15
|
+
"VisualizationEnvelope",
|
|
16
|
+
"VisualizerManifest",
|
|
17
|
+
"VisualizerPlugin",
|
|
18
|
+
"VisualizerRegistry",
|
|
19
|
+
"get_visualizer_registry",
|
|
20
|
+
]
|