feat: v0.3 - Manus replay + token tracking + SIGTERM fix + accelerate button
This is the v0.3 milestone commit before the v0.4 big version push.
Major themes: process replay, runtime stability, cost observability.
## New Features
- **Manus-style process replay** (frontend + backend)
- `GET /api/simulation/<id>/replay` returns full workflow + agents + rounds + aggregate
- `frontend/src/views/SimulationReplayView.vue` 3-column layout (workflow / actions / stats)
- bottom scrubber with play/pause/step + 5 speed levels (0.5x-10x)
- filters out stale actions from previous runs via latest simulation_start timestamp
- **Token usage tracking** (`backend/app/utils/token_tracker.py`)
- process-wide stage→model→tokens counter
- LLMClient auto-records prompt/completion tokens after each call
- stages tagged at API entry: step1_ontology, step2_graph_build, step3_prepare, step5_report
- `GET /api/usage/summary` for live stats + CNY cost estimate
- `GET /api/usage/estimate-simulation` for OASIS subprocess estimation
- pricing table for GLM/SiliconFlow/MiniMax/OpenAI/Anthropic models
- documented as internal-use, removed from customer-facing builds
- **Step 2 "Skip & Continue" button**
- lets user stop profile generation early and proceed with what's already generated
- `simulation_manager.request_accelerate()` + cancel_check in oasis_profile_generator
- new endpoint `POST /api/simulation/prepare/accelerate`
## Critical Bug Fix
- **SIGTERM no longer kills running simulation subprocess**
- root cause: `SimulationRunner.register_cleanup()` registered SIGTERM/SIGINT/SIGHUP handlers
that called `os.killpg` on every tracked sim child, even though spawn already used
`start_new_session=True` to give children isolated sessions
- fix: neutered `register_cleanup` to a no-op; `cleanup_all_simulations` itself preserved
for explicit stop_simulation paths
- validated: killed Flask backend twice, simulation subprocess kept running
- impact: hot-reload backend code without interrupting in-flight simulations
## Performance & Tuning
- semaphore 30 → 100 (twitter + reddit) for higher LLM concurrency
- discovered 200 agents as memory/cost/statistical sweet spot for 8G server
(503 agents OOMs both platforms; 200 agents fits cleanly with 95% confidence margin)
## Documentation
- **PRD.md** rewritten as v0.3 baseline (10 chapters + 2 appendices, 639 lines)
- product positioning across 3 usage modes (one-shot / model-reuse / SaaS)
- v0.4 roadmap: domestic platforms (douyin/wechat/xiaohongshu/weibo), fork sim, multi-tenant
- operational lessons: HF mirror, Tencent PyPI mirror, GLM-4-Flash choice, agent count
- decision log with dates
- per-stage token/cost breakdown for typical 200-agent run
- **README.md / README-ZH.md** updated with replay step + Graphiti+Neo4j
## Files Touched
19 files changed, 1105 insertions(+), 246 deletions(-)
This commit is contained in:
parent
8088b927f0
commit
1fef01d979
641
PRD.md
641
PRD.md
|
|
@ -1,243 +1,580 @@
|
|||
# Foresight 先见之明 — 产品需求文档 (PRD)
|
||||
|
||||
## 1. 产品概述
|
||||
|
||||
Foresight(先见之明)是一个基于知识图谱和 LLM 的社交媒体舆情模拟平台。用户上传文档资料,系统自动构建知识图谱、生成虚拟 Agent 画像,并模拟社交媒体上的传播与互动行为,最终生成分析报告。
|
||||
|
||||
**核心价值**:在事件发生前预判舆论走向,帮助品牌、政府、机构提前制定应对策略。
|
||||
|
||||
**上游项目**:基于 [MiroFish](https://github.com/666ghj/MiroFish) v0.1.2 二次开发。
|
||||
> **版本**:v0.3 — 2026-04-15 大版本前最后一次校准
|
||||
> **基线**:基于 [MiroFish](https://github.com/666ghj/MiroFish) v0.1.2 二次开发,已大量重构。
|
||||
|
||||
---
|
||||
|
||||
## 2. 目标用户
|
||||
## 1. 产品定位
|
||||
|
||||
| 用户类型 | 使用场景 |
|
||||
|----------|----------|
|
||||
| 品牌公关团队 | 新品发布前预判舆论反应 |
|
||||
| 政府舆情分析师 | 政策出台前模拟民意 |
|
||||
| 内容创作者 | 预测爆款视频的传播路径 |
|
||||
| 研究人员 | 社交网络传播行为研究 |
|
||||
**Foresight 是一个把"需要预测的未知"变成"可演化的数字沙盘"的群体智能引擎。**
|
||||
|
||||
用户上传任何一份"信号"——一段视频文案、一份政策草案、一次金融事件复盘、一段小说背景——Foresight 自动构建该信号所属世界的知识图谱,孵化出几百个有完整人格、记忆、行为模式的虚拟 Agent,让他们在虚拟社交平台上自由互动、扩散、对抗、共鸣,最后输出一份"如果这件事真的发生,世界会变成什么样"的详尽预测报告。
|
||||
|
||||
**核心命题**:让"未来"在数字沙盘里先演练一遍,让决策在百战模拟之后才下刀。
|
||||
|
||||
### 三种使用形态
|
||||
|
||||
| 形态 | 用户 | 典型问题 | 输出 |
|
||||
|---|---|---|---|
|
||||
| **单次预测** | 个人 / 临时项目 | "这条视频发出去会火吗?" | 一份报告 + 可回放的传播沙盘 |
|
||||
| **建模 + 复用** | 团队长期使用 | "我有一批 200 人的目标受众样本,每周给我跑 5 条新内容看哪个最容易出圈" | 同一批 agent 反复跑不同 initial_posts |
|
||||
| **多租户 SaaS**(v0.4 路线图) | 客户分账户 | "金融预测 / 舆情扩散 / 关系发展 各建一套独立沙盘" | 子账户体系 + 按次计费 + 项目隔离 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 系统架构
|
||||
## 2. 产品愿景:从工具到平台
|
||||
|
||||
### 2.1 v0.3 现状(已实现)
|
||||
|
||||
一个**单租户**的端到端预测流水线:上传文档 → 图谱 → 人设 → 配置 → 模拟 → 报告 → 回放 → 互动。
|
||||
|
||||
### 2.2 v0.4 目标(下一个大版本)
|
||||
|
||||
**Foresight 升级为多领域可定制的预测系统。** 不再只服务"舆情扩散"一个场景,而是抽象为**"任何可被多 agent 互动建模的预测问题"**:
|
||||
|
||||
| 应用领域 | 输入信号 | Agent 类型 | 预测输出 |
|
||||
|---|---|---|---|
|
||||
| **内容传播预测** | 视频逐字稿 / 帖子文案 | 200 个画像各异的目标受众 | 触达率 / 互动率 / 传播路径 / 爆款概率 |
|
||||
| **舆情扩散预测** | 突发事件 / 政策草案 | 不同立场的意见领袖 + 普通群众 | 舆论走向 / 关键拐点 / 情绪曲线 |
|
||||
| **金融市场预测** | 财报 / 政策 / 黑天鹅事件 | 散户 / 机构 / 量化 / 媒体 | 价格反应 / 资金流向 / 板块联动 |
|
||||
| **人际关系发展** | 角色背景 / 起始事件 | 故事中的所有角色 | 关系演化树 / 关键转折 / 多结局 |
|
||||
| **品牌危机推演** | 危机事件 + 公关方案 | 用户 / 媒体 / 监管 / 竞品 | 不同应对策略下的舆情走向 |
|
||||
|
||||
**核心抽象**:每个领域 = 一组(领域语料 + 领域 agent 库 + 领域平台规则 + 领域评估指标)。Foresight 提供**通用的工作流编排**,领域知识用配置即可注入。
|
||||
|
||||
### 2.3 v0.5 商业化(远期)
|
||||
|
||||
- 子账户体系(org / user / project 三级权限)
|
||||
- 按模拟次数 / agent 数 / 报告深度计费
|
||||
- 模型 fork & 对比模式(A/B 内容对比传播效率)
|
||||
- 数据隔离 + 合规审计
|
||||
|
||||
---
|
||||
|
||||
## 3. 系统架构(v0.3 实际部署)
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ M-flow(记忆系统) │
|
||||
│ 踩坑/经验/凭证记录 │
|
||||
└─────────────────────┘
|
||||
用户浏览器
|
||||
│
|
||||
├── 前端 (Vue 3 + Vite)
|
||||
│ 部署: 腾讯云 COS + CDN
|
||||
│ 域名: foresight.yizhou.chat
|
||||
├── 前端 (Vue 3 + Vite)
|
||||
│ 部署:腾讯云 COS + CDN
|
||||
│ 域名:foresight.yizhou.chat
|
||||
│ 新增页面:/simulation/:id/replay(Manus 式过程回放)
|
||||
│
|
||||
└── 后端 (Flask + Python)
|
||||
端口: 5001
|
||||
└── HTTPS → api.foresight.yizhou.chat (Nginx)
|
||||
│
|
||||
├── LLM API (MiniMax M2.7 Highspeed)
|
||||
│ 用途: 本体生成、画像生成、配置生成、报告生成
|
||||
│
|
||||
└── Zep Cloud API
|
||||
用途: 知识图谱存储、搜索、记忆更新
|
||||
└── Backend Flask (5001) 服务器:腾讯云 2C8G
|
||||
│ OS:Ubuntu 24.04
|
||||
│ Python:3.11.15 (uv 管理)
|
||||
│ venv:/opt/foresight/backend/.venv-311
|
||||
│
|
||||
├── LLM API:智谱 GLM-4-Flash (之前用 MiniMax M2.7,已弃)
|
||||
│ 用途:本体、画像、配置、报告、模拟决策
|
||||
│ Endpoint:https://open.bigmodel.cn/api/paas/v4/
|
||||
│
|
||||
├── Knowledge Graph:Graphiti + Neo4j (之前用 Zep Cloud,已弃)
|
||||
│ 部署:Docker 容器 neo4j:5.26-community
|
||||
│ Embedding:BAAI/bge-m3 via SiliconFlow
|
||||
│ Graphiti LLM:Qwen2.5-32B via SiliconFlow
|
||||
│
|
||||
├── HF Hub Mirror:hf-mirror.com OASIS 推荐模型 twhin-bert-base
|
||||
│
|
||||
└── OASIS 模拟引擎 fork 自 camel-oasis 0.2.5
|
||||
支持 Twitter / Reddit 双平台并行
|
||||
🚧 国内平台抽象层:v0.4 路线图(抖音/视频号/小红书/微博)
|
||||
```
|
||||
|
||||
### 3.1 关键基础设施决策(v0.3 沉淀的经验)
|
||||
|
||||
| 决策点 | 当前选择 | 弃用方案 | 原因 |
|
||||
|---|---|---|---|
|
||||
| LLM | 智谱 GLM-4-Flash | MiniMax M2.7 / GPT-4o-mini | Flash 单次延迟 0.5-1s,是 OASIS 高吞吐场景的最优解 |
|
||||
| 知识图谱 | Graphiti + 自托管 Neo4j | Zep Cloud | 摆脱外部依赖、可控、免月费 |
|
||||
| Python | 3.11(uv 管理) | 系统 3.12 | camel-oasis<3.12 不兼容 3.12 |
|
||||
| 包源 | 腾讯云 PyPI 镜像 | 直连 PyPI | 国内服务器直连慢 100x |
|
||||
| HF 模型源 | hf-mirror.com | huggingface.co | 国内服务器连不上 hf 主站 |
|
||||
| Agent 数甜点 | **200 agents** | 503(默认) | 8G 内存上限 + 95% 置信区间足够 |
|
||||
| 运行参数 | semaphore=100 / 双平台 | semaphore=30 / 单平台 | 8G 升级后可承载 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 核心功能流程(5 步流水线)
|
||||
## 4. 核心功能流水线(5 步 + 1 回放)
|
||||
|
||||
### Step 1: 图谱构建
|
||||
### Step 1: 知识图谱构建
|
||||
|
||||
**输入**: 用户上传文档(PDF/MD/TXT)+ 模拟需求描述
|
||||
**输入**:上传文档(PDF/MD/TXT/DOCX)+ 模拟需求自然语言描述
|
||||
|
||||
**流程**:
|
||||
**流程**:
|
||||
1. 文档解析 → 文本提取
|
||||
2. LLM 分析文档 → 生成本体(10 个实体类型 + 6-10 个关系类型)
|
||||
3. 文本分块 → 批量导入 Zep → 构建知识图谱
|
||||
2. LLM 分析全文 → 生成本体(10 个实体类型 + 6-10 个关系类型)
|
||||
3. 文本分块 → 批量调用 Graphiti → 写入 Neo4j
|
||||
4. 返回图谱可视化(节点 + 边)
|
||||
|
||||
**API 端点**:
|
||||
- `POST /api/graph/ontology/generate` — 本体生成
|
||||
- `POST /api/graph/build` — 图谱构建
|
||||
- `GET /api/graph/task/<task_id>` — 构建进度查询
|
||||
**API**:
|
||||
- `POST /api/graph/ontology/generate`
|
||||
- `POST /api/graph/build`
|
||||
- `GET /api/graph/task/<task_id>`
|
||||
|
||||
**Token 消耗**:
|
||||
| 服务 | 小文档 (10 实体) | 大文档 (50 实体) |
|
||||
|------|------------------|------------------|
|
||||
| LLM (本体生成) | 3K-8K | 5K-12K |
|
||||
| Zep (图谱构建) | 5K-10K | 20K-50K |
|
||||
### Step 2: Agent 人设生成
|
||||
|
||||
### Step 2: 环境配置
|
||||
**输入**:已构建的知识图谱
|
||||
|
||||
**输入**: 已构建的知识图谱
|
||||
**流程**:
|
||||
1. 从 Neo4j 读取图谱实体与关系
|
||||
2. 按实体类型筛选,调用 LLM 为每个实体生成 OASIS Agent Profile
|
||||
3. 每个 profile 含:人设故事 / MBTI / 年龄 / 职业 / 兴趣话题 / 活跃时段 / 互动倾向
|
||||
4. 实时写入 `reddit_profiles.json` 和 `twitter_profiles.csv`
|
||||
|
||||
**流程**:
|
||||
1. 从 Zep 读取图谱实体和关系
|
||||
2. 按实体类型筛选,为每个实体生成 Agent 画像(LLM)
|
||||
3. 生成模拟配置:时间线、事件、Agent 活动参数、平台配置
|
||||
**新功能**(v0.3 新增):
|
||||
- **加速完成按钮**:右上角"加速完成",用户可在生成到任意数量时立即停止剩余生成,使用已生成的 profile 进入下一步
|
||||
|
||||
**API 端点**:
|
||||
- `POST /api/simulation/prepare` — 准备模拟环境
|
||||
**API**:`POST /api/simulation/prepare`
|
||||
|
||||
**Token 消耗**:
|
||||
| 服务 | 小规模 (10 实体) | 大规模 (50 实体) |
|
||||
|------|------------------|------------------|
|
||||
| LLM (画像生成) | 20K-40K | 50K-100K |
|
||||
| LLM (配置生成) | 20K-35K | 40K-50K |
|
||||
| Zep (实体读取) | 1K-2K | 5K-10K |
|
||||
### Step 3: 模拟配置生成
|
||||
|
||||
### Step 3: 模拟运行
|
||||
**输入**:profiles + 模拟需求 + 文档原文
|
||||
|
||||
**输入**: Agent 画像 + 模拟配置
|
||||
**流程**:
|
||||
1. LLM 智能生成时间配置(peak hours / off-peak hours / 活跃度系数)
|
||||
2. LLM 智能生成事件配置(initial_posts 列表 + 轮次事件)
|
||||
3. LLM 为每个 agent 分配活跃时段、互动概率
|
||||
4. 输出 `simulation_config.json`
|
||||
|
||||
**流程**:
|
||||
1. 创建虚拟社交平台环境
|
||||
2. Agent 按配置执行社交行为(发帖、评论、转发、点赞等)
|
||||
3. 实时记录互动日志
|
||||
4. 可选:将 Agent 行为写回 Zep 图谱(记忆更新)
|
||||
### Step 4: 双平台模拟运行
|
||||
|
||||
**API 端点**:
|
||||
- `POST /api/simulation/run` — 启动模拟
|
||||
- `GET /api/simulation/status/<sim_id>` — 查询状态
|
||||
- `GET /api/simulation/history` — 历史记录
|
||||
**输入**:profiles + simulation_config
|
||||
|
||||
**Token 消耗**:
|
||||
| 服务 | 说明 |
|
||||
|------|------|
|
||||
| Zep (记忆更新,可选) | 每个 Agent 动作 100-300 tokens,大规模模拟可达 1M+ |
|
||||
**流程**:
|
||||
1. 启动 OASIS Twitter env + Reddit env 并行(asyncio.gather)
|
||||
2. 每轮按时间窗口激活若干 agent
|
||||
3. 每个激活的 agent 调用 LLM 生成行为(发帖 / 评论 / 点赞 / 转发 / 关注)
|
||||
4. 实时写入 `twitter/actions.jsonl` 和 `reddit/actions.jsonl`
|
||||
5. 每平台限制 semaphore=100 防止 API 过载
|
||||
|
||||
### Step 4: 报告生成
|
||||
**性能指标**(200 agents 双平台 8G 服务器):
|
||||
- 启动 + tokenizer 加载:~30-60s
|
||||
- 每轮:~30-60s(取决于活跃 agent 数)
|
||||
- 15 rounds 完整跑完:~10-15 分钟
|
||||
|
||||
**输入**: 模拟结果 + 知识图谱
|
||||
**API**:
|
||||
- `POST /api/simulation/start` — 启动
|
||||
- `POST /api/simulation/stop` — 停止
|
||||
- `GET /api/simulation/<id>/run-status/detail` — 实时状态
|
||||
|
||||
**流程**:
|
||||
**新功能**(v0.3 新增):
|
||||
- **后端 SIGTERM 误杀子进程 bug 修复**:之前重启 Flask 会连带杀掉正在跑的模拟,已通过让 `register_cleanup` 变为 no-op 修复,现在可以热更新后端代码不影响运行中的模拟。
|
||||
|
||||
### Step 5: 报告生成
|
||||
|
||||
**输入**:完整模拟结果 + 知识图谱
|
||||
|
||||
**流程**:
|
||||
1. LLM 规划报告大纲(5 个章节)
|
||||
2. 每个章节使用 ReACT 循环(推理→工具调用→生成)
|
||||
3. 工具调用包括:图谱搜索(InsightForge/Panorama)、节点详情查询
|
||||
4. 最终输出结构化分析报告
|
||||
2. 每章节 ReACT 循环(推理 → 工具调用 → 生成)
|
||||
3. 工具:图谱搜索(InsightForge / Panorama)/ 节点详情 / agent 行为统计
|
||||
4. 输出结构化 Markdown 报告
|
||||
|
||||
**API 端点**:
|
||||
- `POST /api/report/generate` — 生成报告
|
||||
**API**:`POST /api/report/generate`
|
||||
|
||||
**Token 消耗**:
|
||||
| 服务 | 小规模 | 大规模 |
|
||||
|------|--------|--------|
|
||||
| LLM (ReACT 多轮) | 50K-80K | 100K-150K |
|
||||
| Zep (图谱搜索) | 5K-10K | 10K-20K |
|
||||
> Token 消耗最大的环节,单次完整报告约 80-150K tokens。
|
||||
|
||||
> 报告生成是整个流水线中**Token 消耗最大**的环节。
|
||||
### Step 6(新增): Manus 式过程回放
|
||||
|
||||
### Step 5: 交互问答
|
||||
**v0.3 新增的核心功能**。把整个 Foresight 工作流(Step 1-5)做成可拖拽 / 可播放的可视化时间线,方便给客户、合作方、自己复盘演示。
|
||||
|
||||
**输入**: 用户问题
|
||||
**界面布局**:
|
||||
|
||||
**流程**:
|
||||
1. 用户自由提问
|
||||
2. 系统结合图谱搜索 + LLM 回答
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ ← 返回 foresight 回放 sim_xxxx [running] │
|
||||
├─────────┬──────────────────────────────┬────────────┤
|
||||
│ 工作流 │ 当前动作(大卡片) │ 全局统计 │
|
||||
│ 时间线 │ ┌─────────────────────┐ │ │
|
||||
│ │ │ 👤 90后 (#6) │ │ 总动作 N │
|
||||
│ ● 步骤1 │ │ 📱 reddit │ │ rounds 8 │
|
||||
│ │ 文档 │ │ ✏️ CREATE_POST │ │ │
|
||||
│ │ │ │ "芒格说复利..." │ │ 类型分布 │
|
||||
│ ● 步骤2 │ └─────────────────────┘ │ POST 85%│
|
||||
│ │ 图谱 │ │ LIKE 12%│
|
||||
│ │ │ 动作流(滚动 30 条) │ │
|
||||
│ ● 步骤3 │ ┌─────────────────────┐ │ Top Agents │
|
||||
│ │ 人设 │ │ r03 90后 ✏️ ... │ │ 90后 8 │
|
||||
│ │ │ │ r03 70后 💬 ... │ │ 00后 6 │
|
||||
│ ● 步骤4 │ │ r04 AI 📤 ... │ │ │
|
||||
│ │ 配置 │ └─────────────────────┘ │ 平台分布 │
|
||||
│ │ │ │ TW 17 RD 8 │
|
||||
│ ● 步骤5 │ │ │
|
||||
│ 模拟 │ │ │
|
||||
├─────────┴──────────────────────────────┴────────────┤
|
||||
│ ⏮ ▶ ⏭ ━━━━━●───── Round 8/15 Day 1 08:00 │
|
||||
│ 速度 0.5x · 1x · 2x · 5x · 10x │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**API 端点**:
|
||||
- `POST /api/report/chat` — 实时问答
|
||||
**核心能力**:
|
||||
- 左栏:5 步工作流时间线(带状态 + 元数据)
|
||||
- 中上:当前动作大卡片(agent 头像 + 内容 + 平台/类型 tag)
|
||||
- 中下:动作流滚动(最近 30 条,可点击跳转)
|
||||
- 右栏:聚合统计(总数 / 类型分布 bar / Top 8 agents / 平台分布卡)
|
||||
- 底部:scrubber 时间轴 + 播放控件 + 5 档速度
|
||||
|
||||
**Token 消耗**: 每条消息 2K-4K tokens (LLM + Zep)
|
||||
**数据源**:`GET /api/simulation/:id/replay` 一次性返回所有数据,前端无需多次请求。
|
||||
|
||||
**最新 run 过滤**:actions.jsonl 是 append-only 文件,多次 run 会累积。后端通过扫描最近一次 `simulation_start` 事件的时间戳,过滤掉历史 run 残留 actions,保证回放只显示最新一次。
|
||||
|
||||
---
|
||||
|
||||
## 5. Token 消耗总览
|
||||
## 4.5 Token 用量追踪与成本估算(v0.3 新增 · 内部用)
|
||||
|
||||
### 单次完整流水线估算
|
||||
为了支持后续定价决策和成本控制,v0.3 新增 token 追踪模块。**销售给客户的版本需要移除此 blueprint 注册**(去掉 `app/__init__.py` 里的 `usage_bp` 即可)。
|
||||
|
||||
| 阶段 | 主要 API | 10 实体 | 50 实体 |
|
||||
|------|----------|---------|---------|
|
||||
| 图谱构建 | LLM + Zep | 8K-18K | 25K-62K |
|
||||
| 环境配置 | LLM + Zep | 41K-77K | 95K-160K |
|
||||
| 模拟运行 | Zep (可选) | 0-100K | 0-1M+ |
|
||||
| 报告生成 | LLM + Zep | 55K-90K | 110K-170K |
|
||||
| **合计 (不含模拟记忆)** | | **~100K-185K** | **~230K-392K** |
|
||||
### 工作机制
|
||||
|
||||
### API 费用构成
|
||||
- `backend/app/utils/token_tracker.py` 提供进程内全局 stage→model→tokens 计数器
|
||||
- `LLMClient` 每次 `chat()` 自动 record 一次 `usage.prompt_tokens / completion_tokens`
|
||||
- 各 API 端点在入口处 `token_tracker.set_stage("step1_ontology")` 等
|
||||
- 价格表内置在 `PRICING` 字典,覆盖 GLM / SiliconFlow / MiniMax / OpenAI / Anthropic 主流模型
|
||||
|
||||
| 外部服务 | 用途 | 计费方式 |
|
||||
|----------|------|----------|
|
||||
| **MiniMax M2.7 Highspeed** | 所有 LLM 推理(本体/画像/配置/报告/问答) | 按 token 计费 |
|
||||
| **Zep Cloud** | 知识图谱(存储/搜索/记忆更新) | 按 API 调用计费 |
|
||||
### Stage 命名
|
||||
|
||||
| Stage | 触发位置 | 说明 |
|
||||
|---|---|---|
|
||||
| `step1_ontology` | `POST /api/graph/ontology/generate` | 文档分析与本体生成 |
|
||||
| `step2_graph_build` | `POST /api/graph/build` | Graphiti 图谱构建(含 LLM 提取实体) |
|
||||
| `step3_prepare` | `POST /api/simulation/prepare` | Profile 生成 + 模拟配置生成 |
|
||||
| `step4_simulation` | OASIS 子进程(**不在 Flask 内**) | 需要走 `estimate-simulation` API 估算 |
|
||||
| `step5_report` | `POST /api/report/generate` | 报告 ReACT 多轮调用 |
|
||||
|
||||
### API
|
||||
|
||||
| 端点 | 用途 |
|
||||
|---|---|
|
||||
| `GET /api/usage/summary` | 当前累计统计:每个 stage 的 prompt/completion tokens + 估算成本(CNY) |
|
||||
| `POST /api/usage/reset` | 清空(可指定 stage) |
|
||||
| `GET /api/usage/estimate-simulation?rounds=15&active_agents_per_round=10` | 估算 OASIS 模拟成本(无法精确测) |
|
||||
| `POST /api/usage/set-stage` | 手动切 stage(测试用) |
|
||||
|
||||
### 局限
|
||||
|
||||
1. **OASIS 模拟子进程的 LLM 调用无法被 Flask 进程的 tracker 捕获**(camel-ai 用自己的 client)。需要走 estimate API 用经验公式估算。
|
||||
2. **进程重启会丢失数据**。如需持久化,加 `reset()` 前 dump 到 JSON 文件即可。
|
||||
3. **价格表是 2026-04 价格快照**,需要定期更新 `PRICING` 字典。
|
||||
|
||||
### 典型 Foresight 单次完整流水线成本估算(200 agents / 15 rounds / GLM-4-Flash)
|
||||
|
||||
| Stage | Tokens 范围 | CNY 估算 |
|
||||
|---|---|---|
|
||||
| Step 1 本体生成 | 5K-15K | 0.001-0.003 |
|
||||
| Step 2 图谱构建 | 50K-200K | 0.005-0.020 |
|
||||
| Step 3 Profile + Config | 100K-300K | 0.010-0.030 |
|
||||
| Step 4 模拟 (15 rounds) | 1M-3M | 0.10-0.30 |
|
||||
| Step 5 报告生成 | 80K-150K | 0.008-0.015 |
|
||||
| **合计** | **~1.2M-3.7M** | **~0.12-0.37 元** |
|
||||
|
||||
> 关键洞察:**模拟运行 (Step 4) 占总成本的 80%+**,但因为 GLM-4-Flash 单价极低,单次完整流水线 < 0.5 元 RMB。这给定价留了巨大空间:以成本 0.5 元 / 次,售价 5-50 元 / 次给客户都是合理的(取决于客户类型与定制化程度)。
|
||||
|
||||
---
|
||||
|
||||
## 5. v0.4 路线图(下一个大版本要做的事)
|
||||
|
||||
按优先级:
|
||||
|
||||
### P0:国内平台抽象层
|
||||
|
||||
**痛点**:当前 Twitter + Reddit 是欧美社交语境,国内 IP / 内容 / 客户都不匹配。
|
||||
|
||||
**目标**:支持抖音 / 视频号 / 小红书 / 微博 / 公众号 5 个国内平台。
|
||||
|
||||
**两条路径**:
|
||||
|
||||
| 路径 A(快速 MVP,2-3 周) | 路径 B(真模拟,1-2 月) |
|
||||
|---|---|
|
||||
| 基于 OASIS Reddit 模式 fork 一份"通用国内平台"虚拟实现 | 抛弃 OASIS,自研 platform engine |
|
||||
| 不真正模拟抖音 ML 算法,用参数化传播模型 | 真模拟抖音 FYP / 视频号双引擎 / 小红书 tag 聚类 |
|
||||
| LLM 决定 agent 互动 + 配置文件定义平台规则参数 | 行业报告训练参数 + 黑盒推荐算法逼近 |
|
||||
| 可申请客户付费试点 | 可申请专利 / 学术发表 |
|
||||
|
||||
**先走路径 A**,3 周内可演示。客户付费数据反哺路径 B。
|
||||
|
||||
**配置形态(设计稿)**:
|
||||
|
||||
```json
|
||||
{
|
||||
"platforms": [
|
||||
{
|
||||
"id": "douyin",
|
||||
"type": "short_video",
|
||||
"weight": 0.45,
|
||||
"rules": {
|
||||
"recommendation": "fyp_engagement_loop",
|
||||
"viral_threshold": 0.08,
|
||||
"interaction_types": ["like", "comment", "share", "follow", "watch_full"],
|
||||
"key_features": ["video_completion_rate", "comment_density", "share_velocity"]
|
||||
}
|
||||
},
|
||||
{ "id": "xiaohongshu", "type": "lifestyle_feed", "weight": 0.25, "rules": {...} },
|
||||
{ "id": "wechat_video", "type": "social_graph_video", "weight": 0.15, "rules": {...} },
|
||||
{ "id": "weibo", "type": "broadcast_micro_blog", "weight": 0.10, "rules": {...} },
|
||||
{ "id": "wechat_official", "type": "subscription_long_form", "weight": 0.05, "rules": {...} }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### P1:模型复用 / Fork 模拟
|
||||
|
||||
**痛点**:现在每次跑模拟都要走完 Step 1-4,重复劳动。
|
||||
|
||||
**目标**:基于已建好的 simulation 一键 fork 一个新版本,只换 `event_config.initial_posts` 即可。
|
||||
|
||||
**功能点**:
|
||||
- "Fork as Template" 按钮
|
||||
- 模板库:保存通过验证的 sim 作为预设
|
||||
- A/B 对比模式:两条新内容并排跑,结果并排展示
|
||||
|
||||
### P2:多租户 SaaS 改造
|
||||
|
||||
**功能模块**(需要派出 8 个并行 sub-agent 协同开发):
|
||||
|
||||
| Sub-Agent | 模块 | 任务 |
|
||||
|---|---|---|
|
||||
| Architect | 多租户架构 | PostgreSQL schema 隔离 / API gateway / RBAC 设计 |
|
||||
| Backend Engineer | API 改造 | 加 user_id / org_id / billing 字段,所有数据按租户隔离 |
|
||||
| Frontend Engineer | UI 改造 | 登录 / 注册 / dashboard / 子账户管理界面 |
|
||||
| Platform Engine | 平台抽象 | 上面 P0 的国内平台抽象层落地 |
|
||||
| Auth & Security | 认证 | 接 Better Auth / OAuth / API key 发放 |
|
||||
| Billing | 计费 | 接微信 / 支付宝 / Stripe,按模拟次数 / agent 数计费 |
|
||||
| DevOps | 容器化 | Docker + k8s + 监控 + 扩容策略 |
|
||||
| PM/Critic | 全程 review | 商业目标对齐 + 架构 review |
|
||||
|
||||
### P3:稳定性 / 运维改进
|
||||
|
||||
- ✅ Backend SIGTERM 误杀子进程 bug(v0.3 已修)
|
||||
- ⏳ 子进程心跳超时:当前 simulation 子进程挂掉后 state 不会自动更新成 failed,需要加心跳检测
|
||||
- ⏳ 模拟运行内存预算检查:启动前根据 agent 数 + 平台数预估内存,超出可用内存时拒绝启动
|
||||
- ⏳ Replay actions.jsonl 自动清理:每次新 run 启动前清空旧文件,避免历史残留
|
||||
- ⏳ 多模拟并发支持:单服务器同时跑 2-3 个 sim(需要更大内存或更精细资源调度)
|
||||
|
||||
---
|
||||
|
||||
## 6. 技术栈
|
||||
|
||||
### 前端
|
||||
|
||||
| 技术 | 版本 | 用途 |
|
||||
|------|------|------|
|
||||
| Vue 3 | 3.x | UI 框架 |
|
||||
|---|---|---|
|
||||
| Vue 3 | 3.5+ | UI 框架(Composition API + script setup) |
|
||||
| Vite | 7.x | 构建工具 |
|
||||
| Vue Router 4 | - | 路由 |
|
||||
| vue-i18n | 9.x | 中英双语 |
|
||||
| D3.js / Force Graph | - | 图谱可视化 |
|
||||
| 字体 | Space Grotesk + Noto Sans SC + JetBrains Mono | 设计语言 |
|
||||
|
||||
### 后端
|
||||
|
||||
| 技术 | 版本 | 用途 |
|
||||
|------|------|------|
|
||||
| Python | 3.x | 运行时 |
|
||||
| Flask | - | Web 框架 |
|
||||
| OpenAI SDK | - | LLM 客户端(兼容 MiniMax) |
|
||||
| zep-cloud | 3.13.0 | Zep 知识图谱 SDK |
|
||||
|---|---|---|
|
||||
| Python | 3.11.15 (uv 管理) | 运行时 |
|
||||
| Flask | 3.1.3 | Web 框架 |
|
||||
| OpenAI SDK | - | LLM 客户端(兼容智谱/MiniMax/SiliconFlow) |
|
||||
| camel-ai | 0.2.78 | OASIS 依赖 |
|
||||
| camel-oasis | 0.2.5 | 社交模拟引擎 |
|
||||
| graphiti-core | - | Neo4j 图谱构建框架 |
|
||||
| transformers + torch | - | OASIS 内置 twhin-bert-base |
|
||||
| neo4j-driver | - | Neo4j 客户端 |
|
||||
|
||||
### 部署
|
||||
|
||||
| 组件 | 平台 | 说明 |
|
||||
|------|------|------|
|
||||
| 前端静态文件 | 腾讯云 COS + CDN | foresight.yizhou.chat |
|
||||
| SSL 证书 | Let's Encrypt | 通过 acme.sh 签发 |
|
||||
| 后端 API | 待部署 | 需要云服务器运行 Flask |
|
||||
|---|---|---|
|
||||
| 前端静态 | 腾讯云 COS + CDN | foresight.yizhou.chat |
|
||||
| SSL 证书 | acme.sh / Let's Encrypt | 自动续期 |
|
||||
| 后端 | 腾讯云轻量 2C8G Ubuntu 24.04 | api.foresight.yizhou.chat → 127.0.0.1:5001 |
|
||||
| Neo4j | Docker container neo4j:5.26-community | bolt://localhost:7687 |
|
||||
| 进程管理 | nohup + disown(无 systemd / 无 docker) | 启动命令见下 |
|
||||
|
||||
**Backend 启动命令**:
|
||||
|
||||
```bash
|
||||
cd /opt/foresight/backend && \
|
||||
nohup ./.venv-311/bin/python run.py --host 0.0.0.0 \
|
||||
>> logs/server.log 2>&1 < /dev/null & \
|
||||
disown
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 配置项
|
||||
|
||||
```env
|
||||
# LLM 配置
|
||||
LLM_API_KEY=<MiniMax API Key>
|
||||
LLM_BASE_URL=https://api.minimax.chat/v1
|
||||
LLM_MODEL_NAME=MiniMax-M2.5
|
||||
# ========== LLM ==========
|
||||
# 智谱 GLM-4-Flash(高吞吐低延迟,OASIS 模拟首选)
|
||||
LLM_API_KEY=<智谱 API Key>
|
||||
LLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4/
|
||||
LLM_MODEL_NAME=glm-4-flash
|
||||
# 可选升级:glm-4-flashx / glm-4-air / glm-4-plus(同 endpoint,质量↑速度↓)
|
||||
|
||||
# Zep 配置
|
||||
ZEP_API_KEY=<Zep Cloud API Key>
|
||||
# 双 LLM 加速(可选,让两平台用不同 provider 分摊 RPM)
|
||||
LLM_BOOST_API_KEY=
|
||||
LLM_BOOST_BASE_URL=
|
||||
LLM_BOOST_MODEL_NAME=
|
||||
|
||||
# 服务端口
|
||||
FLASK_PORT=5001
|
||||
# ========== Neo4j(自托管 Graphiti 后端) ==========
|
||||
NEO4J_URI=bolt://localhost:7687
|
||||
NEO4J_USER=neo4j
|
||||
NEO4J_PASSWORD=<密码>
|
||||
|
||||
# ========== Graphiti LLM(图谱构建) ==========
|
||||
GRAPHITI_LLM_API_KEY=<SiliconFlow API Key>
|
||||
GRAPHITI_LLM_BASE_URL=https://api.siliconflow.cn/v1
|
||||
GRAPHITI_LLM_MODEL=Qwen/Qwen2.5-32B-Instruct
|
||||
|
||||
# ========== Embedding ==========
|
||||
EMBEDDING_API_KEY=<SiliconFlow API Key>
|
||||
EMBEDDING_BASE_URL=https://api.siliconflow.cn/v1
|
||||
EMBEDDING_MODEL=BAAI/bge-m3
|
||||
|
||||
# ========== HuggingFace 镜像(必填,国内服务器) ==========
|
||||
HF_ENDPOINT=https://hf-mirror.com
|
||||
|
||||
# ========== Flask ==========
|
||||
FLASK_DEBUG=False
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 当前状态与待办
|
||||
## 8. v0.3 当前状态与已交付
|
||||
|
||||
### 已完成
|
||||
- [x] 前端 UI(Vue 3,支持明暗主题)
|
||||
- [x] 品牌迁移(MiroFish → Foresight 先见之明)
|
||||
- [x] 前端部署(腾讯云 COS + CDN + HTTPS)
|
||||
- [x] Dark Mode 全屏响应式布局
|
||||
- [x] 国际化支持(中/英)
|
||||
### v0.3 已完成(本次大版本前的全部更新)
|
||||
|
||||
### 待完成
|
||||
- [ ] **后端云部署**:当前后端只能在本地运行(localhost:5001),需部署到腾讯云 CVM 或轻量服务器
|
||||
- [ ] **前端 API 地址配置**:设置 `VITE_API_BASE_URL` 指向云端后端
|
||||
- [ ] **图谱生成功能验证**:端到端测试完整流水线
|
||||
- [ ] **模拟结果持久化**:当前模拟结果存在内存中,需接入数据库
|
||||
- [ ] **用户认证**:多用户场景下的身份管理
|
||||
#### 基础设施迁移
|
||||
- [x] 从 Zep Cloud → 自托管 Graphiti + Neo4j(脱离外部 SaaS 依赖)
|
||||
- [x] LLM 从 MiniMax M2.7 → 智谱 GLM-4-Flash(速度提升 2-5x)
|
||||
- [x] Python 3.12 → 3.11.15(uv 管理 .venv-311,解决 camel-oasis 兼容性)
|
||||
- [x] 服务器从 2C4G → 2C8G(解决 OOM 崩溃)
|
||||
- [x] 加 4G swap → 总可用内存 ~13G
|
||||
- [x] 配置 hf-mirror.com(解决 twhin-bert-base 下载卡死)
|
||||
|
||||
#### 性能优化
|
||||
- [x] semaphore 30 → 100(每轮 2-3x 加速)
|
||||
- [x] 找到 200 agents 内存/性能/统计置信度甜点
|
||||
- [x] 双平台并行(Twitter + Reddit asyncio.gather)
|
||||
|
||||
#### 新功能
|
||||
- [x] **Step 2 加速完成按钮**:profile 生成中可一键停止剩余、用已有进入下一步
|
||||
- [x] **Manus 式过程回放界面**(核心 v0.3 交付)
|
||||
- 后端 `GET /api/simulation/:id/replay` 一次性返回全部回放数据
|
||||
- 前端 `/simulation/:id/replay` 三栏布局 + scrubber + 5 档播放速度
|
||||
- 工作流时间线 + 当前动作卡 + 滚动 feed + 聚合统计
|
||||
- 自动过滤历史 run 残留 actions
|
||||
|
||||
#### Bug 修复
|
||||
- [x] **Backend SIGTERM 误杀模拟子进程 bug**:之前重启 Flask 会连带杀子进程,现已修复,可热更新后端代码不影响正在跑的模拟
|
||||
- [x] CORS / Neo4j Query / DateTime 序列化等历史 bug
|
||||
|
||||
#### 文档与记忆系统
|
||||
- [x] 部署信息全部归档到 M-flow(infra / lessons / services / credentials)
|
||||
- [x] PRD.md / README.md 重写
|
||||
|
||||
### v0.4 待完成(路线图详见 §5)
|
||||
|
||||
- [ ] **国内平台抽象层**(P0)— 抖音 / 视频号 / 小红书 / 微博 / 公众号
|
||||
- [ ] **Fork 模拟 + A/B 对比**(P1)— 模型复用与对比演化
|
||||
- [ ] **多租户 SaaS 改造**(P2)— 子账户体系 + 计费 + 数据隔离
|
||||
- [ ] **稳定性运维**(P3)— 子进程心跳 / 内存预算检查 / 自动清理
|
||||
|
||||
---
|
||||
|
||||
## 9. 关键文件索引
|
||||
|
||||
### 后端
|
||||
|
||||
| 路径 | 用途 |
|
||||
|------|------|
|
||||
| `frontend/src/api/index.js` | API 客户端配置(baseURL) |
|
||||
| `frontend/src/views/Process.vue` | 图谱构建主界面 |
|
||||
|---|---|
|
||||
| `backend/run.py` | Flask 入口 |
|
||||
| `backend/app/__init__.py` | Flask 初始化(注意:v0.3 已移除 register_cleanup 的破坏性 signal handler) |
|
||||
| `backend/app/api/graph.py` | 图谱构建 API |
|
||||
| `backend/app/api/simulation.py` | 模拟 / prepare / start / stop / **replay**(新增 line ~2005) |
|
||||
| `backend/app/api/report.py` | 报告生成 API |
|
||||
| `backend/app/services/ontology_generator.py` | LLM 本体生成 |
|
||||
| `backend/app/services/graph_builder.py` | Graphiti / Neo4j 图谱构建 |
|
||||
| `backend/app/services/oasis_profile_generator.py` | Agent 画像生成(含加速完成 cancel_check 逻辑) |
|
||||
| `backend/app/services/simulation_config_generator.py` | 模拟配置生成 |
|
||||
| `backend/app/services/simulation_manager.py` | 模拟管理(含 accelerate flag) |
|
||||
| `backend/app/services/simulation_runner.py` | 模拟运行(含 v0.3 register_cleanup 修复) |
|
||||
| `backend/app/services/report_agent.py` | 报告 ReACT 生成 |
|
||||
| `backend/scripts/run_parallel_simulation.py` | 双平台并行模拟脚本(semaphore=100) |
|
||||
|
||||
### 前端
|
||||
|
||||
| 路径 | 用途 |
|
||||
|---|---|
|
||||
| `frontend/src/router/index.js` | 路由(v0.3 新增 `/simulation/:id/replay`) |
|
||||
| `frontend/src/api/simulation.js` | 模拟相关 API client |
|
||||
| `frontend/src/views/SimulationReplayView.vue` | **v0.3 新增** Manus 式回放主界面 |
|
||||
| `frontend/src/views/SimulationView.vue` | 模拟运行界面 |
|
||||
| `frontend/src/views/ReportView.vue` | 报告查看界面 |
|
||||
| `backend/run.py` | 后端入口 |
|
||||
| `backend/app/api/graph.py` | 图谱相关 API 端点 |
|
||||
| `backend/app/api/simulation.py` | 模拟相关 API 端点 |
|
||||
| `backend/app/api/report.py` | 报告相关 API 端点 |
|
||||
| `backend/app/services/ontology_generator.py` | 本体生成服务(LLM) |
|
||||
| `backend/app/services/graph_builder.py` | 图谱构建服务(Zep) |
|
||||
| `backend/app/services/oasis_profile_generator.py` | Agent 画像生成(LLM + Zep) |
|
||||
| `backend/app/services/simulation_config_generator.py` | 模拟配置生成(LLM) |
|
||||
| `backend/app/services/report_agent.py` | 报告生成(ReACT,LLM + Zep) |
|
||||
| `backend/app/utils/llm_client.py` | LLM 客户端封装 |
|
||||
| `.env` | API Keys 和配置 |
|
||||
| `frontend/src/views/SimulationRunView.vue` | 模拟启动界面 |
|
||||
| `frontend/src/views/ReportView.vue` | 报告查看 |
|
||||
| `frontend/src/components/Step2EnvSetup.vue` | Step 2 环境设置(v0.3 新增加速完成按钮) |
|
||||
|
||||
### 部署 / 运维
|
||||
|
||||
| 路径 | 说明 |
|
||||
|---|---|
|
||||
| `.env` | API Keys 与配置(智谱 / Neo4j / SiliconFlow / HF mirror) |
|
||||
| `docker-compose.yml` | Neo4j 容器配置 |
|
||||
| `~/.claude/projects/.../memory/` | M-flow 记忆系统索引(OpenClaw 内部) |
|
||||
|
||||
### 服务器(远程)
|
||||
|
||||
| 路径 | 说明 |
|
||||
|---|---|
|
||||
| `/opt/foresight/backend/` | 后端代码 |
|
||||
| `/opt/foresight/backend/.venv-311/` | Python 3.11 虚拟环境(5.1G) |
|
||||
| `/opt/foresight/backend/uploads/simulations/sim_<id>/` | 单次模拟数据目录 |
|
||||
| `/opt/foresight/backend/logs/server.log` | Flask 日志 |
|
||||
| `/opt/foresight/.env` | 服务器配置 |
|
||||
| `/etc/nginx/sites-enabled/foresight-api` | Nginx 反代配置 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 已知限制与边界
|
||||
|
||||
| 限制 | 说明 | 缓解策略 |
|
||||
|---|---|---|
|
||||
| Agent 数 ≤ 200(双平台 2C8G) | 超过会 OOM | 升级 4C16G / 单平台 / 拆分 batch |
|
||||
| 国内平台未支持 | 当前只有 Twitter+Reddit | v0.4 P0 |
|
||||
| 单租户 | 多客户共用一套数据 | v0.4 P2 |
|
||||
| 子进程心跳缺失 | sim 挂了 state 仍是 running | v0.4 P3 |
|
||||
| 报告生成 Token 消耗大 | 单次 80-150K | 优化 prompt / 缓存图谱搜索结果 |
|
||||
| 重启 sim 需手动 reset state | 半死 state 阻塞下次启动 | v0.4 P3 加 force-restart 按钮 |
|
||||
|
||||
---
|
||||
|
||||
## 附录 A:v0.3 关键运维教训
|
||||
|
||||
1. **Python 版本约束必须 pre-flight 检查**:`requirements.txt` 改动后立刻验证 venv 兼容
|
||||
2. **国内服务器装包必走腾讯云镜像**:`uv pip install --index-url http://mirrors.tencentyun.com/pypi/simple --trusted-host mirrors.tencentyun.com`
|
||||
3. **HuggingFace 模型必配 `HF_ENDPOINT=https://hf-mirror.com`**:否则 OASIS 启动卡死
|
||||
4. **遇到模拟卡死先看 simulation.log 最后 10 行**,不要先猜 LLM 慢
|
||||
5. **8G 内存只能跑 200 agents 双平台**,503 会 OOM。要 503 双平台需升级 16G
|
||||
6. **重启 Flask 不再杀子进程**(v0.3 修复后),可以安全热更新代码
|
||||
7. **GLM-4-Flash 是 OASIS 场景的最优 LLM**:旗舰模型反而是反向优化(每次调用 2-5s 太慢)
|
||||
|
||||
## 附录 B:决策日志
|
||||
|
||||
| 日期 | 决策 | 原因 |
|
||||
|---|---|---|
|
||||
| 2026-04-13 | 从 Zep Cloud → Graphiti+Neo4j | 脱离外部依赖 |
|
||||
| 2026-04-14 | LLM 切 GLM-4-Flash | MiniMax 速度不够 |
|
||||
| 2026-04-14 | uv + Python 3.11 重建 venv | camel-oasis 不支持 3.12 |
|
||||
| 2026-04-14 | 服务器升级 2C8G | 3.6G 跑不动 OASIS |
|
||||
| 2026-04-14 | 配 hf-mirror.com | 服务器连不上 huggingface |
|
||||
| 2026-04-15 | 修复 SIGTERM 误杀 bug | 热更新后端不再中断模拟 |
|
||||
| 2026-04-15 | 200 agents 设为甜点 | 8G 容量 + 95% 置信度足够 |
|
||||
| 2026-04-15 | 上线 Manus 式 replay UI | 给客户演示 + 复盘工具 |
|
||||
| 2026-04-15 | v0.4 路线图:国内平台 + SaaS | 用户战略需求 |
|
||||
|
|
|
|||
11
README-ZH.md
11
README-ZH.md
|
|
@ -85,11 +85,12 @@ Foresight 致力于打造映射现实的群体智能镜像,通过捕捉个体
|
|||
|
||||
## 🔄 工作流程
|
||||
|
||||
1. **图谱构建**:现实种子提取 & 个体与群体记忆注入 & GraphRAG构建
|
||||
2. **环境搭建**:实体关系抽取 & 人设生成 & 环境配置Agent注入仿真参数
|
||||
3. **开始模拟**:双平台并行模拟 & 自动解析预测需求 & 动态更新时序记忆
|
||||
4. **报告生成**:ReportAgent拥有丰富的工具集与模拟后环境进行深度交互
|
||||
5. **深度互动**:与模拟世界中的任意一位进行对话 & 与ReportAgent进行对话
|
||||
1. **图谱构建**:现实种子提取 & 个体与群体记忆注入 & GraphRAG(Graphiti + Neo4j)构建
|
||||
2. **环境搭建**:实体关系抽取 & 人设生成 & 环境配置Agent注入仿真参数(支持「加速完成」按钮)
|
||||
3. **开始模拟**:双平台并行模拟(Twitter + Reddit) & 自动解析预测需求 & 动态更新时序记忆
|
||||
4. **报告生成**:ReportAgent 拥有丰富的工具集与模拟后环境进行深度交互
|
||||
5. **深度互动**:与模拟世界中的任意一位进行对话 & 与 ReportAgent 进行对话
|
||||
6. **过程回放(v0.3 新增)**:Manus 式可拖拽时间轴,回放整个工作流和每一轮 agent 行为,方便复盘和演示
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
|
|
|
|||
|
|
@ -85,11 +85,12 @@ Click the image to watch Foresight's deep prediction of the lost ending based on
|
|||
|
||||
## 🔄 Workflow
|
||||
|
||||
1. **Graph Building**: Seed extraction & Individual/collective memory injection & GraphRAG construction
|
||||
2. **Environment Setup**: Entity relationship extraction & Persona generation & Agent configuration injection
|
||||
3. **Simulation**: Dual-platform parallel simulation & Auto-parse prediction requirements & Dynamic temporal memory updates
|
||||
1. **Graph Building**: Seed extraction & Individual/collective memory injection & GraphRAG construction (Graphiti + Neo4j)
|
||||
2. **Environment Setup**: Entity relationship extraction & Persona generation & Agent configuration injection (with "Skip & Continue" button)
|
||||
3. **Simulation**: Dual-platform parallel simulation (Twitter + Reddit) & Auto-parse prediction requirements & Dynamic temporal memory updates
|
||||
4. **Report Generation**: ReportAgent with rich toolset for deep interaction with post-simulation environment
|
||||
5. **Deep Interaction**: Chat with any agent in the simulated world & Interact with ReportAgent
|
||||
6. **Process Replay (v0.3 New)**: Manus-style draggable timeline to replay the entire workflow and every round of agent actions — perfect for retros and demos
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
|
|
|
|||
|
|
@ -63,10 +63,11 @@ def create_app(config_class=Config):
|
|||
return response
|
||||
|
||||
# 注册蓝图
|
||||
from .api import graph_bp, simulation_bp, report_bp
|
||||
from .api import graph_bp, simulation_bp, report_bp, usage_bp
|
||||
app.register_blueprint(graph_bp, url_prefix='/api/graph')
|
||||
app.register_blueprint(simulation_bp, url_prefix='/api/simulation')
|
||||
app.register_blueprint(report_bp, url_prefix='/api/report')
|
||||
app.register_blueprint(usage_bp, url_prefix='/api/usage')
|
||||
|
||||
# 健康检查
|
||||
@app.route('/health')
|
||||
|
|
|
|||
|
|
@ -7,8 +7,10 @@ from flask import Blueprint
|
|||
graph_bp = Blueprint('graph', __name__)
|
||||
simulation_bp = Blueprint('simulation', __name__)
|
||||
report_bp = Blueprint('report', __name__)
|
||||
usage_bp = Blueprint('usage', __name__)
|
||||
|
||||
from . import graph # noqa: E402, F401
|
||||
from . import simulation # noqa: E402, F401
|
||||
from . import report # noqa: E402, F401
|
||||
from . import usage # noqa: E402, F401
|
||||
|
||||
|
|
|
|||
|
|
@ -154,7 +154,11 @@ def generate_ontology():
|
|||
simulation_requirement = request.form.get('simulation_requirement', '')
|
||||
project_name = request.form.get('project_name', 'Unnamed Project')
|
||||
additional_context = request.form.get('additional_context', '')
|
||||
|
||||
|
||||
# token 追踪:标记当前 stage
|
||||
from ..utils import token_tracker
|
||||
token_tracker.set_stage("step1_ontology")
|
||||
|
||||
logger.debug(f"项目名称: {project_name}")
|
||||
logger.debug(f"模拟需求: {simulation_requirement[:100]}...")
|
||||
|
||||
|
|
@ -282,7 +286,11 @@ def build_graph():
|
|||
"""
|
||||
try:
|
||||
logger.info("=== 开始构建图谱 ===")
|
||||
|
||||
|
||||
# token 追踪:标记当前 stage
|
||||
from ..utils import token_tracker
|
||||
token_tracker.set_stage("step2_graph_build")
|
||||
|
||||
# 检查配置
|
||||
errors = []
|
||||
if not Config.ZEP_API_KEY:
|
||||
|
|
|
|||
|
|
@ -48,8 +48,12 @@ def generate_report():
|
|||
}
|
||||
"""
|
||||
try:
|
||||
# token 追踪:报告生成阶段
|
||||
from ..utils import token_tracker
|
||||
token_tracker.set_stage("step5_report")
|
||||
|
||||
data = request.get_json() or {}
|
||||
|
||||
|
||||
simulation_id = data.get('simulation_id')
|
||||
if not simulation_id:
|
||||
return jsonify({
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ Step2: Zep实体读取与过滤、OASIS模拟准备与运行(全程自动化
|
|||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import csv
|
||||
import traceback
|
||||
from flask import request, jsonify, send_file
|
||||
|
||||
|
|
@ -401,8 +403,12 @@ def prepare_simulation():
|
|||
import os
|
||||
from ..models.task import TaskManager, TaskStatus
|
||||
from ..config import Config
|
||||
|
||||
from ..utils import token_tracker
|
||||
|
||||
try:
|
||||
# token 追踪:profile + config 生成阶段统一归到 step3_prepare
|
||||
token_tracker.set_stage("step3_prepare")
|
||||
|
||||
data = request.get_json() or {}
|
||||
|
||||
simulation_id = data.get('simulation_id')
|
||||
|
|
@ -639,6 +645,53 @@ def prepare_simulation():
|
|||
}), 500
|
||||
|
||||
|
||||
@simulation_bp.route('/prepare/accelerate', methods=['POST'])
|
||||
def accelerate_prepare():
|
||||
"""
|
||||
加速完成 Agent 人设生成
|
||||
|
||||
向正在运行的 prepare 任务发送"加速完成"信号:
|
||||
- 停止继续生成未开始的 profile
|
||||
- 用已生成的 profile 继续后续流程(config 生成等)
|
||||
|
||||
请求(JSON):
|
||||
{ "simulation_id": "sim_xxxx" }
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
simulation_id = data.get('simulation_id')
|
||||
if not simulation_id:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": t('api.requireSimulationId')
|
||||
}), 400
|
||||
|
||||
manager = SimulationManager()
|
||||
state = manager.get_simulation(simulation_id)
|
||||
if not state:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": t('api.simulationNotFound', id=simulation_id)
|
||||
}), 404
|
||||
|
||||
manager.request_accelerate(simulation_id)
|
||||
logger.info(f"已收到加速完成请求: simulation_id={simulation_id}")
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": {
|
||||
"simulation_id": simulation_id,
|
||||
"message": "加速完成信号已发送,后台将在下一个检查点停止生成剩余人设"
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"加速完成请求失败: {str(e)}")
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@simulation_bp.route('/prepare/status', methods=['POST'])
|
||||
def get_prepare_status():
|
||||
"""
|
||||
|
|
@ -1955,6 +2008,337 @@ def get_simulation_timeline(simulation_id: str):
|
|||
}), 500
|
||||
|
||||
|
||||
def _find_latest_simulation_start(sim_dir: str) -> str:
|
||||
"""
|
||||
扫描 twitter/actions.jsonl 和 reddit/actions.jsonl,找出最近一次 simulation_start 事件的时间戳。
|
||||
用于过滤掉历史 run 残留的 actions(jsonl 是 append-only,多次 run 会累积)。
|
||||
返回 ISO 格式时间戳字符串,找不到则返回空串。
|
||||
"""
|
||||
latest = ""
|
||||
for sub in ("twitter", "reddit"):
|
||||
path = os.path.join(sim_dir, sub, "actions.jsonl")
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or '"simulation_start"' not in line:
|
||||
continue
|
||||
try:
|
||||
evt = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
if evt.get("event_type") != "simulation_start":
|
||||
continue
|
||||
ts = evt.get("timestamp", "")
|
||||
if ts and ts > latest:
|
||||
latest = ts
|
||||
except Exception:
|
||||
continue
|
||||
return latest
|
||||
|
||||
|
||||
def _extract_entity_type_names(ontology):
|
||||
"""从 ontology 字段中提取实体类型名称列表,兼容 dict / list / dict-of-list 多种格式"""
|
||||
if not ontology:
|
||||
return []
|
||||
if isinstance(ontology, list):
|
||||
return [str(x) for x in ontology[:50]]
|
||||
if isinstance(ontology, dict):
|
||||
et = ontology.get("entity_types")
|
||||
if isinstance(et, dict):
|
||||
return list(et.keys())[:50]
|
||||
if isinstance(et, list):
|
||||
return [item.get("name", str(item)) if isinstance(item, dict) else str(item) for item in et[:50]]
|
||||
return []
|
||||
|
||||
|
||||
@simulation_bp.route('/<simulation_id>/replay', methods=['GET'])
|
||||
def get_simulation_replay(simulation_id: str):
|
||||
"""
|
||||
获取模拟完整回放数据(Manus 式过程回放)
|
||||
|
||||
一次性返回展示整个 Foresight 工作流所需的全部数据,前端无需多次请求:
|
||||
- simulation: 基本状态
|
||||
- project: 文档/需求/图谱信息
|
||||
- workflow: 5 步工作流时间线(每步 status + metadata)
|
||||
- config: 模拟配置摘要(时间配置 + 事件配置)
|
||||
- agents: agent 精简列表(id/name/username/profession/bio)
|
||||
- rounds: 每轮动作列表 + 每轮统计(action_types, by_platform, active_agents_count)
|
||||
- aggregate: 全局聚合(总动作数、类型分布、top agents)
|
||||
|
||||
用途:前端 /simulation/:id/replay 路由,播放整个模拟过程
|
||||
"""
|
||||
try:
|
||||
manager = SimulationManager()
|
||||
state = manager.get_simulation(simulation_id)
|
||||
if not state:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": f"simulation not found: {simulation_id}"
|
||||
}), 404
|
||||
|
||||
sim_dir = manager._get_simulation_dir(simulation_id)
|
||||
|
||||
# ---------- 1. 项目信息 ----------
|
||||
project_info = None
|
||||
try:
|
||||
project = ProjectManager.get_project(state.project_id)
|
||||
if project:
|
||||
project_info = {
|
||||
"project_id": project.project_id,
|
||||
"name": project.name,
|
||||
"status": project.status.value if hasattr(project.status, 'value') else str(project.status),
|
||||
"graph_id": project.graph_id,
|
||||
"simulation_requirement": project.simulation_requirement,
|
||||
"files": [
|
||||
{
|
||||
"filename": f.get("original_filename") or f.get("filename"),
|
||||
"size": f.get("size"),
|
||||
}
|
||||
for f in (project.files or [])
|
||||
],
|
||||
"total_text_length": project.total_text_length,
|
||||
"analysis_summary": (project.analysis_summary or "")[:500] if project.analysis_summary else None,
|
||||
"ontology_entity_types": _extract_entity_type_names(project.ontology),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"replay: 读取项目信息失败 {state.project_id}: {e}")
|
||||
|
||||
# ---------- 2. 模拟配置 ----------
|
||||
sim_config = {}
|
||||
config_path = os.path.join(sim_dir, "simulation_config.json")
|
||||
if os.path.exists(config_path):
|
||||
try:
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
sim_config = json.load(f)
|
||||
except Exception as e:
|
||||
logger.warning(f"replay: 读取 simulation_config.json 失败: {e}")
|
||||
|
||||
time_config = sim_config.get("time_config", {}) or {}
|
||||
event_config = sim_config.get("event_config", {}) or {}
|
||||
agent_configs = sim_config.get("agent_configs", []) or []
|
||||
minutes_per_round = time_config.get("minutes_per_round", 60)
|
||||
|
||||
# ---------- 3. Agents (精简版,用于回放显示) ----------
|
||||
agents = []
|
||||
reddit_path = os.path.join(sim_dir, "reddit_profiles.json")
|
||||
twitter_path = os.path.join(sim_dir, "twitter_profiles.csv")
|
||||
|
||||
if os.path.exists(reddit_path):
|
||||
try:
|
||||
with open(reddit_path, 'r', encoding='utf-8') as f:
|
||||
reddit_profiles = json.load(f)
|
||||
for idx, p in enumerate(reddit_profiles):
|
||||
agents.append({
|
||||
"id": p.get("user_id", idx),
|
||||
"name": p.get("name") or p.get("username") or f"agent_{idx}",
|
||||
"username": p.get("username") or p.get("user_name") or f"agent_{idx}",
|
||||
"profession": p.get("profession"),
|
||||
"bio": (p.get("bio") or "")[:200],
|
||||
"interested_topics": p.get("interested_topics") or [],
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"replay: 读取 reddit_profiles.json 失败: {e}")
|
||||
elif os.path.exists(twitter_path):
|
||||
try:
|
||||
with open(twitter_path, 'r', encoding='utf-8') as f:
|
||||
reader = csv.DictReader(f)
|
||||
for idx, row in enumerate(reader):
|
||||
agents.append({
|
||||
"id": int(row.get("user_id", idx) or idx),
|
||||
"name": row.get("name") or row.get("username") or f"agent_{idx}",
|
||||
"username": row.get("username") or row.get("user_name") or f"agent_{idx}",
|
||||
"profession": row.get("profession"),
|
||||
"bio": (row.get("bio") or "")[:200],
|
||||
"interested_topics": [],
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"replay: 读取 twitter_profiles.csv 失败: {e}")
|
||||
|
||||
# ---------- 4. Actions + rounds ----------
|
||||
# 找出最近一次 simulation_start 的时间戳,过滤掉之前 run 残留的 actions
|
||||
# (actions.jsonl 是 append-only,多次 run 会累积;只显示最新这次)
|
||||
latest_start_ts = _find_latest_simulation_start(sim_dir)
|
||||
|
||||
all_actions = SimulationRunner.get_all_actions(simulation_id)
|
||||
if latest_start_ts:
|
||||
all_actions = [a for a in all_actions if a.timestamp >= latest_start_ts]
|
||||
# 按时间戳升序(回放需要从 round 0 到最后)
|
||||
all_actions.sort(key=lambda a: (a.round_num, a.timestamp))
|
||||
|
||||
rounds_map = {}
|
||||
for action in all_actions:
|
||||
r = action.round_num
|
||||
if r not in rounds_map:
|
||||
simulated_minutes = r * minutes_per_round
|
||||
rounds_map[r] = {
|
||||
"round_num": r,
|
||||
"simulated_hour": (simulated_minutes // 60) % 24,
|
||||
"simulated_day": simulated_minutes // (60 * 24) + 1,
|
||||
"first_timestamp": action.timestamp,
|
||||
"last_timestamp": action.timestamp,
|
||||
"actions": [],
|
||||
"_active_agents": set(),
|
||||
"_by_type": {},
|
||||
"_by_platform": {"twitter": 0, "reddit": 0},
|
||||
}
|
||||
rd = rounds_map[r]
|
||||
rd["actions"].append(action.to_dict())
|
||||
rd["last_timestamp"] = action.timestamp
|
||||
rd["_active_agents"].add(action.agent_id)
|
||||
rd["_by_type"][action.action_type] = rd["_by_type"].get(action.action_type, 0) + 1
|
||||
if action.platform in rd["_by_platform"]:
|
||||
rd["_by_platform"][action.platform] += 1
|
||||
|
||||
rounds_list = []
|
||||
for r in sorted(rounds_map.keys()):
|
||||
rd = rounds_map[r]
|
||||
rounds_list.append({
|
||||
"round_num": rd["round_num"],
|
||||
"simulated_hour": rd["simulated_hour"],
|
||||
"simulated_day": rd["simulated_day"],
|
||||
"first_timestamp": rd["first_timestamp"],
|
||||
"last_timestamp": rd["last_timestamp"],
|
||||
"actions": rd["actions"],
|
||||
"stats": {
|
||||
"total_actions": len(rd["actions"]),
|
||||
"active_agents_count": len(rd["_active_agents"]),
|
||||
"by_type": rd["_by_type"],
|
||||
"by_platform": rd["_by_platform"],
|
||||
},
|
||||
})
|
||||
|
||||
# ---------- 5. Aggregate ----------
|
||||
total_actions = len(all_actions)
|
||||
action_type_dist = {}
|
||||
agent_action_count = {}
|
||||
platform_totals = {"twitter": 0, "reddit": 0}
|
||||
for a in all_actions:
|
||||
action_type_dist[a.action_type] = action_type_dist.get(a.action_type, 0) + 1
|
||||
key = (a.agent_id, a.agent_name)
|
||||
agent_action_count[key] = agent_action_count.get(key, 0) + 1
|
||||
if a.platform in platform_totals:
|
||||
platform_totals[a.platform] += 1
|
||||
|
||||
top_agents = sorted(
|
||||
[{"agent_id": k[0], "agent_name": k[1], "count": v} for k, v in agent_action_count.items()],
|
||||
key=lambda x: x["count"],
|
||||
reverse=True,
|
||||
)[:10]
|
||||
|
||||
# ---------- 6. Workflow 时间线 ----------
|
||||
status_str = state.status.value if hasattr(state.status, 'value') else str(state.status)
|
||||
workflow = [
|
||||
{
|
||||
"step": 1,
|
||||
"name": "文档录入与需求确认",
|
||||
"status": "completed" if project_info else "pending",
|
||||
"metadata": {
|
||||
"files": project_info["files"] if project_info else [],
|
||||
"requirement": project_info["simulation_requirement"] if project_info else None,
|
||||
"text_length": project_info["total_text_length"] if project_info else 0,
|
||||
"analysis_summary": project_info["analysis_summary"] if project_info else None,
|
||||
},
|
||||
},
|
||||
{
|
||||
"step": 2,
|
||||
"name": "知识图谱构建",
|
||||
"status": "completed" if state.entities_count > 0 else "pending",
|
||||
"metadata": {
|
||||
"graph_id": state.graph_id,
|
||||
"entity_types": state.entity_types,
|
||||
"entities_count": state.entities_count,
|
||||
"ontology_entity_types": project_info["ontology_entity_types"] if project_info else [],
|
||||
},
|
||||
},
|
||||
{
|
||||
"step": 3,
|
||||
"name": "Agent 人设生成",
|
||||
"status": "completed" if state.profiles_count > 0 else "pending",
|
||||
"metadata": {
|
||||
"profiles_count": state.profiles_count,
|
||||
"agents_loaded": len(agents),
|
||||
},
|
||||
},
|
||||
{
|
||||
"step": 4,
|
||||
"name": "模拟配置生成",
|
||||
"status": "completed" if state.config_generated else "pending",
|
||||
"metadata": {
|
||||
"config_reasoning": (state.config_reasoning or "")[:500] if state.config_reasoning else None,
|
||||
"total_simulation_hours": time_config.get("total_simulation_hours"),
|
||||
"minutes_per_round": minutes_per_round,
|
||||
"peak_hours": time_config.get("peak_hours"),
|
||||
"agents_per_hour_min": time_config.get("agents_per_hour_min"),
|
||||
"agents_per_hour_max": time_config.get("agents_per_hour_max"),
|
||||
"initial_posts_count": len(event_config.get("initial_posts", [])),
|
||||
},
|
||||
},
|
||||
{
|
||||
"step": 5,
|
||||
"name": "双平台模拟运行",
|
||||
"status": status_str,
|
||||
"metadata": {
|
||||
"total_rounds_executed": len(rounds_list),
|
||||
"total_actions": total_actions,
|
||||
"twitter_enabled": state.enable_twitter,
|
||||
"reddit_enabled": state.enable_reddit,
|
||||
"by_platform": platform_totals,
|
||||
"current_run_started_at": latest_start_ts or state.created_at,
|
||||
"simulation_created_at": state.created_at,
|
||||
"updated_at": state.updated_at,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": {
|
||||
"simulation": {
|
||||
"simulation_id": state.simulation_id,
|
||||
"project_id": state.project_id,
|
||||
"graph_id": state.graph_id,
|
||||
"status": status_str,
|
||||
"enable_twitter": state.enable_twitter,
|
||||
"enable_reddit": state.enable_reddit,
|
||||
"entities_count": state.entities_count,
|
||||
"profiles_count": state.profiles_count,
|
||||
"created_at": state.created_at,
|
||||
"updated_at": state.updated_at,
|
||||
},
|
||||
"project": project_info,
|
||||
"workflow": workflow,
|
||||
"config": {
|
||||
"time_config": time_config,
|
||||
"event_config": {
|
||||
"initial_posts": event_config.get("initial_posts", []),
|
||||
"events": event_config.get("events", []),
|
||||
},
|
||||
"agent_configs_count": len(agent_configs),
|
||||
},
|
||||
"agents": agents,
|
||||
"rounds": rounds_list,
|
||||
"aggregate": {
|
||||
"total_actions": total_actions,
|
||||
"rounds_with_actions": len(rounds_list),
|
||||
"action_type_distribution": action_type_dist,
|
||||
"platform_totals": platform_totals,
|
||||
"top_agents": top_agents,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取回放数据失败: {str(e)}")
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
}), 500
|
||||
|
||||
|
||||
@simulation_bp.route('/<simulation_id>/agent-stats', methods=['GET'])
|
||||
def get_agent_stats(simulation_id: str):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -0,0 +1,118 @@
|
|||
"""
|
||||
Token 用量查询 API(v0.3 新增)
|
||||
|
||||
提供 LLM token 消耗的实时统计与成本估算。
|
||||
内部用途,销售给客户的版本可移除此 blueprint 注册。
|
||||
"""
|
||||
|
||||
from flask import jsonify, request
|
||||
from . import usage_bp
|
||||
from ..utils import token_tracker
|
||||
from ..utils.logger import get_logger
|
||||
|
||||
logger = get_logger('foresight.api.usage')
|
||||
|
||||
|
||||
@usage_bp.route('/summary', methods=['GET'])
|
||||
def get_usage_summary():
|
||||
"""
|
||||
获取当前累计 token 用量摘要
|
||||
|
||||
返回:
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"stages": {
|
||||
"<stage_name>": {
|
||||
"by_model": {...},
|
||||
"prompt_tokens": int,
|
||||
"completion_tokens": int,
|
||||
"calls": int,
|
||||
"estimated_cost_cny": float
|
||||
}
|
||||
},
|
||||
"total": {...},
|
||||
"current_stage": "..."
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": token_tracker.get_summary(),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"获取 token 用量失败: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@usage_bp.route('/reset', methods=['POST'])
|
||||
def reset_usage():
|
||||
"""
|
||||
清空 token 用量统计
|
||||
|
||||
Body (可选):
|
||||
{"stage": "<stage_name>"} // 不填则全部清空
|
||||
"""
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
stage = data.get("stage")
|
||||
token_tracker.reset(stage)
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": {"reset_stage": stage or "all"},
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"重置 token 用量失败: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@usage_bp.route('/estimate-simulation', methods=['GET'])
|
||||
def estimate_simulation():
|
||||
"""
|
||||
估算 OASIS 模拟子进程的 token 消耗(无法精确追踪,只能估算)
|
||||
|
||||
Query params:
|
||||
rounds: 模拟轮数 (默认 15)
|
||||
active_agents_per_round: 每轮平均激活 agent 数 (默认 10)
|
||||
llm_calls_per_action: 每动作 LLM 调用次数 (默认 4)
|
||||
prompt_tokens: 平均 prompt token (默认 800)
|
||||
completion_tokens: 平均 completion token (默认 200)
|
||||
model: 模型名 (默认 glm-4-flash)
|
||||
"""
|
||||
try:
|
||||
rounds = request.args.get('rounds', 15, type=int)
|
||||
agents = request.args.get('active_agents_per_round', 10, type=int)
|
||||
calls = request.args.get('llm_calls_per_action', 4, type=int)
|
||||
prompt = request.args.get('prompt_tokens', 800, type=int)
|
||||
completion = request.args.get('completion_tokens', 200, type=int)
|
||||
model = request.args.get('model', 'glm-4-flash')
|
||||
|
||||
result = token_tracker.estimate_simulation_cost(
|
||||
rounds=rounds,
|
||||
active_agents_per_round_avg=agents,
|
||||
llm_calls_per_action=calls,
|
||||
avg_prompt_tokens_per_call=prompt,
|
||||
avg_completion_tokens_per_call=completion,
|
||||
model=model,
|
||||
)
|
||||
return jsonify({"success": True, "data": result})
|
||||
except Exception as e:
|
||||
logger.error(f"估算模拟 token 失败: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@usage_bp.route('/set-stage', methods=['POST'])
|
||||
def set_stage():
|
||||
"""
|
||||
手动设置当前 stage(用于测试或非自动埋点的场景)
|
||||
|
||||
Body: {"stage": "<stage_name>"}
|
||||
"""
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
stage = data.get("stage")
|
||||
token_tracker.set_stage(stage)
|
||||
return jsonify({"success": True, "data": {"current_stage": stage}})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
|
@ -819,7 +819,8 @@ class OasisProfileGenerator:
|
|||
graph_id: Optional[str] = None,
|
||||
parallel_count: int = 5,
|
||||
realtime_output_path: Optional[str] = None,
|
||||
output_platform: str = "reddit"
|
||||
output_platform: str = "reddit",
|
||||
cancel_check: Optional[callable] = None
|
||||
) -> List[OasisAgentProfile]:
|
||||
"""
|
||||
批量从实体生成Agent Profile(支持并行生成)
|
||||
|
|
@ -918,6 +919,8 @@ class OasisProfileGenerator:
|
|||
print(f"开始生成Agent人设 - 共 {total} 个实体,并行数: {parallel_count}")
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
cancelled = False
|
||||
|
||||
# 使用线程池并行执行
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=parallel_count) as executor:
|
||||
# 提交所有任务
|
||||
|
|
@ -925,12 +928,22 @@ class OasisProfileGenerator:
|
|||
executor.submit(generate_single_profile, idx, entity): (idx, entity)
|
||||
for idx, entity in enumerate(entities)
|
||||
}
|
||||
|
||||
|
||||
# 收集结果
|
||||
for future in concurrent.futures.as_completed(future_to_entity):
|
||||
# 检测到加速信号:取消未开始的任务并结束收集
|
||||
if cancel_check and cancel_check():
|
||||
cancelled = True
|
||||
logger.info("检测到加速完成信号,取消剩余未完成的人设生成任务")
|
||||
print("\n[加速完成] 用户请求立即结束人设生成,取消剩余任务...")
|
||||
for f in future_to_entity:
|
||||
if not f.done():
|
||||
f.cancel()
|
||||
break
|
||||
|
||||
idx, entity = future_to_entity[future]
|
||||
entity_type = entity.get_entity_type() or "Entity"
|
||||
|
||||
|
||||
try:
|
||||
result_idx, profile, error = future.result()
|
||||
profiles[result_idx] = profile
|
||||
|
|
@ -970,11 +983,21 @@ class OasisProfileGenerator:
|
|||
# 实时写入文件(即使是备用人设)
|
||||
save_profiles_realtime()
|
||||
|
||||
# 过滤掉未完成的 None 占位符(加速完成或任务取消时可能存在)
|
||||
final_profiles = [p for p in profiles if p is not None]
|
||||
|
||||
# 加速完成后再写一次实时文件,确保文件内容与返回值一致
|
||||
if cancelled and realtime_output_path:
|
||||
save_profiles_realtime()
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"人设生成完成!共生成 {len([p for p in profiles if p])} 个Agent")
|
||||
if cancelled:
|
||||
print(f"人设生成已加速完成!共生成 {len(final_profiles)} 个Agent(原计划 {total} 个)")
|
||||
else:
|
||||
print(f"人设生成完成!共生成 {len(final_profiles)} 个Agent")
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
return profiles
|
||||
|
||||
return final_profiles
|
||||
|
||||
def _print_generated_profile(self, entity_name: str, entity_type: str, profile: OasisAgentProfile):
|
||||
"""实时输出生成的人设到控制台(完整内容,不截断)"""
|
||||
|
|
|
|||
|
|
@ -125,10 +125,25 @@ class SimulationManager:
|
|||
|
||||
# 模拟数据存储目录
|
||||
SIMULATION_DATA_DIR = os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
os.path.dirname(__file__),
|
||||
'../../uploads/simulations'
|
||||
)
|
||||
|
||||
|
||||
# 加速完成标志:simulation_id -> True 表示用户请求立即结束 profile 生成
|
||||
_accelerate_flags: Dict[str, bool] = {}
|
||||
|
||||
@classmethod
|
||||
def request_accelerate(cls, simulation_id: str) -> None:
|
||||
cls._accelerate_flags[simulation_id] = True
|
||||
|
||||
@classmethod
|
||||
def is_accelerate_requested(cls, simulation_id: str) -> bool:
|
||||
return cls._accelerate_flags.get(simulation_id, False)
|
||||
|
||||
@classmethod
|
||||
def clear_accelerate(cls, simulation_id: str) -> None:
|
||||
cls._accelerate_flags.pop(simulation_id, None)
|
||||
|
||||
def __init__(self):
|
||||
# 确保目录存在
|
||||
os.makedirs(self.SIMULATION_DATA_DIR, exist_ok=True)
|
||||
|
|
@ -262,7 +277,10 @@ class SimulationManager:
|
|||
state = self._load_simulation_state(simulation_id)
|
||||
if not state:
|
||||
raise ValueError(f"模拟不存在: {simulation_id}")
|
||||
|
||||
|
||||
# 重置加速标志,防止上一轮残留
|
||||
self.clear_accelerate(simulation_id)
|
||||
|
||||
try:
|
||||
state.status = SimulationStatus.PREPARING
|
||||
self._save_simulation_state(state)
|
||||
|
|
@ -343,9 +361,19 @@ class SimulationManager:
|
|||
graph_id=state.graph_id, # 传入graph_id用于Zep检索
|
||||
parallel_count=parallel_profile_count, # 并行生成数量
|
||||
realtime_output_path=realtime_output_path, # 实时保存路径
|
||||
output_platform=realtime_platform # 输出格式
|
||||
output_platform=realtime_platform, # 输出格式
|
||||
cancel_check=lambda sid=simulation_id: self.is_accelerate_requested(sid)
|
||||
)
|
||||
|
||||
|
||||
# 若触发了加速完成,后续 config 生成需要只使用实际生成了 profile 的实体,
|
||||
# 避免实体数(filtered.entities)与 profile 数不一致导致 config 与 profile 对不上
|
||||
if len(profiles) < len(filtered.entities):
|
||||
generated_uuids = {p.source_entity_uuid for p in profiles if p.source_entity_uuid}
|
||||
filtered.entities = [e for e in filtered.entities if e.uuid in generated_uuids]
|
||||
filtered.filtered_count = len(filtered.entities)
|
||||
state.entities_count = filtered.filtered_count
|
||||
logger.info(f"加速完成:实际使用 {len(filtered.entities)} 个实体继续后续流程")
|
||||
|
||||
state.profiles_count = len(profiles)
|
||||
|
||||
# 保存Profile文件(注意:Twitter使用CSV格式,Reddit使用JSON格式)
|
||||
|
|
@ -444,9 +472,10 @@ class SimulationManager:
|
|||
|
||||
logger.info(f"模拟准备完成: {simulation_id}, "
|
||||
f"entities={state.entities_count}, profiles={state.profiles_count}")
|
||||
|
||||
|
||||
self.clear_accelerate(simulation_id)
|
||||
return state
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"模拟准备失败: {simulation_id}, error={str(e)}")
|
||||
import traceback
|
||||
|
|
@ -454,6 +483,7 @@ class SimulationManager:
|
|||
state.status = SimulationStatus.FAILED
|
||||
state.error = str(e)
|
||||
self._save_simulation_state(state)
|
||||
self.clear_accelerate(simulation_id)
|
||||
raise
|
||||
|
||||
def get_simulation(self, simulation_id: str) -> Optional[SimulationState]:
|
||||
|
|
|
|||
|
|
@ -1287,74 +1287,28 @@ class SimulationRunner:
|
|||
@classmethod
|
||||
def register_cleanup(cls):
|
||||
"""
|
||||
注册清理函数
|
||||
|
||||
在 Flask 应用启动时调用,确保服务器关闭时清理所有模拟进程
|
||||
注册清理函数(已禁用)
|
||||
|
||||
运维修复(2026-04-14):原实现在 Flask 收到 SIGTERM/SIGINT/SIGHUP 或
|
||||
进程退出时,会调用 cleanup_all_simulations() 杀掉所有正在运行的模拟
|
||||
子进程。这导致重启 Flask backend 必然中断所有进行中的模拟,运维成本
|
||||
极高。
|
||||
|
||||
子进程已通过 subprocess.Popen(..., start_new_session=True) 获得独立
|
||||
的 session/PGID,不会因 Flask 父进程收到信号而被顺带杀掉。因此 Flask
|
||||
退出时无需也不应该主动清理这些子进程 —— 它们可以继续跑完,Flask 重
|
||||
启后通过 _load_run_state 等机制重新接管。
|
||||
|
||||
仅当用户显式调用 stop_simulation 时才会终止子进程(那条路径不经过
|
||||
这里)。所以本方法保留为空实现,避免破坏调用方。
|
||||
"""
|
||||
global _cleanup_registered
|
||||
|
||||
if _cleanup_registered:
|
||||
return
|
||||
|
||||
# Flask debug 模式下,只在 reloader 子进程中注册清理(实际运行应用的进程)
|
||||
# WERKZEUG_RUN_MAIN=true 表示是 reloader 子进程
|
||||
# 如果不是 debug 模式,则没有这个环境变量,也需要注册
|
||||
is_reloader_process = os.environ.get('WERKZEUG_RUN_MAIN') == 'true'
|
||||
is_debug_mode = os.environ.get('FLASK_DEBUG') == '1' or os.environ.get('WERKZEUG_RUN_MAIN') is not None
|
||||
|
||||
# 在 debug 模式下,只在 reloader 子进程中注册;非 debug 模式下始终注册
|
||||
if is_debug_mode and not is_reloader_process:
|
||||
_cleanup_registered = True # 标记已注册,防止子进程再次尝试
|
||||
return
|
||||
|
||||
# 保存原有的信号处理器
|
||||
original_sigint = signal.getsignal(signal.SIGINT)
|
||||
original_sigterm = signal.getsignal(signal.SIGTERM)
|
||||
# SIGHUP 只在 Unix 系统存在(macOS/Linux),Windows 没有
|
||||
original_sighup = None
|
||||
has_sighup = hasattr(signal, 'SIGHUP')
|
||||
if has_sighup:
|
||||
original_sighup = signal.getsignal(signal.SIGHUP)
|
||||
|
||||
def cleanup_handler(signum=None, frame=None):
|
||||
"""信号处理器:先清理模拟进程,再调用原处理器"""
|
||||
# 只有在有进程需要清理时才打印日志
|
||||
if cls._processes or cls._graph_memory_enabled:
|
||||
logger.info(f"收到信号 {signum},开始清理...")
|
||||
cls.cleanup_all_simulations()
|
||||
|
||||
# 调用原有的信号处理器,让 Flask 正常退出
|
||||
if signum == signal.SIGINT and callable(original_sigint):
|
||||
original_sigint(signum, frame)
|
||||
elif signum == signal.SIGTERM and callable(original_sigterm):
|
||||
original_sigterm(signum, frame)
|
||||
elif has_sighup and signum == signal.SIGHUP:
|
||||
# SIGHUP: 终端关闭时发送
|
||||
if callable(original_sighup):
|
||||
original_sighup(signum, frame)
|
||||
else:
|
||||
# 默认行为:正常退出
|
||||
sys.exit(0)
|
||||
else:
|
||||
# 如果原处理器不可调用(如 SIG_DFL),则使用默认行为
|
||||
raise KeyboardInterrupt
|
||||
|
||||
# 注册 atexit 处理器(作为备用)
|
||||
atexit.register(cls.cleanup_all_simulations)
|
||||
|
||||
# 注册信号处理器(仅在主线程中)
|
||||
try:
|
||||
# SIGTERM: kill 命令默认信号
|
||||
signal.signal(signal.SIGTERM, cleanup_handler)
|
||||
# SIGINT: Ctrl+C
|
||||
signal.signal(signal.SIGINT, cleanup_handler)
|
||||
# SIGHUP: 终端关闭(仅 Unix 系统)
|
||||
if has_sighup:
|
||||
signal.signal(signal.SIGHUP, cleanup_handler)
|
||||
except ValueError:
|
||||
# 不在主线程中,只能使用 atexit
|
||||
logger.warning("无法注册信号处理器(不在主线程),仅使用 atexit")
|
||||
|
||||
logger.info(
|
||||
"register_cleanup 已禁用:Flask 退出时不再自动清理模拟子进程,"
|
||||
"避免重启 backend 时误杀正在运行的模拟。子进程独立 session 存活。"
|
||||
)
|
||||
_cleanup_registered = True
|
||||
|
||||
@classmethod
|
||||
|
|
|
|||
|
|
@ -60,8 +60,21 @@ class LLMClient:
|
|||
|
||||
if response_format:
|
||||
kwargs["response_format"] = response_format
|
||||
|
||||
|
||||
response = self.client.chat.completions.create(**kwargs)
|
||||
# Token 追踪(v0.3 新增):记录每次调用的 token 消耗,按当前 stage 归类
|
||||
try:
|
||||
from . import token_tracker
|
||||
usage = getattr(response, "usage", None)
|
||||
if usage is not None:
|
||||
token_tracker.record_usage(
|
||||
model=self.model,
|
||||
prompt_tokens=int(getattr(usage, "prompt_tokens", 0) or 0),
|
||||
completion_tokens=int(getattr(usage, "completion_tokens", 0) or 0),
|
||||
)
|
||||
except Exception:
|
||||
pass # 永不阻塞主流程
|
||||
|
||||
content = response.choices[0].message.content
|
||||
# 部分模型(如MiniMax M2.5)会在content中包含<think>思考内容,需要移除
|
||||
content = re.sub(r'<think>[\s\S]*?</think>', '', content).strip()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,238 @@
|
|||
"""
|
||||
Token 用量追踪器(v0.3 新增)
|
||||
|
||||
进程内全局单例,按 stage 维度统计 LLM token 消耗。
|
||||
- LLMClient 自动 record 每次 API call 的 prompt/completion tokens
|
||||
- 调用方在 stage 切换时 set_stage("step1_ontology")
|
||||
- 通过 GET /api/usage/summary 查询
|
||||
- 用于成本估算和销售定价
|
||||
|
||||
说明:
|
||||
1. 这是 Flask 后端进程内的统计,不包含 OASIS 模拟子进程的 LLM 消耗
|
||||
(camel-ai 用自己的 LLM client,需另外估算)
|
||||
2. 进程重启会丢失数据,如需持久化可用 reset() 前先 dump 到文件
|
||||
3. 价格为每 1K tokens 的 CNY 价格,参考各 provider 官网 (2026-04 价格)
|
||||
"""
|
||||
|
||||
import threading
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
_lock = threading.Lock()
|
||||
|
||||
# 全局状态:{stage: {model: {prompt_tokens, completion_tokens, calls}}}
|
||||
_stage_usage: Dict[str, Dict[str, Dict[str, int]]] = {}
|
||||
_current_stage: Optional[str] = None
|
||||
|
||||
# 模型定价表(CNY per 1K tokens, prompt / completion)
|
||||
# 来源:各 provider 2026-04 官网价格
|
||||
PRICING = {
|
||||
# 智谱 GLM 系列
|
||||
"glm-4-flash": {"prompt": 0.0001, "completion": 0.0001},
|
||||
"glm-4-flashx": {"prompt": 0.001, "completion": 0.001},
|
||||
"glm-4-air": {"prompt": 0.001, "completion": 0.001},
|
||||
"glm-4-plus": {"prompt": 0.05, "completion": 0.05},
|
||||
"glm-4.6": {"prompt": 0.05, "completion": 0.05},
|
||||
"glm-5.1": {"prompt": 0.1, "completion": 0.1},
|
||||
# SiliconFlow 免费
|
||||
"qwen/qwen2.5-32b-instruct": {"prompt": 0.0, "completion": 0.0},
|
||||
"qwen2.5-32b-instruct": {"prompt": 0.0, "completion": 0.0},
|
||||
"baai/bge-m3": {"prompt": 0.0, "completion": 0.0},
|
||||
# MiniMax
|
||||
"minimax-m2.7-highspeed": {"prompt": 0.001, "completion": 0.001},
|
||||
"minimax-m2.5": {"prompt": 0.001, "completion": 0.002},
|
||||
# Anthropic / OpenAI(备用)
|
||||
"gpt-4o-mini": {"prompt": 0.0011, "completion": 0.0044},
|
||||
"gpt-4o": {"prompt": 0.018, "completion": 0.072},
|
||||
"claude-haiku-4-5": {"prompt": 0.0072, "completion": 0.036},
|
||||
"claude-sonnet-4-6": {"prompt": 0.022, "completion": 0.108},
|
||||
# 兜底
|
||||
"default": {"prompt": 0.001, "completion": 0.002},
|
||||
}
|
||||
|
||||
|
||||
def _get_pricing(model: str) -> Dict[str, float]:
|
||||
if not model:
|
||||
return PRICING["default"]
|
||||
key = model.lower().strip()
|
||||
if key in PRICING:
|
||||
return PRICING[key]
|
||||
# 模糊匹配(去掉版本号)
|
||||
for k in PRICING:
|
||||
if k != "default" and (key.startswith(k) or k.startswith(key)):
|
||||
return PRICING[k]
|
||||
return PRICING["default"]
|
||||
|
||||
|
||||
def set_stage(name: Optional[str]) -> None:
|
||||
"""设置当前 stage 名称,后续的 LLM 调用会归到这个 stage 下"""
|
||||
global _current_stage
|
||||
with _lock:
|
||||
_current_stage = name
|
||||
|
||||
|
||||
def get_stage() -> Optional[str]:
|
||||
return _current_stage
|
||||
|
||||
|
||||
def record_usage(
|
||||
model: str,
|
||||
prompt_tokens: int,
|
||||
completion_tokens: int,
|
||||
stage: Optional[str] = None,
|
||||
) -> None:
|
||||
"""记录一次 LLM 调用的 token 消耗"""
|
||||
if not isinstance(prompt_tokens, int) or not isinstance(completion_tokens, int):
|
||||
return
|
||||
s = stage or _current_stage or "unknown"
|
||||
with _lock:
|
||||
stage_bucket = _stage_usage.setdefault(s, {})
|
||||
model_bucket = stage_bucket.setdefault(
|
||||
model or "unknown",
|
||||
{"prompt_tokens": 0, "completion_tokens": 0, "calls": 0},
|
||||
)
|
||||
model_bucket["prompt_tokens"] += prompt_tokens
|
||||
model_bucket["completion_tokens"] += completion_tokens
|
||||
model_bucket["calls"] += 1
|
||||
|
||||
|
||||
def get_summary() -> Dict[str, Any]:
|
||||
"""
|
||||
返回当前累计的 token 用量与估算成本。
|
||||
|
||||
格式:
|
||||
{
|
||||
"stages": {
|
||||
"step1_ontology": {
|
||||
"by_model": {"glm-4-flash": {"prompt_tokens": 1234, "completion_tokens": 567, "calls": 3, "estimated_cost_cny": 0.0002}},
|
||||
"prompt_tokens": 1234,
|
||||
"completion_tokens": 567,
|
||||
"calls": 3,
|
||||
"estimated_cost_cny": 0.0002
|
||||
},
|
||||
...
|
||||
},
|
||||
"total": {
|
||||
"prompt_tokens": 50000,
|
||||
"completion_tokens": 12000,
|
||||
"calls": 45,
|
||||
"estimated_cost_cny": 0.0123
|
||||
}
|
||||
}
|
||||
"""
|
||||
with _lock:
|
||||
stages_out = {}
|
||||
total_prompt = 0
|
||||
total_completion = 0
|
||||
total_calls = 0
|
||||
total_cost = 0.0
|
||||
|
||||
for stage_name, models in _stage_usage.items():
|
||||
stage_data = {
|
||||
"by_model": {},
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"calls": 0,
|
||||
"estimated_cost_cny": 0.0,
|
||||
}
|
||||
for model_name, m in models.items():
|
||||
pricing = _get_pricing(model_name)
|
||||
cost = (
|
||||
m["prompt_tokens"] / 1000.0 * pricing["prompt"]
|
||||
+ m["completion_tokens"] / 1000.0 * pricing["completion"]
|
||||
)
|
||||
stage_data["by_model"][model_name] = {
|
||||
"prompt_tokens": m["prompt_tokens"],
|
||||
"completion_tokens": m["completion_tokens"],
|
||||
"calls": m["calls"],
|
||||
"estimated_cost_cny": round(cost, 6),
|
||||
}
|
||||
stage_data["prompt_tokens"] += m["prompt_tokens"]
|
||||
stage_data["completion_tokens"] += m["completion_tokens"]
|
||||
stage_data["calls"] += m["calls"]
|
||||
stage_data["estimated_cost_cny"] += cost
|
||||
|
||||
stage_data["estimated_cost_cny"] = round(stage_data["estimated_cost_cny"], 6)
|
||||
stages_out[stage_name] = stage_data
|
||||
total_prompt += stage_data["prompt_tokens"]
|
||||
total_completion += stage_data["completion_tokens"]
|
||||
total_calls += stage_data["calls"]
|
||||
total_cost += stage_data["estimated_cost_cny"]
|
||||
|
||||
return {
|
||||
"stages": stages_out,
|
||||
"total": {
|
||||
"prompt_tokens": total_prompt,
|
||||
"completion_tokens": total_completion,
|
||||
"calls": total_calls,
|
||||
"estimated_cost_cny": round(total_cost, 6),
|
||||
},
|
||||
"current_stage": _current_stage,
|
||||
}
|
||||
|
||||
|
||||
def reset(stage: Optional[str] = None) -> None:
|
||||
"""清空统计。stage=None 全部清空,否则只清空指定 stage"""
|
||||
global _stage_usage
|
||||
with _lock:
|
||||
if stage is None:
|
||||
_stage_usage = {}
|
||||
else:
|
||||
_stage_usage.pop(stage, None)
|
||||
|
||||
|
||||
def estimate_simulation_cost(
|
||||
rounds: int,
|
||||
active_agents_per_round_avg: int,
|
||||
llm_calls_per_action: int = 4,
|
||||
avg_prompt_tokens_per_call: int = 800,
|
||||
avg_completion_tokens_per_call: int = 200,
|
||||
model: str = "glm-4-flash",
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
估算 OASIS 模拟子进程的 token 消耗(无法精确测,只能估算)
|
||||
|
||||
OASIS 内部每个 agent action 通常需要 3-5 次 LLM 调用:
|
||||
1. 读 timeline / 推荐过滤
|
||||
2. 决策(要做什么动作)
|
||||
3. 生成内容
|
||||
4. 偶尔 reflection
|
||||
|
||||
Args:
|
||||
rounds: 模拟轮数
|
||||
active_agents_per_round_avg: 每轮平均激活的 agent 数
|
||||
llm_calls_per_action: 每个 action 内部 LLM 调用次数(默认 4)
|
||||
avg_prompt_tokens_per_call: 平均 prompt token
|
||||
avg_completion_tokens_per_call: 平均 completion token
|
||||
model: 模型名(用于查询定价)
|
||||
|
||||
Returns:
|
||||
估算结果含 token / 成本范围(low/mid/high)
|
||||
"""
|
||||
total_actions = rounds * active_agents_per_round_avg
|
||||
total_calls = total_actions * llm_calls_per_action
|
||||
|
||||
pricing = _get_pricing(model)
|
||||
|
||||
mid_prompt = total_calls * avg_prompt_tokens_per_call
|
||||
mid_completion = total_calls * avg_completion_tokens_per_call
|
||||
mid_cost = (
|
||||
mid_prompt / 1000.0 * pricing["prompt"]
|
||||
+ mid_completion / 1000.0 * pricing["completion"]
|
||||
)
|
||||
|
||||
return {
|
||||
"model": model,
|
||||
"rounds": rounds,
|
||||
"active_agents_per_round_avg": active_agents_per_round_avg,
|
||||
"llm_calls_per_action": llm_calls_per_action,
|
||||
"total_actions": total_actions,
|
||||
"total_llm_calls": total_calls,
|
||||
"estimated_prompt_tokens": mid_prompt,
|
||||
"estimated_completion_tokens": mid_completion,
|
||||
"estimated_cost_cny": {
|
||||
"low": round(mid_cost * 0.6, 4),
|
||||
"mid": round(mid_cost, 4),
|
||||
"high": round(mid_cost * 1.6, 4),
|
||||
},
|
||||
"note": "这是估算值,OASIS 模拟在子进程中运行,无法精确追踪。实际值可能在 low-high 区间内浮动。",
|
||||
}
|
||||
|
|
@ -1156,7 +1156,7 @@ async def run_twitter_simulation(
|
|||
agent_graph=result.agent_graph,
|
||||
platform=oasis.DefaultPlatformType.TWITTER,
|
||||
database_path=db_path,
|
||||
semaphore=30, # 限制最大并发 LLM 请求数,防止 API 过载
|
||||
semaphore=100, # 限制最大并发 LLM 请求数,防止 API 过载
|
||||
)
|
||||
|
||||
await result.env.reset()
|
||||
|
|
@ -1347,7 +1347,7 @@ async def run_reddit_simulation(
|
|||
agent_graph=result.agent_graph,
|
||||
platform=oasis.DefaultPlatformType.REDDIT,
|
||||
database_path=db_path,
|
||||
semaphore=30, # 限制最大并发 LLM 请求数,防止 API 过载
|
||||
semaphore=100, # 限制最大并发 LLM 请求数,防止 API 过载
|
||||
)
|
||||
|
||||
await result.env.reset()
|
||||
|
|
|
|||
|
|
@ -24,6 +24,14 @@ export const getPrepareStatus = (data) => {
|
|||
return service.post('/api/simulation/prepare/status', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 加速完成 Agent 人设生成:停止生成剩余 profile,用已生成的继续后续流程
|
||||
* @param {Object} data - { simulation_id }
|
||||
*/
|
||||
export const accelerateSimulationPrepare = (data) => {
|
||||
return service.post('/api/simulation/prepare/accelerate', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模拟状态
|
||||
* @param {string} simulationId
|
||||
|
|
@ -143,6 +151,15 @@ export const getAgentStats = (simulationId) => {
|
|||
return service.get(`/api/simulation/${simulationId}/agent-stats`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模拟完整回放数据(Manus 式过程回放)
|
||||
* 一次性返回 simulation/project/workflow/config/agents/rounds/aggregate
|
||||
* @param {string} simulationId
|
||||
*/
|
||||
export const getSimulationReplay = (simulationId) => {
|
||||
return service.get(`/api/simulation/${simulationId}/replay`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模拟动作历史
|
||||
* @param {string} simulationId
|
||||
|
|
|
|||
|
|
@ -49,6 +49,16 @@
|
|||
<span class="step-title">{{ $t('step2.generateAgentPersona') }}</span>
|
||||
</div>
|
||||
<div class="step-status">
|
||||
<button
|
||||
v-if="phase === 1 && profiles.length > 0"
|
||||
class="accelerate-btn"
|
||||
:class="{ 'is-requested': accelerateRequested }"
|
||||
:disabled="accelerateRequested"
|
||||
@click="handleAccelerate"
|
||||
:title="$t('step2.accelerateHint')"
|
||||
>
|
||||
{{ accelerateRequested ? $t('step2.acceleratePending') : $t('step2.accelerateComplete') }}
|
||||
</button>
|
||||
<span v-if="phase > 1" class="badge success">{{ $t('common.completed') }}</span>
|
||||
<span v-else-if="phase === 1" class="badge processing">{{ prepareProgress }}%</span>
|
||||
<span v-else class="badge pending">{{ $t('common.pending') }}</span>
|
||||
|
|
@ -639,7 +649,8 @@ import {
|
|||
getPrepareStatus,
|
||||
getSimulationProfilesRealtime,
|
||||
getSimulationConfig,
|
||||
getSimulationConfigRealtime
|
||||
getSimulationConfigRealtime,
|
||||
accelerateSimulationPrepare
|
||||
} from '../api/simulation'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
|
@ -665,6 +676,7 @@ const expectedTotal = ref(null)
|
|||
const simulationConfig = ref(null)
|
||||
const selectedProfile = ref(null)
|
||||
const showProfilesDetail = ref(true)
|
||||
const accelerateRequested = ref(false)
|
||||
|
||||
// 日志去重:记录上一次输出的关键信息
|
||||
let lastLoggedMessage = ''
|
||||
|
|
@ -824,6 +836,26 @@ const startPrepareSimulation = async () => {
|
|||
}
|
||||
}
|
||||
|
||||
const handleAccelerate = async () => {
|
||||
if (accelerateRequested.value || !props.simulationId) return
|
||||
if (profiles.value.length === 0) {
|
||||
addLog(t('log.accelerateNoProfiles'))
|
||||
return
|
||||
}
|
||||
accelerateRequested.value = true
|
||||
addLog(t('log.accelerateRequested', { count: profiles.value.length }))
|
||||
try {
|
||||
const res = await accelerateSimulationPrepare({ simulation_id: props.simulationId })
|
||||
if (!res.success) {
|
||||
accelerateRequested.value = false
|
||||
addLog(t('log.accelerateFailed', { error: res.error || t('common.unknownError') }))
|
||||
}
|
||||
} catch (err) {
|
||||
accelerateRequested.value = false
|
||||
addLog(t('log.accelerateFailed', { error: err.message }))
|
||||
}
|
||||
}
|
||||
|
||||
const startPolling = () => {
|
||||
pollTimer = setInterval(pollPrepareStatus, 2000)
|
||||
}
|
||||
|
|
@ -1161,6 +1193,38 @@ onUnmounted(() => {
|
|||
.badge.pending { background: #F5F5F5; color: #999; }
|
||||
.badge.accent { background: #E3F2FD; color: #1565C0; }
|
||||
|
||||
.step-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.accelerate-btn {
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
padding: 5px 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #FF5722;
|
||||
background: #FFF;
|
||||
color: #FF5722;
|
||||
cursor: pointer;
|
||||
transition: all 0.18s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.accelerate-btn:hover:not(:disabled) {
|
||||
background: #FF5722;
|
||||
color: #FFF;
|
||||
}
|
||||
.accelerate-btn:disabled,
|
||||
.accelerate-btn.is-requested {
|
||||
cursor: not-allowed;
|
||||
border-color: #BDBDBD;
|
||||
color: #9E9E9E;
|
||||
background: #F5F5F5;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
/* No extra padding - uses step-card's padding */
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import Home from '../views/Home.vue'
|
|||
import Process from '../views/MainView.vue'
|
||||
import SimulationView from '../views/SimulationView.vue'
|
||||
import SimulationRunView from '../views/SimulationRunView.vue'
|
||||
import SimulationReplayView from '../views/SimulationReplayView.vue'
|
||||
import ReportView from '../views/ReportView.vue'
|
||||
import InteractionView from '../views/InteractionView.vue'
|
||||
|
||||
|
|
@ -30,6 +31,12 @@ const routes = [
|
|||
component: SimulationRunView,
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: '/simulation/:simulationId/replay',
|
||||
name: 'SimulationReplay',
|
||||
component: SimulationReplayView,
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: '/report/:reportId',
|
||||
name: 'Report',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,901 @@
|
|||
<template>
|
||||
<div class="replay-view">
|
||||
<!-- Header -->
|
||||
<header class="replay-header">
|
||||
<div class="header-left">
|
||||
<button class="back-btn" @click="goBack">← {{ $t('common.back') }}</button>
|
||||
<div class="brand">
|
||||
<span class="brand-en">foresight</span>
|
||||
<span class="brand-sep">·</span>
|
||||
<span class="brand-zh">回放</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-center">
|
||||
<span class="sim-id mono">{{ simulationId }}</span>
|
||||
<span class="status-badge" :class="statusClass">{{ statusLabel }}</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button class="icon-btn" @click="reload" :title="$t('common.refresh') || '刷新'">↻</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Loading / Error -->
|
||||
<div v-if="loading" class="loading-screen">
|
||||
<div class="loading-text">加载回放数据…</div>
|
||||
</div>
|
||||
<div v-else-if="loadError" class="error-screen">
|
||||
<div class="error-text">加载失败: {{ loadError }}</div>
|
||||
<button class="primary-btn" @click="reload">重试</button>
|
||||
</div>
|
||||
|
||||
<!-- Main 3-column layout -->
|
||||
<div v-else-if="replayData" class="replay-body">
|
||||
<!-- LEFT: Workflow timeline -->
|
||||
<aside class="col-workflow">
|
||||
<h3 class="col-title">工作流时间线</h3>
|
||||
<div class="workflow-list">
|
||||
<div
|
||||
v-for="step in replayData.workflow"
|
||||
:key="step.step"
|
||||
class="workflow-step"
|
||||
:class="{ 'is-completed': step.status === 'completed', 'is-running': step.status === 'running' }"
|
||||
>
|
||||
<div class="step-marker">
|
||||
<span class="step-num">{{ step.step }}</span>
|
||||
</div>
|
||||
<div class="step-body">
|
||||
<div class="step-name">{{ step.name }}</div>
|
||||
<div class="step-meta">
|
||||
<template v-if="step.step === 1">
|
||||
<div class="meta-line">{{ step.metadata.files?.length || 0 }} 个文件 · {{ formatLength(step.metadata.text_length) }}</div>
|
||||
<div class="meta-line dim" v-if="step.metadata.requirement">需求:{{ truncate(step.metadata.requirement, 40) }}</div>
|
||||
</template>
|
||||
<template v-else-if="step.step === 2">
|
||||
<div class="meta-line">{{ step.metadata.entities_count }} 个实体</div>
|
||||
<div class="meta-line dim mono">{{ step.metadata.graph_id }}</div>
|
||||
</template>
|
||||
<template v-else-if="step.step === 3">
|
||||
<div class="meta-line">{{ step.metadata.profiles_count }} 个 Agent 人设</div>
|
||||
<div class="meta-line dim">已加载 {{ step.metadata.agents_loaded }}</div>
|
||||
</template>
|
||||
<template v-else-if="step.step === 4">
|
||||
<div class="meta-line">{{ step.metadata.total_simulation_hours }}h / 每轮 {{ step.metadata.minutes_per_round }}min</div>
|
||||
<div class="meta-line dim">{{ step.metadata.initial_posts_count }} 条初始帖子</div>
|
||||
</template>
|
||||
<template v-else-if="step.step === 5">
|
||||
<div class="meta-line">{{ step.metadata.total_rounds_executed }} 轮 · {{ step.metadata.total_actions }} 动作</div>
|
||||
<div class="meta-line dim">
|
||||
Twitter {{ step.metadata.by_platform?.twitter || 0 }} · Reddit {{ step.metadata.by_platform?.reddit || 0 }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="step-status-tag" :class="step.status">{{ stepStatusLabel(step.status) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- CENTER: Featured action + scrolling feed -->
|
||||
<main class="col-center">
|
||||
<!-- Featured action card -->
|
||||
<section class="featured-action">
|
||||
<div class="featured-header">
|
||||
<span class="section-label">当前动作</span>
|
||||
<span v-if="currentAction" class="action-counter mono">
|
||||
{{ currentActionIndex + 1 }} / {{ allActions.length }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="currentAction" class="action-card large">
|
||||
<div class="action-card-top">
|
||||
<div class="action-agent">
|
||||
<div class="agent-avatar">{{ agentInitial(currentAction.agent_name) }}</div>
|
||||
<div class="agent-info">
|
||||
<div class="agent-name">{{ currentAction.agent_name }}</div>
|
||||
<div class="agent-meta mono">#{{ currentAction.agent_id }} · {{ getAgentProfession(currentAction.agent_id) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-tags">
|
||||
<span class="tag platform" :class="currentAction.platform">{{ currentAction.platform }}</span>
|
||||
<span class="tag action-type">{{ currentAction.action_type }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-content">
|
||||
{{ currentAction.action_args?.content || currentAction.action_args?.text || JSON.stringify(currentAction.action_args) }}
|
||||
</div>
|
||||
<div class="action-card-bottom">
|
||||
<span class="time-info">
|
||||
Day {{ currentRound?.simulated_day || '-' }} · {{ String(currentRound?.simulated_hour ?? 0).padStart(2, '0') }}:00
|
||||
</span>
|
||||
<span class="time-info mono dim">round {{ currentAction.round_num }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-card">
|
||||
<span>还没有动作可回放</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Action feed -->
|
||||
<section class="feed-section">
|
||||
<div class="feed-header">
|
||||
<span class="section-label">动作流</span>
|
||||
<span class="dim mono">{{ visibleFeed.length }} / {{ allActions.length }}</span>
|
||||
</div>
|
||||
<div class="feed-list" ref="feedListRef">
|
||||
<div
|
||||
v-for="(action, idx) in visibleFeed"
|
||||
:key="idx"
|
||||
class="feed-row"
|
||||
:class="{ active: idx === visibleFeed.length - 1 }"
|
||||
@click="jumpToActionByGlobalIndex(action._gIdx)"
|
||||
>
|
||||
<span class="feed-time mono">r{{ action.round_num }}</span>
|
||||
<span class="feed-platform" :class="action.platform">{{ action.platform[0].toUpperCase() }}</span>
|
||||
<span class="feed-agent">{{ action.agent_name }}</span>
|
||||
<span class="feed-type">{{ action.action_type }}</span>
|
||||
<span class="feed-content">{{ truncate(action.action_args?.content || '', 60) }}</span>
|
||||
</div>
|
||||
<div v-if="visibleFeed.length === 0" class="feed-empty">尚无动作记录</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- RIGHT: Aggregate stats -->
|
||||
<aside class="col-stats">
|
||||
<h3 class="col-title">全局统计</h3>
|
||||
|
||||
<div class="stat-block">
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">总动作</span>
|
||||
<span class="stat-value mono">{{ replayData.aggregate.total_actions }}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">已跑 rounds</span>
|
||||
<span class="stat-value mono">{{ replayData.aggregate.rounds_with_actions }}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Agent 总数</span>
|
||||
<span class="stat-value mono">{{ replayData.agents.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-section">
|
||||
<div class="section-label-sm">类型分布</div>
|
||||
<div v-for="(count, type) in replayData.aggregate.action_type_distribution" :key="type" class="bar-row">
|
||||
<span class="bar-label">{{ type }}</span>
|
||||
<div class="bar-track">
|
||||
<div class="bar-fill" :style="{ width: barWidth(count) + '%' }"></div>
|
||||
</div>
|
||||
<span class="bar-num mono">{{ count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-section">
|
||||
<div class="section-label-sm">Top Agents</div>
|
||||
<div v-for="agent in replayData.aggregate.top_agents.slice(0, 8)" :key="agent.agent_id" class="top-agent-row">
|
||||
<span class="top-agent-rank mono">#{{ agent.agent_id }}</span>
|
||||
<span class="top-agent-name">{{ agent.agent_name }}</span>
|
||||
<span class="top-agent-count mono">{{ agent.count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-section">
|
||||
<div class="section-label-sm">平台分布</div>
|
||||
<div class="platform-split">
|
||||
<div class="platform-item twitter">
|
||||
<span class="platform-name">Twitter</span>
|
||||
<span class="platform-count mono">{{ replayData.aggregate.platform_totals?.twitter || 0 }}</span>
|
||||
</div>
|
||||
<div class="platform-item reddit">
|
||||
<span class="platform-name">Reddit</span>
|
||||
<span class="platform-count mono">{{ replayData.aggregate.platform_totals?.reddit || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- Bottom scrubber -->
|
||||
<footer v-if="replayData && allActions.length > 0" class="replay-footer">
|
||||
<div class="footer-controls">
|
||||
<button class="ctrl-btn" @click="stepBack" :disabled="currentActionIndex === 0">⏮</button>
|
||||
<button class="ctrl-btn primary" @click="togglePlay">
|
||||
{{ isPlaying ? '⏸' : '▶' }}
|
||||
</button>
|
||||
<button class="ctrl-btn" @click="stepForward" :disabled="currentActionIndex >= allActions.length - 1">⏭</button>
|
||||
</div>
|
||||
|
||||
<div class="scrubber">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
:max="allActions.length - 1"
|
||||
v-model.number="currentActionIndex"
|
||||
@input="onScrubberInput"
|
||||
/>
|
||||
<div class="scrubber-info">
|
||||
<span class="info-left mono">
|
||||
{{ currentActionIndex + 1 }} / {{ allActions.length }}
|
||||
· round {{ currentAction?.round_num ?? '-' }}/{{ replayData.config?.time_config?.total_simulation_hours
|
||||
? Math.floor((replayData.config.time_config.total_simulation_hours * 60) / replayData.config.time_config.minutes_per_round)
|
||||
: '?' }}
|
||||
</span>
|
||||
<span class="info-right">
|
||||
Day {{ currentRound?.simulated_day || '-' }} ·
|
||||
{{ String(currentRound?.simulated_hour ?? 0).padStart(2, '0') }}:00
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer-speed">
|
||||
<span class="speed-label">速度</span>
|
||||
<button
|
||||
v-for="s in [0.5, 1, 2, 5, 10]"
|
||||
:key="s"
|
||||
class="speed-btn"
|
||||
:class="{ active: speed === s }"
|
||||
@click="speed = s"
|
||||
>{{ s }}x</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getSimulationReplay } from '../api/simulation'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const simulationId = route.params.simulationId
|
||||
|
||||
const loading = ref(true)
|
||||
const loadError = ref(null)
|
||||
const replayData = ref(null)
|
||||
const currentActionIndex = ref(0)
|
||||
const isPlaying = ref(false)
|
||||
const speed = ref(1)
|
||||
const feedListRef = ref(null)
|
||||
|
||||
let playTimer = null
|
||||
|
||||
// All actions flattened in time order, each carrying its global index
|
||||
const allActions = computed(() => {
|
||||
if (!replayData.value) return []
|
||||
const out = []
|
||||
let g = 0
|
||||
for (const round of replayData.value.rounds) {
|
||||
for (const action of round.actions) {
|
||||
out.push({ ...action, _gIdx: g, _round: round })
|
||||
g++
|
||||
}
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
const currentAction = computed(() => allActions.value[currentActionIndex.value] || null)
|
||||
const currentRound = computed(() => currentAction.value?._round || null)
|
||||
|
||||
const visibleFeed = computed(() => {
|
||||
// Show last 30 actions up to current
|
||||
const end = currentActionIndex.value + 1
|
||||
const start = Math.max(0, end - 30)
|
||||
return allActions.value.slice(start, end)
|
||||
})
|
||||
|
||||
const statusClass = computed(() => {
|
||||
const s = replayData.value?.simulation?.status
|
||||
if (s === 'running') return 'running'
|
||||
if (s === 'completed' || s === 'ready') return 'completed'
|
||||
if (s === 'failed') return 'failed'
|
||||
return ''
|
||||
})
|
||||
const statusLabel = computed(() => {
|
||||
const s = replayData.value?.simulation?.status || '-'
|
||||
return ({ running: '运行中', completed: '已完成', ready: '就绪', failed: '失败', preparing: '准备中' })[s] || s
|
||||
})
|
||||
|
||||
const maxTypeCount = computed(() => {
|
||||
const dist = replayData.value?.aggregate?.action_type_distribution || {}
|
||||
return Math.max(1, ...Object.values(dist))
|
||||
})
|
||||
|
||||
function barWidth(count) {
|
||||
return Math.round((count / maxTypeCount.value) * 100)
|
||||
}
|
||||
|
||||
function stepStatusLabel(s) {
|
||||
return ({ completed: '✓', running: '运行中', pending: '待开始', failed: '失败', ready: '就绪' })[s] || s
|
||||
}
|
||||
|
||||
function truncate(s, n) {
|
||||
if (!s) return ''
|
||||
return s.length > n ? s.slice(0, n) + '…' : s
|
||||
}
|
||||
|
||||
function formatLength(n) {
|
||||
if (!n) return '0 字符'
|
||||
if (n > 10000) return (n / 10000).toFixed(1) + 'w 字'
|
||||
if (n > 1000) return (n / 1000).toFixed(1) + 'k 字'
|
||||
return n + ' 字'
|
||||
}
|
||||
|
||||
function agentInitial(name) {
|
||||
if (!name) return '?'
|
||||
return name.slice(0, 1).toUpperCase()
|
||||
}
|
||||
|
||||
function getAgentProfession(agentId) {
|
||||
const a = replayData.value?.agents?.find(x => x.id === agentId)
|
||||
return a?.profession || '-'
|
||||
}
|
||||
|
||||
async function loadReplay() {
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
const res = await getSimulationReplay(simulationId)
|
||||
if (res.success) {
|
||||
replayData.value = res.data
|
||||
// Reset to last action so user sees the most recent state
|
||||
const total = res.data.rounds.reduce((sum, r) => sum + r.actions.length, 0)
|
||||
currentActionIndex.value = Math.max(0, total - 1)
|
||||
} else {
|
||||
loadError.value = res.error || '未知错误'
|
||||
}
|
||||
} catch (e) {
|
||||
loadError.value = e.message || String(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function reload() {
|
||||
pause()
|
||||
loadReplay()
|
||||
}
|
||||
|
||||
function togglePlay() {
|
||||
if (isPlaying.value) pause()
|
||||
else play()
|
||||
}
|
||||
|
||||
function play() {
|
||||
if (currentActionIndex.value >= allActions.value.length - 1) {
|
||||
currentActionIndex.value = 0
|
||||
}
|
||||
isPlaying.value = true
|
||||
scheduleNextStep()
|
||||
}
|
||||
|
||||
function pause() {
|
||||
isPlaying.value = false
|
||||
if (playTimer) {
|
||||
clearTimeout(playTimer)
|
||||
playTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleNextStep() {
|
||||
if (!isPlaying.value) return
|
||||
const intervalMs = 800 / speed.value
|
||||
playTimer = setTimeout(() => {
|
||||
if (currentActionIndex.value < allActions.value.length - 1) {
|
||||
currentActionIndex.value++
|
||||
scrollFeedToBottom()
|
||||
scheduleNextStep()
|
||||
} else {
|
||||
pause()
|
||||
}
|
||||
}, intervalMs)
|
||||
}
|
||||
|
||||
function stepForward() {
|
||||
pause()
|
||||
if (currentActionIndex.value < allActions.value.length - 1) {
|
||||
currentActionIndex.value++
|
||||
scrollFeedToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
function stepBack() {
|
||||
pause()
|
||||
if (currentActionIndex.value > 0) {
|
||||
currentActionIndex.value--
|
||||
scrollFeedToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
function onScrubberInput() {
|
||||
pause()
|
||||
scrollFeedToBottom()
|
||||
}
|
||||
|
||||
function jumpToActionByGlobalIndex(idx) {
|
||||
pause()
|
||||
currentActionIndex.value = idx
|
||||
}
|
||||
|
||||
function scrollFeedToBottom() {
|
||||
nextTick(() => {
|
||||
if (feedListRef.value) {
|
||||
feedListRef.value.scrollTop = feedListRef.value.scrollHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.back()
|
||||
}
|
||||
|
||||
watch(speed, () => {
|
||||
if (isPlaying.value) {
|
||||
if (playTimer) clearTimeout(playTimer)
|
||||
scheduleNextStep()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadReplay()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
pause()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.replay-view {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #FAFAFA;
|
||||
font-family: 'Space Grotesk', 'Noto Sans SC', system-ui, sans-serif;
|
||||
color: #1A1A1A;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mono { font-family: 'JetBrains Mono', monospace; }
|
||||
.dim { color: #999; }
|
||||
|
||||
/* ========== Header ========== */
|
||||
.replay-header {
|
||||
height: 56px;
|
||||
background: #FFF;
|
||||
border-bottom: 1px solid #E5E5E5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.header-left { display: flex; align-items: center; gap: 16px; flex: 1; }
|
||||
.header-center { display: flex; align-items: center; gap: 12px; flex: 1; justify-content: center; }
|
||||
.header-right { flex: 1; display: flex; justify-content: flex-end; gap: 8px; }
|
||||
|
||||
.back-btn {
|
||||
background: transparent;
|
||||
border: 1px solid #E5E5E5;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.back-btn:hover { background: #F5F5F5; color: #000; }
|
||||
|
||||
.brand { display: flex; align-items: baseline; gap: 6px; font-weight: 600; }
|
||||
.brand-en { font-size: 14px; letter-spacing: 0.5px; }
|
||||
.brand-sep { color: #CCC; }
|
||||
.brand-zh { font-size: 13px; }
|
||||
|
||||
.sim-id { font-size: 11px; color: #666; padding: 4px 8px; background: #F5F5F5; border-radius: 4px; }
|
||||
|
||||
.status-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
background: #F5F5F5;
|
||||
color: #999;
|
||||
}
|
||||
.status-badge.running { background: #FFF3E0; color: #FF5722; }
|
||||
.status-badge.completed { background: #E8F5E9; color: #2E7D32; }
|
||||
.status-badge.failed { background: #FFEBEE; color: #C62828; }
|
||||
|
||||
.icon-btn {
|
||||
background: transparent;
|
||||
border: 1px solid #E5E5E5;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
}
|
||||
.icon-btn:hover { background: #F5F5F5; color: #000; }
|
||||
|
||||
/* ========== Loading / Error ========== */
|
||||
.loading-screen, .error-screen {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.loading-text, .error-text { color: #666; font-size: 14px; }
|
||||
|
||||
.primary-btn {
|
||||
background: #1A1A1A;
|
||||
color: #FFF;
|
||||
border: none;
|
||||
padding: 8px 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ========== Body 3-col layout ========== */
|
||||
.replay-body {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr 280px;
|
||||
gap: 1px;
|
||||
background: #E5E5E5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.col-workflow, .col-stats {
|
||||
background: #FFF;
|
||||
padding: 20px 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.col-center {
|
||||
background: #FFF;
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.col-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: #999;
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
|
||||
/* ========== Workflow ========== */
|
||||
.workflow-list { display: flex; flex-direction: column; gap: 4px; position: relative; }
|
||||
|
||||
.workflow-step {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
position: relative;
|
||||
}
|
||||
.workflow-step:not(:last-child)::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 13px;
|
||||
top: 36px;
|
||||
bottom: -4px;
|
||||
width: 1px;
|
||||
background: #E5E5E5;
|
||||
}
|
||||
|
||||
.step-marker {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: #F5F5F5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid #E0E0E0;
|
||||
z-index: 1;
|
||||
}
|
||||
.workflow-step.is-completed .step-marker { background: #E8F5E9; border-color: #4CAF50; }
|
||||
.workflow-step.is-running .step-marker { background: #FFF3E0; border-color: #FF5722; animation: pulse-border 1.5s infinite; }
|
||||
|
||||
.step-num { font-size: 11px; font-weight: 700; color: #666; }
|
||||
.workflow-step.is-completed .step-num { color: #2E7D32; }
|
||||
.workflow-step.is-running .step-num { color: #FF5722; }
|
||||
|
||||
.step-body { flex: 1; min-width: 0; }
|
||||
.step-name { font-size: 13px; font-weight: 600; margin-bottom: 4px; }
|
||||
.step-meta { font-size: 11px; color: #666; line-height: 1.5; }
|
||||
.meta-line { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.step-status-tag {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
margin-top: 4px;
|
||||
background: #F5F5F5;
|
||||
color: #999;
|
||||
}
|
||||
.step-status-tag.completed { background: #E8F5E9; color: #2E7D32; }
|
||||
.step-status-tag.running { background: #FFF3E0; color: #FF5722; }
|
||||
.step-status-tag.failed { background: #FFEBEE; color: #C62828; }
|
||||
|
||||
@keyframes pulse-border {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(255, 87, 34, 0.4); }
|
||||
50% { box-shadow: 0 0 0 4px rgba(255, 87, 34, 0); }
|
||||
}
|
||||
|
||||
/* ========== Featured action ========== */
|
||||
.featured-action { display: flex; flex-direction: column; gap: 12px; flex-shrink: 0; }
|
||||
.featured-header { display: flex; justify-content: space-between; align-items: baseline; }
|
||||
.section-label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #999; }
|
||||
.action-counter { font-size: 11px; color: #666; }
|
||||
|
||||
.action-card.large {
|
||||
background: #FFF;
|
||||
border: 1px solid #E5E5E5;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.action-card-top { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 14px; }
|
||||
.action-agent { display: flex; gap: 12px; align-items: center; }
|
||||
.agent-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #FF5722, #FF8A65);
|
||||
color: #FFF;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
}
|
||||
.agent-name { font-size: 14px; font-weight: 600; }
|
||||
.agent-meta { font-size: 11px; color: #999; margin-top: 2px; }
|
||||
|
||||
.action-tags { display: flex; gap: 6px; }
|
||||
.tag {
|
||||
font-size: 10px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 3px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.tag.platform.twitter { background: #E1F5FE; color: #0277BD; }
|
||||
.tag.platform.reddit { background: #FFEBEE; color: #C62828; }
|
||||
.tag.action-type { background: #F5F5F5; color: #666; }
|
||||
|
||||
.action-content {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #1A1A1A;
|
||||
padding: 12px 0;
|
||||
border-top: 1px solid #F0F0F0;
|
||||
border-bottom: 1px solid #F0F0F0;
|
||||
margin-bottom: 12px;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.action-card-bottom { display: flex; justify-content: space-between; font-size: 11px; color: #666; }
|
||||
.time-info { display: flex; align-items: center; gap: 4px; }
|
||||
|
||||
.empty-card {
|
||||
background: #F9F9F9;
|
||||
border: 1px dashed #E0E0E0;
|
||||
border-radius: 8px;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ========== Feed ========== */
|
||||
.feed-section { flex: 1; display: flex; flex-direction: column; gap: 8px; min-height: 0; }
|
||||
.feed-header { display: flex; justify-content: space-between; align-items: baseline; }
|
||||
|
||||
.feed-list {
|
||||
flex: 1;
|
||||
background: #FAFAFA;
|
||||
border: 1px solid #E5E5E5;
|
||||
border-radius: 6px;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.feed-row {
|
||||
display: grid;
|
||||
grid-template-columns: 36px 20px 100px 110px 1fr;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
align-items: center;
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
.feed-row:hover { background: #F0F0F0; }
|
||||
.feed-row.active {
|
||||
background: #FFF3E0;
|
||||
border-left-color: #FF5722;
|
||||
}
|
||||
|
||||
.feed-time { color: #999; font-family: 'JetBrains Mono', monospace; }
|
||||
.feed-platform {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 10px;
|
||||
}
|
||||
.feed-platform.twitter { background: #E1F5FE; color: #0277BD; }
|
||||
.feed-platform.reddit { background: #FFEBEE; color: #C62828; }
|
||||
|
||||
.feed-agent { font-weight: 600; color: #1A1A1A; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.feed-type { color: #666; font-size: 10px; }
|
||||
.feed-content { color: #555; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.feed-empty { text-align: center; color: #BBB; padding: 40px; font-size: 12px; }
|
||||
|
||||
/* ========== Stats column ========== */
|
||||
.stat-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: #FAFAFA;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.stat-row { display: flex; justify-content: space-between; align-items: center; }
|
||||
.stat-label { font-size: 12px; color: #666; }
|
||||
.stat-value { font-size: 14px; font-weight: 700; }
|
||||
|
||||
.stat-section { margin-bottom: 24px; }
|
||||
.section-label-sm {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: #999;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.bar-row { display: grid; grid-template-columns: 80px 1fr 32px; gap: 8px; align-items: center; padding: 4px 0; font-size: 11px; }
|
||||
.bar-label { color: #666; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bar-track { background: #F0F0F0; height: 6px; border-radius: 3px; overflow: hidden; }
|
||||
.bar-fill { background: #FF5722; height: 100%; transition: width 0.3s; }
|
||||
.bar-num { color: #1A1A1A; text-align: right; font-size: 11px; font-weight: 600; }
|
||||
|
||||
.top-agent-row {
|
||||
display: grid;
|
||||
grid-template-columns: 28px 1fr 32px;
|
||||
gap: 8px;
|
||||
padding: 5px 0;
|
||||
font-size: 11px;
|
||||
align-items: center;
|
||||
}
|
||||
.top-agent-rank { color: #999; }
|
||||
.top-agent-name { color: #1A1A1A; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.top-agent-count { text-align: right; font-weight: 600; }
|
||||
|
||||
.platform-split { display: flex; gap: 8px; }
|
||||
.platform-item {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.platform-item.twitter { background: #E1F5FE; color: #0277BD; }
|
||||
.platform-item.reddit { background: #FFEBEE; color: #C62828; }
|
||||
.platform-name { font-size: 10px; font-weight: 600; text-transform: uppercase; }
|
||||
.platform-count { font-size: 18px; font-weight: 700; }
|
||||
|
||||
/* ========== Footer scrubber ========== */
|
||||
.replay-footer {
|
||||
height: 72px;
|
||||
background: #FFF;
|
||||
border-top: 1px solid #E5E5E5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 24px;
|
||||
gap: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.footer-controls { display: flex; gap: 6px; align-items: center; }
|
||||
.ctrl-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #E0E0E0;
|
||||
background: #FFF;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.ctrl-btn:hover:not(:disabled) { background: #F5F5F5; color: #000; }
|
||||
.ctrl-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.ctrl-btn.primary {
|
||||
background: #1A1A1A;
|
||||
color: #FFF;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
font-size: 16px;
|
||||
border-color: #1A1A1A;
|
||||
}
|
||||
.ctrl-btn.primary:hover:not(:disabled) { background: #333; }
|
||||
|
||||
.scrubber { flex: 1; display: flex; flex-direction: column; gap: 4px; }
|
||||
.scrubber input[type="range"] {
|
||||
width: 100%;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
height: 24px;
|
||||
margin: 0;
|
||||
}
|
||||
.scrubber input[type="range"]::-webkit-slider-runnable-track {
|
||||
height: 4px;
|
||||
background: #E5E5E5;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.scrubber input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #FF5722;
|
||||
border-radius: 50%;
|
||||
margin-top: -5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.scrubber input[type="range"]::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #FF5722;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
.scrubber-info { display: flex; justify-content: space-between; font-size: 11px; }
|
||||
.info-left { color: #666; }
|
||||
.info-right { color: #1A1A1A; font-weight: 600; }
|
||||
|
||||
.footer-speed { display: flex; gap: 4px; align-items: center; }
|
||||
.speed-label { font-size: 11px; color: #999; margin-right: 4px; }
|
||||
.speed-btn {
|
||||
background: transparent;
|
||||
border: 1px solid #E5E5E5;
|
||||
padding: 4px 10px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
.speed-btn:hover { background: #F5F5F5; }
|
||||
.speed-btn.active { background: #1A1A1A; color: #FFF; border-color: #1A1A1A; }
|
||||
</style>
|
||||
|
|
@ -103,6 +103,9 @@
|
|||
"asyncTaskDone": "Async task completed",
|
||||
"generateAgentPersona": "Generate Agent Personas",
|
||||
"generateAgentPersonaDesc": "Combine context to auto-extract entities and relations from the knowledge graph, initialize simulated individuals, and assign unique behaviors and memories based on reality seeds",
|
||||
"accelerateComplete": "Skip & Continue",
|
||||
"acceleratePending": "Finishing…",
|
||||
"accelerateHint": "Stop generating remaining personas and proceed to the next step with the ones already generated",
|
||||
"currentAgentCount": "Current Agents",
|
||||
"expectedAgentTotal": "Expected Total Agents",
|
||||
"relatedTopicsCount": "Reality Seed Related Topics",
|
||||
|
|
@ -453,6 +456,9 @@
|
|||
"preparingScripts": "Preparing Scripts"
|
||||
},
|
||||
"log": {
|
||||
"accelerateRequested": "⚡ Skip requested: proceeding to next step with {count} generated Agent personas",
|
||||
"accelerateNoProfiles": "⚠ No personas generated yet, cannot skip",
|
||||
"accelerateFailed": "Skip request failed: {error}",
|
||||
"preparingGoBack": "Preparing to return to Step 2, closing simulation...",
|
||||
"closingSimEnv": "Closing simulation environment...",
|
||||
"simEnvClosed": "✓ Simulation environment closed",
|
||||
|
|
|
|||
|
|
@ -103,6 +103,9 @@
|
|||
"asyncTaskDone": "异步任务已完成",
|
||||
"generateAgentPersona": "生成 Agent 人设",
|
||||
"generateAgentPersonaDesc": "结合上下文,自动调用工具从知识图谱梳理实体与关系,初始化模拟个体,并基于现实种子赋予他们独特的行为与记忆",
|
||||
"accelerateComplete": "加速完成",
|
||||
"acceleratePending": "正在加速…",
|
||||
"accelerateHint": "停止继续生成剩余人设,使用已生成的人设直接进入下一步",
|
||||
"currentAgentCount": "当前Agent数",
|
||||
"expectedAgentTotal": "预期Agent总数",
|
||||
"relatedTopicsCount": "现实种子当前关联话题数",
|
||||
|
|
@ -453,6 +456,9 @@
|
|||
"preparingScripts": "准备模拟脚本"
|
||||
},
|
||||
"log": {
|
||||
"accelerateRequested": "⚡ 已请求加速完成:将使用已生成的 {count} 个 Agent 人设进入下一步",
|
||||
"accelerateNoProfiles": "⚠ 暂无已生成的人设,无法加速完成",
|
||||
"accelerateFailed": "加速完成请求失败: {error}",
|
||||
"preparingGoBack": "准备返回 Step 2,正在关闭模拟...",
|
||||
"closingSimEnv": "正在关闭模拟环境...",
|
||||
"simEnvClosed": "✓ 模拟环境已关闭",
|
||||
|
|
|
|||
Loading…
Reference in New Issue