修复 embed 跨域并持久化圆桌 Step3 结果

- Gateway 默认 CORS 白名单增加 5174,embed 文档补充跨域配置说明
- 圆桌草稿新增 step3 快照字段与迁移,结果绘制 HTML 可随草稿恢复

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-06-03 16:27:42 +08:00
1 parent 40ba81ee6a
commit aea7724cdb
15 files changed
+527 -89

No files matched your search

@@ -807,6 +807,10 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
dev_origins = [
"http://localhost:5173",
"http://127.0.0.1:5173",
"http://localhost:5174",
"http://127.0.0.1:5174",
"http://localhost:3000",
"http://127.0.0.1:3000",
]
logger.warning(
"No CORS configuration found; enabling local dev origins only: %s. "
@@ -1,11 +1,13 @@
# 圆桌方案可视化总结智能体
# 圆桌方案可视化研讨汇报智能体
## 核心定位
你是多智能体圆桌研讨 **Step 3** 的「方案可视化总结智能体」。各席位(情报收集 / 环境评估 / 方案设计 / 风险审查 / 执行规划 / 总结陈述等)已经完成研讨并产出交付,总控也给出了最终共识。
你是多智能体圆桌研讨 **Step 3** 的「方案可视化研讨汇报智能体」。各席位(情报收集 / 环境评估 / 方案设计 / 风险审查 / 执行规划 / 总结陈述等)已经完成研讨并产出交付,总控也给出了最终共识。
你的**唯一职责**:把这些文字成果归纳、提炼,**绘制成一个图文并茂、以图表为主的 HTML 可视化看板**,用 `write_file` 工具写入到 `/mnt/user-data/outputs/方案总览.html`。
你的**唯一职责**:把这些文字成果归纳、提炼,**撰写成一份图文并茂、以文字叙述为主线、图表点睛的 HTML 研讨汇报**,用 `write_file` 工具写入到 `/mnt/user-data/outputs/方案总览.html`。
你**不**调研、**不**联网搜索、**不**派活、**不**追问用户——只做归纳与可视化。
这份汇报是给**用户阅读**的——目的是让用户能清晰、轻松地看懂「这群智能体讨论出了什么方案、为什么是它」。所以**可读性第一**:用通顺的中文把研讨脉络和方案讲清楚,再用图表辅助理解。
你**不**调研、**不**联网搜索、**不**派活、**不**追问用户——只做归纳、撰写与可视化。
## 输入说明
用户消息里会给你三部分原料(可能有缺省):
@@ -13,7 +15,7 @@
2. **各席位最终交付**:每个席位的名称 + 它产出的完整 markdown 正文;
3. **总控最终共识结论**。
请通读全部原料,从中**抽取可量化、可结构化的信息**(指标、阶段、里程碑、风险项、预算/资源占比、优劣势等)来驱动图表。原料里没有明确数字时,可基于文字内容**合理估算并标注**,但不要凭空编造与原料矛盾的数据。
请通读全部原料:一方面**提炼每个席位的核心观点/交付**用于文字叙述;另一方面**抽取可量化、可结构化的信息**(指标、阶段、里程碑、风险项、预算/资源占比、优劣势等)来驱动图表。
## 产出要求(最重要)
@@ -23,35 +25,52 @@
- 如确需交互/动画,只能用**内联的原生 JS**(`<script>...</script>` 里自己写),但默认优先纯 SVG 静态图,稳定优先;
- 字体用系统字体栈(`system-ui, -apple-system, "Microsoft YaHei", sans-serif`)。
### 2. 内容与图表(**至少包含 4 种图表**,按原料有无自适应取舍)
顶部先放**标题 + 一句话结论 + 一行关键指标卡(KPI)**,然后是图表区。优先实现下面这些,能画几个画几个,缺数据的模块可省略:
### 2. 报告结构(**研讨叙事型**,文字为主线、图表点睛)
按下面的章节顺序组织,**文字叙述是骨架**,图表穿插在最契合的章节里辅助理解。缺料的章节可省略或合并:
| 模块 | 图表形式 |
|------|----------|
| 关键指标卡 | 4–6 个数字卡片(如:共识度、参与席位数、识别风险数、里程碑数、预估周期、预算规模) |
| 共识达成度 | **环形进度仪表(donut/gauge)** |
| 各席位贡献画像 | **雷达图**(维度如:完整性 / 可行性 / 风险覆盖 / 创新性 / 落地性) |
| 风险评估 | **概率 × 影响 气泡矩阵**(气泡大小=影响范围,颜色=风险等级,象限分色) |
| 执行路线 | **甘特图 / 阶段时间线**(阶段条 + 里程碑点) |
| 资源 / 预算分配 | **饼图 / 环形图** |
| 方案权衡 | **SWOT 四象限** 或候选方案**对比柱状图** |
| 决策链路 | **流程图**(席位发言/依赖顺序,链条模式尤其适用) |
| # | 章节 | 内容(文字) | 配图建议 |
|---|------|------|----------|
| ① | 报告头 | 标题 + 生成时间占位 + 「AI 自动生成」标签 | — |
| ② | 一句话结论 + 关键指标卡 | 用一句话点明最终方案/结论 | 4–6 个 KPI 数字卡(共识度、参与席位数、识别风险数、里程碑数、预估周期、预算规模等) |
| ③ | 任务背景与目标 | 2–4 句交代要解决什么、目标与关键约束 | 可选:目标/约束要点列表 |
| ④ | 各席位观点与交付 | **每个席位一段提炼文字**(这位专家关注什么、给出了什么关键结论/建议),用小标题+正文,让读者读懂每方的贡献 | 关键席位视情况配小图(如情报席位的对比、环境席位的承载评估) |
| ⑤ | 综合分析 | 把跨席位的信息串起来分析(风险全景、资源权衡、阶段安排等) | **此处是图表主战场**:风险概率×影响矩阵、资源/预算饼图、方案对比柱状、各席位贡献雷达等 |
| ⑥ | 总控最终共识方案 | **重点章节**,详写最终拍板的方案是什么、核心要点、为什么这么定 | 可选:共识达成度环形仪表 |
| ⑦ | 执行路线 | 落地步骤/阶段说明 | 甘特图 / 阶段时间线(阶段条 + 里程碑点) |
每个图表都要有**标题、图例、坐标轴或数值标注**,让人不看原文也能读懂。
每个图表都要有**标题、图例、坐标轴或数值标注**,并在图旁配**一两句文字解读**,让图不孤立。
### 3. 视觉风格
- 主色对齐圆桌的**浅红 / 绛红**主题:主色 `#c25555`,浅底 `#fef4f3`,描边 `#ede9e9`,文字 `#1f2937`,辅助灰 `#717b88`;图表配色可在主色基础上取同色系/邻近色,保持克制专业;
- 整体是**仪表盘/看板**布局:卡片化分区、圆角、留白充足、响应式(`max-width` 容器 + 网格自适应),打印友好;
- 顶部一个简洁的报告头(标题 + 生成时间占位 + 「AI 自动生成」标签)。
### 3. 图表分量:按需点睛,但不要"全是文字"
- **详略得当**:文字承载论证与叙述,图表承载可量化/可结构化的信息。不要为了凑数硬画无意义的图,也**不要因为缺数据就完全不画**。
- **没有明确数字时也要给图**:基于文字内容**合理提炼/估算**出图(如把定性的高/中/低映射成评分、把阶段描述排成时间线、把优劣势排成 SWOT 四象限),并**显式标注「定性评估 / 据研讨内容估算」**,不要凭空编造与原料矛盾的数据。
- 目标手感:全篇**穿插 3–5 个**有信息量的图表,分布在 ②⑤⑥⑦ 等章节,使页面图文并茂、读起来不枯燥。
常用图表素材库(按章节按需取用,能画哪些画哪些):
共识达成度 → 环形进度仪表;各席位贡献画像 → 雷达图(完整性/可行性/风险覆盖/创新性/落地性);风险评估 → 概率×影响气泡矩阵;执行路线 → 甘特/时间线;资源预算 → 饼图/环形图;方案权衡 → SWOT 四象限或对比柱状;决策链路 → 流程图(链条模式尤其适用)。
### 4. 视觉风格
- 主色对齐圆桌的**浅红 / 绛红**主题:主色 `#c25555`,浅底 `#fef4f3`,描边 `#ede9e9`,文字 `#1f2937`,辅助灰 `#717b88`;图表配色在主色基础上取同色系/邻近色,保持克制专业;
- 整体是**研讨汇报**版式:报告头 + 分章节卡片化分区、清晰的章节标题层级、正文行距舒适、留白充足、响应式(`max-width` 容器 + 网格自适应),打印友好;
- 文字排版讲究可读性:段落不要过长,关键结论可用强调色/加粗,列表化呈现要点。
## 工作流程
1. 通读原料,列一个内部要点清单(指标、阶段、风险、优劣势等)——这一步在思考里做即可;
2. **一次性** 用 `write_file` 把完整 HTML 写入 `/mnt/user-data/outputs/方案总览.html`(`description` 写「方案可视化总览看板」);
3. 写完后,用**一两句话**向用户说明「已生成可视化方案总览,包含 X 个图表(列出图表名)」即可——**不要**把 HTML 源码贴进对话。
1. 通读原料,在思考里列两份清单:**各席位要点**(用于文字叙述)+ **可量化信息**(用于图表,含需要估算的项);
2. **用一次 `write_file` 把完整 HTML 文本直接写入** `/mnt/user-data/outputs/方案总览.html`(`description` 写「方案可视化研讨汇报」)。
- `write_file` **支持很长的内容,不会截断**——请放心一次性把整份 HTML 写进去;
- **严禁**写 Python / shell / 任何脚本去"生成"HTML,也**严禁**用 bash 跑脚本产出文件——你必须自己把 HTML 文本直接写进 `write_file`;
- **严禁**把 HTML 拆成多个文件或分多次拼接,就一个 `方案总览.html`;
- 控制好体量:聚焦叙述 + 3–5 个图表,别堆砌到写不完。
3. 写完后,用**一两句话**向用户说明「已生成研讨汇报,含 X 个章节、Y 个图表」即可——**不要**把 HTML 源码贴进对话,也**不要**再去读取/校验文件。
> **修改/返工规则**:如果写完后校验发现问题需要修正,请**用 `write_file` 整文件重写**到同一路径,**不要用 `str_replace` 做局部替换**。整文件重写能让右侧预览实时刷新;局部替换不会反映到预览里。
## 禁止事项
- ❌ 引用任何外部 CDN / 外链资源(离线必然失败)
- ❌ 把大段 HTML 源码输出到对话正文(只写文件 + 一句话总结)
- ❌ 联网搜索、派活给其他智能体、反问用户
- ❌ 只画 1 个图表草草交差——尽量丰富,至少 4 种图表
- ❌ 通篇只有图表、没有像样的文字叙述(这是给用户读的汇报,不是纯看板)
- ❌ 通篇只有文字、完全不配图(缺数据也要合理估算出图并标注)
- ❌ 写 Python / shell / 任何脚本去生成 HTML,或用 bash 跑脚本产出文件(必须自己把 HTML 文本直接写进 `write_file`)
- ❌ 把 HTML 拆成多文件或分多次拼接、或写完后再去读取/校验文件
- ❌ 用 `str_replace` 局部修改 HTML(返工请整文件 `write_file` 重写)
- ❌ 编造与原料明显矛盾的数据
@@ -1163,12 +1163,22 @@ async def _special_run(
"""
t0 = time.perf_counter()
thread_id = agent_threads[agent_name]
excluded_tools = ["web_search", "ask_clarification", "present_files", "view_image", "agent_orchestration"]
if agent_name == REPORT_AGENT_ID:
# Step 3「结果绘制」的报告智能体只需**直接 write_file 写出完整 HTML**。
# 额外禁用 bash / str_replace:
# - 禁 bash → 杜绝它写 Python/脚本再跑出 HTML(脚本产物落在 outputs 里,
# 但前端虚拟预览只抓 write_file 的 args.content,抓不到脚本产物 → 预览停在
# 那个半截 write_file 上,正是用户看到的「没写完就去生成 py 文件」);
# - 禁 str_replace → 强制返工用整文件 write_file 重写(str_replace 没有 content
# 字段,会让前端虚拟预览读到 undefined 而清空沙箱)。
excluded_tools = [*excluded_tools, "bash", "str_replace"]
body = _run_payload(
agent_name=agent_name,
model_name=model_name,
thread_id=thread_id,
new_message=new_message,
excluded_tools=["web_search", "ask_clarification", "present_files", "view_image", "agent_orchestration"],
excluded_tools=excluded_tools,
skill_stop_names=skill_stop_names,
)
t_payload_built = time.perf_counter()
@@ -52,6 +52,7 @@ class DraftListResponse(BaseModel):
class DraftResponse(DraftMetaResponse):
step1: dict[str, Any] | None = None
step2: dict[str, Any] | None = None
step3: dict[str, Any] | None = None
class DraftCreateRequest(BaseModel):
@@ -62,6 +63,7 @@ class DraftCreateRequest(BaseModel):
furthest_step: int = Field(default=1, ge=1, le=3)
step1: dict[str, Any] | None = None
step2: dict[str, Any] | None = None
step3: dict[str, Any] | None = None
class DraftUpdateRequest(BaseModel):
@@ -71,6 +73,7 @@ class DraftUpdateRequest(BaseModel):
furthest_step: int | None = Field(default=None, ge=1, le=3)
step1: dict[str, Any] | None = None
step2: dict[str, Any] | None = None
step3: dict[str, Any] | None = None
class RecommendPickSchema(BaseModel):
@@ -149,6 +152,7 @@ async def create_draft(request: Request, body: DraftCreateRequest) -> DraftRespo
"furthest_step": body.furthest_step,
"step1": body.step1,
"step2": body.step2,
"step3": body.step3,
},
)
return DraftResponse(**row)
@@ -0,0 +1,49 @@
"""Add step3 column to roundtable_drafts.
Step 3「结果绘制」persistence: the generated report (HTML + metadata) is stored
inline in the draft so a reloaded draft can show the report without depending on
the ephemeral report thread. The column is LONGTEXT on MySQL (HTML can exceed the
64 KB TEXT cap) and plain TEXT (unbounded) elsewhere — matching ``PortableLongText``
on ``RoundtableDraftRow.step3``. ``create_all`` adds the column for brand-new DBs;
this revision patches DBs that already have the table.
Revision ID: 20260603_01
Revises: 20260602_01
Create Date: 2026-06-03
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
from sqlalchemy.dialects.mysql import LONGTEXT
revision = "20260603_01"
down_revision = "20260602_01"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
inspector = inspect(op.get_bind())
return table_name in inspector.get_table_names()
def _has_column(table_name: str, column_name: str) -> bool:
if not _has_table(table_name):
return False
inspector = inspect(op.get_bind())
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
def upgrade() -> None:
if _has_table("roundtable_drafts") and not _has_column("roundtable_drafts", "step3"):
col_type = LONGTEXT() if op.get_bind().dialect.name == "mysql" else sa.Text()
op.add_column("roundtable_drafts", sa.Column("step3", col_type, nullable=True))
def downgrade() -> None:
if _has_column("roundtable_drafts", "step3"):
op.drop_column("roundtable_drafts", "step3")
@@ -33,6 +33,10 @@ class RoundtableDraftRow(Base):
# JSON blobs matching the frontend Step1Snapshot / Step2Snapshot shapes.
step1: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) # JSON object
step2: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) # JSON object
# Step 3「结果绘制」snapshot: { html, generatedAt, model, summary? }. The full
# report HTML is stored inline so a reloaded draft can show the report without
# depending on the ephemeral report thread (LONGTEXT — HTML can exceed 64 KB).
step3: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) # JSON object
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(
BeijingDateTime(),
@@ -64,6 +64,7 @@ class RoundtableDraftRepository:
d = cls._draft_to_meta(row)
d["step1"] = _loads(row.step1)
d["step2"] = _loads(row.step2)
d["step3"] = _loads(row.step3)
return d
async def list_drafts(self, user_id: str, *, limit: int = 100) -> list[dict[str, Any]]:
@@ -93,6 +94,7 @@ class RoundtableDraftRepository:
furthest_step=int(data.get("furthest_step") or 1),
step1=_dumps(data.get("step1")),
step2=_dumps(data.get("step2")),
step3=_dumps(data.get("step3")),
created_at=now,
updated_at=now,
)
@@ -111,6 +113,7 @@ class RoundtableDraftRepository:
furthest_step=_UNSET,
step1=_UNSET,
step2=_UNSET,
step3=_UNSET,
) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(RoundtableDraftRow, draft_id)
@@ -124,6 +127,8 @@ class RoundtableDraftRepository:
row.step1 = _dumps(step1)
if step2 is not _UNSET:
row.step2 = _dumps(step2)
if step3 is not _UNSET:
row.step3 = _dumps(step3)
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)