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:
liyizhouAI 2026-04-15 09:37:22 +08:00
parent 8088b927f0
commit 1fef01d979
21 changed files with 2361 additions and 246 deletions

641
PRD.md
View File

@ -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/replayManus 式过程回放)
└── 后端 (Flask + Python)
端口: 5001
└── HTTPS → api.foresight.yizhou.chat (Nginx)
├── LLM API (MiniMax M2.7 Highspeed)
│ 用途: 本体生成、画像生成、配置生成、报告生成
└── Zep Cloud API
用途: 知识图谱存储、搜索、记忆更新
└── Backend Flask (5001) 服务器:腾讯云 2C8G
│ OSUbuntu 24.04
│ Python3.11.15 (uv 管理)
│ venv/opt/foresight/backend/.venv-311
├── LLM API智谱 GLM-4-Flash (之前用 MiniMax M2.7,已弃)
│ 用途:本体、画像、配置、报告、模拟决策
│ Endpointhttps://open.bigmodel.cn/api/paas/v4/
├── Knowledge GraphGraphiti + Neo4j (之前用 Zep Cloud已弃
│ 部署Docker 容器 neo4j:5.26-community
│ EmbeddingBAAI/bge-m3 via SiliconFlow
│ Graphiti LLMQwen2.5-32B via SiliconFlow
├── HF Hub Mirrorhf-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.11uv 管理) | 系统 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快速 MVP2-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 误杀子进程 bugv0.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] 前端 UIVue 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.15uv 管理 .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-flowinfra / 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` | 报告生成ReACTLLM + 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 按钮 |
---
## 附录 Av0.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 | 用户战略需求 |

View File

@ -85,11 +85,12 @@ Foresight 致力于打造映射现实的群体智能镜像,通过捕捉个体
## 🔄 工作流程
1. **图谱构建**:现实种子提取 & 个体与群体记忆注入 & GraphRAG构建
2. **环境搭建**:实体关系抽取 & 人设生成 & 环境配置Agent注入仿真参数
3. **开始模拟**:双平台并行模拟 & 自动解析预测需求 & 动态更新时序记忆
4. **报告生成**ReportAgent拥有丰富的工具集与模拟后环境进行深度交互
5. **深度互动**:与模拟世界中的任意一位进行对话 & 与ReportAgent进行对话
1. **图谱构建**:现实种子提取 & 个体与群体记忆注入 & GraphRAGGraphiti + Neo4j构建
2. **环境搭建**:实体关系抽取 & 人设生成 & 环境配置Agent注入仿真参数支持「加速完成」按钮
3. **开始模拟**双平台并行模拟Twitter + Reddit & 自动解析预测需求 & 动态更新时序记忆
4. **报告生成**ReportAgent 拥有丰富的工具集与模拟后环境进行深度交互
5. **深度互动**:与模拟世界中的任意一位进行对话 & 与 ReportAgent 进行对话
6. **过程回放v0.3 新增)**Manus 式可拖拽时间轴,回放整个工作流和每一轮 agent 行为,方便复盘和演示
## 🚀 快速开始

View File

@ -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

View File

@ -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')

View File

@ -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

View File

@ -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:

View File

@ -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({

View File

@ -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 残留的 actionsjsonl 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):
"""

118
backend/app/api/usage.py Normal file
View File

@ -0,0 +1,118 @@
"""
Token 用量查询 APIv0.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

View File

@ -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):
"""实时输出生成的人设到控制台(完整内容,不截断)"""

View File

@ -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]:

View File

@ -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/LinuxWindows 没有
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

View File

@ -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()

View File

@ -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 区间内浮动。",
}

View File

@ -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()

View File

@ -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

View File

@ -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 */
}

View File

@ -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',

View File

@ -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>

View File

@ -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",

View File

@ -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": "✓ 模拟环境已关闭",