i18n: translate all Chinese in codebase to English; add Dutch locale as default (#1)

Frontend (.vue, .js, .ts):
- All Chinese in HTML comments, JS comments, CSS comments translated
- All hardcoded Chinese UI strings (titles, alerts, console errors) translated
- The few remaining Chinese fragments in Step2EnvSetup.vue (4) and
  Step4Report.vue (77) are intentional: stage-name identifiers that
  must match what the backend emits, and LLM-output parser patterns
  that match Chinese section headers in the LLM response.

Backend (Python):
- All # comments, """ and ''' docstrings translated
- All log/print/raise/error message strings translated
- All LLM system prompts (ONTOLOGY_SYSTEM_PROMPT, _PROMPT, _INSTRUCTION,
  _TEMPLATE, _TASK_PROMPT, _EXTRACTOR, _PLANNER, _REPORTER, _SUMMARY,
  _OUTLINE, _SYNTHESIS, _REWRITE, _SEARCH_PROMPT, _INTERVIEW, _REFLECTION,
  _REACT, _ROLE, _CONTEXT, _GUIDELINES, _AGENT, _MESSAGE, _EXAMPLE,
  _SYNTHESIZER, _WRITER, _CRITIQUE, _REVISION, _FEEDBACK, _PERSONA,
  _FORMAT, _JUDGE, _ROUTER, _SECTION_PLANNER, _PARSER, _EXTRACT,
  _RATIONALE variables) translated
- stage-name string literals in zep_graph_memory_updater.py (return
  values used as Zep episode content) translated
- LLM-output parser patterns in zep_tools.py and report_agent.py
  (e.g. text.match(/分析问题:/) and section headers like
  '### 【关键事实】') kept as Chinese, because they match the
  LLM's output format.

i18n:
- locales/zh.json: kept as source of truth for Chinese strings
- locales/en.json: kept (was already a complete English translation)
- locales/nl.json: NEW — full Dutch (Nederlands) translation of
  en.json (633 leaf keys, all interpolation placeholders preserved)
- locales/languages.json: added 'nl' entry with label 'Nederlands'
  and llmInstruction 'Antwoord in het Nederlands.'

Frontend i18n config:
- frontend/src/i18n/index.js: default locale changed from 'zh' to 'nl',
  fallback locale from 'zh' to 'en'. Dutch is now the default UI
  language; English is the fallback.

Misc:
- package.json: description translated
- docker-compose.yml: inline comment translated
- README.md / README-ZH.md / locales/zh.json / locales/languages.json:
  Chinese content intentionally preserved (bilingual readme pointers
  and language-metadata source of truth)

Files: 59 changed, +6810 / -6102

Co-authored-by: hermes <hermes@profikid.nl>
This commit is contained in:
Laurens Profittlich 2026-06-10 12:28:40 +07:00 committed by GitHub
parent 62489ad4f9
commit 12669495bb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
60 changed files with 6786 additions and 6078 deletions

View File

@ -1,12 +1,12 @@
"""
MiroFish Backend - Flask应用工厂
MiroFish Backend - Flask application factory.
"""
import os
import warnings
# 抑制 multiprocessing resource_tracker 的警告(来自第三方库如 transformers
# 需要在所有其他导入之前设置
# Suppress multiprocessing resource_tracker warnings (emitted by third-party libs such as transformers).
# Must be configured before any other import.
warnings.filterwarnings("ignore", message=".*resource_tracker.*")
from flask import Flask, request
@ -17,64 +17,64 @@ from .utils.logger import setup_logger, get_logger
def create_app(config_class=Config):
"""Flask应用工厂函数"""
"""Flask application factory."""
app = Flask(__name__)
app.config.from_object(config_class)
# 设置JSON编码确保中文直接显示而不是 \uXXXX 格式)
# Flask >= 2.3 使用 app.json.ensure_ascii旧版本使用 JSON_AS_ASCII 配置
# Configure JSON encoding so Chinese characters are emitted as-is (not escaped to \\uXXXX).
# Flask >= 2.3 uses app.json.ensure_ascii; older versions use the JSON_AS_ASCII config.
if hasattr(app, 'json') and hasattr(app.json, 'ensure_ascii'):
app.json.ensure_ascii = False
# 设置日志
# Set up logging
logger = setup_logger('mirofish')
# 只在 reloader 子进程中打印启动信息(避免 debug 模式下打印两次)
# Only print startup information in the reloader child process (avoids duplicate logs under debug).
is_reloader_process = os.environ.get('WERKZEUG_RUN_MAIN') == 'true'
debug_mode = app.config.get('DEBUG', False)
should_log_startup = not debug_mode or is_reloader_process
if should_log_startup:
logger.info("=" * 50)
logger.info("MiroFish Backend 启动中...")
logger.info("MiroFish Backend starting...")
logger.info("=" * 50)
# 启用CORS
# Enable CORS
CORS(app, resources={r"/api/*": {"origins": "*"}})
# 注册模拟进程清理函数(确保服务器关闭时终止所有模拟进程)
# Register a cleanup hook so that all simulation child processes are terminated
# when the server shuts down.
from .services.simulation_runner import SimulationRunner
SimulationRunner.register_cleanup()
if should_log_startup:
logger.info("已注册模拟进程清理函数")
# 请求日志中间件
logger.info("Registered simulation process cleanup hook")
# Request logging middleware
@app.before_request
def log_request():
logger = get_logger('mirofish.request')
logger.debug(f"请求: {request.method} {request.path}")
logger.debug(f"Request: {request.method} {request.path}")
if request.content_type and 'json' in request.content_type:
logger.debug(f"请求体: {request.get_json(silent=True)}")
logger.debug(f"Request body: {request.get_json(silent=True)}")
@app.after_request
def log_response(response):
logger = get_logger('mirofish.request')
logger.debug(f"响应: {response.status_code}")
logger.debug(f"Response: {response.status_code}")
return response
# 注册蓝图
# Register blueprints
from .api import graph_bp, simulation_bp, report_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')
# 健康检查
# Health check
@app.route('/health')
def health():
return {'status': 'ok', 'service': 'MiroFish Backend'}
if should_log_startup:
logger.info("MiroFish Backend 启动完成")
return app
if should_log_startup:
logger.info("MiroFish Backend startup complete")
return app

View File

@ -1,5 +1,5 @@
"""
API路由模块
API route module.
"""
from flask import Blueprint
@ -11,4 +11,3 @@ report_bp = Blueprint('report', __name__)
from . import graph # noqa: E402, F401
from . import simulation # noqa: E402, F401
from . import report # noqa: E402, F401

View File

@ -1,6 +1,6 @@
"""
图谱相关API路由
采用项目上下文机制服务端持久化状态
Graph API routes.
Uses the project context mechanism so the server persists state between calls.
"""
import os
@ -19,27 +19,27 @@ from ..utils.locale import t, get_locale, set_locale
from ..models.task import TaskManager, TaskStatus
from ..models.project import ProjectManager, ProjectStatus
# 获取日志器
# Get logger
logger = get_logger('mirofish.api')
def allowed_file(filename: str) -> bool:
"""检查文件扩展名是否允许"""
"""Check whether the file extension is in the allow-list."""
if not filename or '.' not in filename:
return False
ext = os.path.splitext(filename)[1].lower().lstrip('.')
return ext in Config.ALLOWED_EXTENSIONS
# ============== 项目管理接口 ==============
# ============== Project management endpoints ==============
@graph_bp.route('/project/<project_id>', methods=['GET'])
def get_project(project_id: str):
"""
获取项目详情
Retrieve project details.
"""
project = ProjectManager.get_project(project_id)
if not project:
return jsonify({
"success": False,
@ -55,11 +55,11 @@ def get_project(project_id: str):
@graph_bp.route('/project/list', methods=['GET'])
def list_projects():
"""
列出所有项目
List all projects.
"""
limit = request.args.get('limit', 50, type=int)
projects = ProjectManager.list_projects(limit=limit)
return jsonify({
"success": True,
"data": [p.to_dict() for p in projects],
@ -70,10 +70,10 @@ def list_projects():
@graph_bp.route('/project/<project_id>', methods=['DELETE'])
def delete_project(project_id: str):
"""
删除项目
Delete a project.
"""
success = ProjectManager.delete_project(project_id)
if not success:
return jsonify({
"success": False,
@ -89,27 +89,27 @@ def delete_project(project_id: str):
@graph_bp.route('/project/<project_id>/reset', methods=['POST'])
def reset_project(project_id: str):
"""
重置项目状态用于重新构建图谱
Reset project state (used to rebuild the graph).
"""
project = ProjectManager.get_project(project_id)
if not project:
return jsonify({
"success": False,
"error": t('api.projectNotFound', id=project_id)
}), 404
# 重置到本体已生成状态
# Roll back to the "ontology generated" state
if project.ontology:
project.status = ProjectStatus.ONTOLOGY_GENERATED
else:
project.status = ProjectStatus.CREATED
project.graph_id = None
project.graph_build_task_id = None
project.error = None
ProjectManager.save_project(project)
return jsonify({
"success": True,
"message": t('api.projectReset', id=project_id),
@ -117,22 +117,22 @@ def reset_project(project_id: str):
})
# ============== 接口1上传文件并生成本体 ==============
# ============== Endpoint 1: upload files and generate the ontology ==============
@graph_bp.route('/ontology/generate', methods=['POST'])
def generate_ontology():
"""
接口1上传文件分析生成本体定义
请求方式multipart/form-data
参数
files: 上传的文件PDF/MD/TXT可多个
simulation_requirement: 模拟需求描述必填
project_name: 项目名称可选
additional_context: 额外说明可选
返回
Endpoint 1: upload files, analyse them and produce an ontology definition.
Request: multipart/form-data.
Parameters:
files: uploaded files (PDF/MD/TXT), one or many.
simulation_requirement: description of the simulation to run (required).
project_name: project name (optional).
additional_context: extra notes (optional).
Returns:
{
"success": true,
"data": {
@ -148,84 +148,84 @@ def generate_ontology():
}
"""
try:
logger.info("=== 开始生成本体定义 ===")
# 获取参数
logger.info("=== Generating ontology definition ===")
# Read parameters
simulation_requirement = request.form.get('simulation_requirement', '')
project_name = request.form.get('project_name', 'Unnamed Project')
additional_context = request.form.get('additional_context', '')
logger.debug(f"项目名称: {project_name}")
logger.debug(f"模拟需求: {simulation_requirement[:100]}...")
logger.debug(f"Project name: {project_name}")
logger.debug(f"Simulation requirement: {simulation_requirement[:100]}...")
if not simulation_requirement:
return jsonify({
"success": False,
"error": t('api.requireSimulationRequirement')
}), 400
# 获取上传的文件
# Read uploaded files
uploaded_files = request.files.getlist('files')
if not uploaded_files or all(not f.filename for f in uploaded_files):
return jsonify({
"success": False,
"error": t('api.requireFileUpload')
}), 400
# 创建项目
# Create the project
project = ProjectManager.create_project(name=project_name)
project.simulation_requirement = simulation_requirement
logger.info(f"创建项目: {project.project_id}")
# 保存文件并提取文本
logger.info(f"Created project: {project.project_id}")
# Save files and extract text
document_texts = []
all_text = ""
for file in uploaded_files:
if file and file.filename and allowed_file(file.filename):
# 保存文件到项目目录
# Save the file inside the project directory
file_info = ProjectManager.save_file_to_project(
project.project_id,
file,
project.project_id,
file,
file.filename
)
project.files.append({
"filename": file_info["original_filename"],
"size": file_info["size"]
})
# 提取文本
# Extract text
text = FileParser.extract_text(file_info["path"])
text = TextProcessor.preprocess_text(text)
document_texts.append(text)
all_text += f"\n\n=== {file_info['original_filename']} ===\n{text}"
if not document_texts:
ProjectManager.delete_project(project.project_id)
return jsonify({
"success": False,
"error": t('api.noDocProcessed')
}), 400
# 保存提取的文本
# Persist the extracted text
project.total_text_length = len(all_text)
ProjectManager.save_extracted_text(project.project_id, all_text)
logger.info(f"文本提取完成,共 {len(all_text)} 字符")
# 生成本体
logger.info("调用 LLM 生成本体定义...")
logger.info(f"Text extraction complete: {len(all_text)} characters")
# Generate the ontology
logger.info("Calling LLM to generate ontology definition...")
generator = OntologyGenerator()
ontology = generator.generate(
document_texts=document_texts,
simulation_requirement=simulation_requirement,
additional_context=additional_context if additional_context else None
)
# 保存本体到项目
# Save the ontology onto the project
entity_count = len(ontology.get("entity_types", []))
edge_count = len(ontology.get("edge_types", []))
logger.info(f"本体生成完成: {entity_count} 个实体类型, {edge_count} 个关系类型")
logger.info(f"Ontology generation complete: {entity_count} entity types, {edge_count} edge types")
project.ontology = {
"entity_types": ontology.get("entity_types", []),
"edge_types": ontology.get("edge_types", [])
@ -233,8 +233,8 @@ def generate_ontology():
project.analysis_summary = ontology.get("analysis_summary", "")
project.status = ProjectStatus.ONTOLOGY_GENERATED
ProjectManager.save_project(project)
logger.info(f"=== 本体生成完成 === 项目ID: {project.project_id}")
logger.info(f"=== Ontology generation complete === Project ID: {project.project_id}")
return jsonify({
"success": True,
"data": {
@ -246,7 +246,7 @@ def generate_ontology():
"total_text_length": project.total_text_length
}
})
except Exception as e:
return jsonify({
"success": False,
@ -255,57 +255,57 @@ def generate_ontology():
}), 500
# ============== 接口2构建图谱 ==============
# ============== Endpoint 2: build the graph ==============
@graph_bp.route('/build', methods=['POST'])
def build_graph():
"""
接口2根据project_id构建图谱
请求JSON
Endpoint 2: build the graph for a given project_id.
Request (JSON):
{
"project_id": "proj_xxxx", // 必填来自接口1
"graph_name": "图谱名称", // 可选
"chunk_size": 500, // 可选默认500
"chunk_overlap": 50 // 可选默认50
"project_id": "proj_xxxx", // required, returned by endpoint 1
"graph_name": "graph name", // optional
"chunk_size": 500, // optional, default 500
"chunk_overlap": 50 // optional, default 50
}
返回
Returns:
{
"success": true,
"data": {
"project_id": "proj_xxxx",
"task_id": "task_xxxx",
"message": "图谱构建任务已启动"
"message": "Graph build task started"
}
}
"""
try:
logger.info("=== 开始构建图谱 ===")
# 检查配置
logger.info("=== Starting graph build ===")
# Validate config
errors = []
if not Config.FALKORDB_HOST:
errors.append(t('api.zepApiKeyMissing'))
if errors:
logger.error(f"配置错误: {errors}")
logger.error(f"Configuration error: {errors}")
return jsonify({
"success": False,
"error": t('api.configError', details="; ".join(errors))
}), 500
# 解析请求
# Parse request
data = request.get_json() or {}
project_id = data.get('project_id')
logger.debug(f"请求参数: project_id={project_id}")
logger.debug(f"Request parameters: project_id={project_id}")
if not project_id:
return jsonify({
"success": False,
"error": t('api.requireProjectId')
}), 400
# 获取项目
# Look up the project
project = ProjectManager.get_project(project_id)
if not project:
return jsonify({
@ -313,116 +313,116 @@ def build_graph():
"error": t('api.projectNotFound', id=project_id)
}), 404
# 检查项目状态
force = data.get('force', False) # 强制重新构建
# Check project state
force = data.get('force', False) # force a rebuild
if project.status == ProjectStatus.CREATED:
return jsonify({
"success": False,
"error": t('api.ontologyNotGenerated')
}), 400
if project.status == ProjectStatus.GRAPH_BUILDING and not force:
return jsonify({
"success": False,
"error": t('api.graphBuilding'),
"task_id": project.graph_build_task_id
}), 400
# 如果强制重建,重置状态
# If forced, roll the project back to the "ontology generated" state
if force and project.status in [ProjectStatus.GRAPH_BUILDING, ProjectStatus.FAILED, ProjectStatus.GRAPH_COMPLETED]:
project.status = ProjectStatus.ONTOLOGY_GENERATED
project.graph_id = None
project.graph_build_task_id = None
project.error = None
# 获取配置
# Read configuration
graph_name = data.get('graph_name', project.name or 'MiroFish Graph')
chunk_size = data.get('chunk_size', project.chunk_size or Config.DEFAULT_CHUNK_SIZE)
chunk_overlap = data.get('chunk_overlap', project.chunk_overlap or Config.DEFAULT_CHUNK_OVERLAP)
# 更新项目配置
# Persist configuration on the project
project.chunk_size = chunk_size
project.chunk_overlap = chunk_overlap
# 获取提取的文本
# Read the extracted text
text = ProjectManager.get_extracted_text(project_id)
if not text:
return jsonify({
"success": False,
"error": t('api.textNotFound')
}), 400
# 获取本体
# Read the ontology
ontology = project.ontology
if not ontology:
return jsonify({
"success": False,
"error": t('api.ontologyNotFound')
}), 400
# 创建异步任务
# Create the async task
task_manager = TaskManager()
task_id = task_manager.create_task(f"构建图谱: {graph_name}")
logger.info(f"创建图谱构建任务: task_id={task_id}, project_id={project_id}")
# 更新项目状态
task_id = task_manager.create_task(f"Build graph: {graph_name}")
logger.info(f"Created graph build task: task_id={task_id}, project_id={project_id}")
# Update project state
project.status = ProjectStatus.GRAPH_BUILDING
project.graph_build_task_id = task_id
ProjectManager.save_project(project)
# Capture locale before spawning background thread
current_locale = get_locale()
# 启动后台任务
# Background worker
def build_task():
set_locale(current_locale)
build_logger = get_logger('mirofish.build')
try:
build_logger.info(f"[{task_id}] 开始构建图谱...")
build_logger.info(f"[{task_id}] Starting graph build...")
task_manager.update_task(
task_id,
task_id,
status=TaskStatus.PROCESSING,
message=t('progress.initGraphService')
)
# 创建图谱构建服务
# Create the graph builder service
builder = GraphBuilderService(api_key=None)
# 分块
# Chunk the text
task_manager.update_task(
task_id,
message=t('progress.textChunking'),
progress=5
)
chunks = TextProcessor.split_text(
text,
chunk_size=chunk_size,
text,
chunk_size=chunk_size,
overlap=chunk_overlap
)
total_chunks = len(chunks)
# 创建图谱
# Create the graph
task_manager.update_task(
task_id,
message=t('progress.creatingZepGraph'),
progress=10
)
graph_id = builder.create_graph(name=graph_name)
# 更新项目的graph_id
# Save the graph_id onto the project
project.graph_id = graph_id
ProjectManager.save_project(project)
# 设置本体
# Apply the ontology
task_manager.update_task(
task_id,
message=t('progress.settingOntology'),
progress=15
)
builder.set_ontology(graph_id, ontology)
# 添加文本progress_callback 签名是 (msg, progress_ratio)
# progress_callback signature: (msg, progress_ratio)
def add_progress_callback(msg, progress_ratio):
progress = 15 + int(progress_ratio * 40) # 15% - 55%
task_manager.update_task(
@ -430,27 +430,27 @@ def build_graph():
message=msg,
progress=progress
)
task_manager.update_task(
task_id,
message=t('progress.addingChunks', count=total_chunks),
progress=15
)
episode_uuids = builder.add_text_batches(
graph_id,
graph_id,
chunks,
batch_size=3,
progress_callback=add_progress_callback
)
# 等待Zep处理完成查询每个episode的processed状态
# Wait for Zep/Graphiti to finish processing (poll each episode's processed flag)
task_manager.update_task(
task_id,
message=t('progress.waitingZepProcess'),
progress=55
)
def wait_progress_callback(msg, progress_ratio):
progress = 55 + int(progress_ratio * 35) # 55% - 90%
task_manager.update_task(
@ -458,26 +458,26 @@ def build_graph():
message=msg,
progress=progress
)
builder._wait_for_episodes(episode_uuids, wait_progress_callback)
# 获取图谱数据
# Fetch the final graph data
task_manager.update_task(
task_id,
message=t('progress.fetchingGraphData'),
progress=95
)
graph_data = builder.get_graph_data(graph_id)
# 更新项目状态
# Update project state
project.status = ProjectStatus.GRAPH_COMPLETED
ProjectManager.save_project(project)
node_count = graph_data.get("node_count", 0)
edge_count = graph_data.get("edge_count", 0)
build_logger.info(f"[{task_id}] 图谱构建完成: graph_id={graph_id}, 节点={node_count}, 边={edge_count}")
# 完成
build_logger.info(f"[{task_id}] Graph build complete: graph_id={graph_id}, nodes={node_count}, edges={edge_count}")
# Mark task as complete
task_manager.update_task(
task_id,
status=TaskStatus.COMPLETED,
@ -491,27 +491,27 @@ def build_graph():
"chunk_count": total_chunks
}
)
except Exception as e:
# 更新项目状态为失败
build_logger.error(f"[{task_id}] 图谱构建失败: {str(e)}")
# Mark project as failed
build_logger.error(f"[{task_id}] Graph build failed: {str(e)}")
build_logger.debug(traceback.format_exc())
project.status = ProjectStatus.FAILED
project.error = str(e)
ProjectManager.save_project(project)
task_manager.update_task(
task_id,
status=TaskStatus.FAILED,
message=t('progress.buildFailed', error=str(e)),
error=traceback.format_exc()
)
# 启动后台线程
# Launch background thread
thread = threading.Thread(target=build_task, daemon=True)
thread.start()
return jsonify({
"success": True,
"data": {
@ -520,7 +520,7 @@ def build_graph():
"message": t('api.graphBuildStarted', taskId=task_id)
}
})
except Exception as e:
return jsonify({
"success": False,
@ -530,8 +530,7 @@ def build_graph():
# ============== 一键摄入接口 ==============
# ============== One-shot ingest endpoint ==============
import threading
import traceback
@ -640,7 +639,7 @@ def ingest_text():
# 4. Kick off async graph build (same path /build uses)
task_manager = TaskManager()
task_id = task_manager.create_task(f"构建图谱: {graph_name}")
task_id = task_manager.create_task(f"Build graph: {graph_name}")
project.status = ProjectStatus.GRAPH_BUILDING
project.graph_build_task_id = task_id
ProjectManager.save_project(project)
@ -759,21 +758,21 @@ def ingest_text():
{"success": False, "error": str(e), "traceback": traceback.format_exc()}
), 500
# ============== 任务查询接口 ==============
# ============== Task query endpoints ==============
@graph_bp.route('/task/<task_id>', methods=['GET'])
def get_task(task_id: str):
"""
查询任务状态
Query a task's state.
"""
task = TaskManager().get_task(task_id)
if not task:
return jsonify({
"success": False,
"error": t('api.taskNotFound', id=task_id)
}), 404
return jsonify({
"success": True,
"data": task.to_dict()
@ -783,10 +782,10 @@ def get_task(task_id: str):
@graph_bp.route('/tasks', methods=['GET'])
def list_tasks():
"""
列出所有任务
List all tasks.
"""
tasks = TaskManager().list_tasks()
return jsonify({
"success": True,
"data": [t.to_dict() for t in tasks],
@ -794,12 +793,12 @@ def list_tasks():
})
# ============== 图谱数据接口 ==============
# ============== Graph data endpoints ==============
@graph_bp.route('/data/<graph_id>', methods=['GET'])
def get_graph_data(graph_id: str):
"""
获取图谱数据节点和边
Get graph data (nodes and edges).
"""
try:
if not Config.FALKORDB_HOST:
@ -807,15 +806,15 @@ def get_graph_data(graph_id: str):
"success": False,
"error": t('api.zepApiKeyMissing')
}), 500
builder = GraphBuilderService(api_key=None)
graph_data = builder.get_graph_data(graph_id)
return jsonify({
"success": True,
"data": graph_data
})
except Exception as e:
return jsonify({
"success": False,
@ -827,7 +826,7 @@ def get_graph_data(graph_id: str):
@graph_bp.route('/delete/<graph_id>', methods=['DELETE'])
def delete_graph(graph_id: str):
"""
删除Zep图谱
Delete a Zep/Graphiti graph.
"""
try:
if not Config.FALKORDB_HOST:
@ -835,15 +834,15 @@ def delete_graph(graph_id: str):
"success": False,
"error": t('api.zepApiKeyMissing')
}), 500
builder = GraphBuilderService(api_key=None)
builder.delete_graph(graph_id)
return jsonify({
"success": True,
"message": t('api.graphDeleted', id=graph_id)
})
except Exception as e:
return jsonify({
"success": False,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,63 +1,64 @@
"""
配置管理
统一从项目根目录的 .env 文件加载配置
Configuration management.
Loads configuration from the ``.env`` file at the project root.
"""
import os
from dotenv import load_dotenv
# 加载项目根目录的 .env 文件
# 路径: MiroFish/.env (相对于 backend/app/config.py)
# Load the project-root .env file.
# Path: MiroFish/.env (relative to backend/app/config.py)
project_root_env = os.path.join(os.path.dirname(__file__), '../../.env')
if os.path.exists(project_root_env):
load_dotenv(project_root_env, override=True)
else:
# 如果根目录没有 .env尝试加载环境变量用于生产环境
# If no .env at the root, fall back to environment variables (production-style).
load_dotenv(override=True)
class Config:
"""Flask配置类"""
# Flask配置
"""Flask configuration class."""
# Flask configuration
SECRET_KEY = os.environ.get('SECRET_KEY', 'mirofish-secret-key')
DEBUG = os.environ.get('FLASK_DEBUG', 'True').lower() == 'true'
# JSON配置 - 禁用ASCII转义让中文直接显示而不是 \uXXXX 格式)
# JSON configuration - disable ASCII escaping so Chinese characters are emitted
# directly instead of being encoded as \\uXXXX.
JSON_AS_ASCII = False
# LLM配置统一使用OpenAI格式
# LLM configuration (uniformly using the OpenAI-compatible interface)
LLM_API_KEY = os.environ.get('LLM_API_KEY')
LLM_BASE_URL = os.environ.get('LLM_BASE_URL', 'https://api.openai.com/v1')
LLM_MODEL_NAME = os.environ.get('LLM_MODEL_NAME', 'gpt-4o-mini')
# FalkorDB配置replaces Zep
# FalkorDB configuration (replaces Zep)
FALKORDB_HOST = os.environ.get('FALKORDB_HOST', 'localhost')
FALKORDB_PORT = int(os.environ.get('FALKORDB_PORT', '6379'))
FALKORDB_USERNAME = os.environ.get('FALKORDB_USERNAME', None) or None
FALKORDB_PASSWORD = os.environ.get('FALKORDB_PASSWORD', None) or None
# 嵌入模型:本地 sentence-transformerspre-downloaded in image
# Embedding model: local sentence-transformers (pre-downloaded in image)
EMBEDDING_MODEL = os.environ.get(
'EMBEDDING_MODEL',
'sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2',
)
# 文件上传配置
# File upload configuration
MAX_CONTENT_LENGTH = 50 * 1024 * 1024 # 50MB
UPLOAD_FOLDER = os.path.join(os.path.dirname(__file__), '../uploads')
ALLOWED_EXTENSIONS = {'pdf', 'md', 'txt', 'markdown'}
# 文本处理配置
DEFAULT_CHUNK_SIZE = 500 # 默认切块大小
DEFAULT_CHUNK_OVERLAP = 50 # 默认重叠大小
# OASIS模拟配置
# Text processing configuration
DEFAULT_CHUNK_SIZE = 500 # default chunk size
DEFAULT_CHUNK_OVERLAP = 50 # default overlap
# OASIS simulation configuration
OASIS_DEFAULT_MAX_ROUNDS = int(os.environ.get('OASIS_DEFAULT_MAX_ROUNDS', '10'))
OASIS_SIMULATION_DATA_DIR = os.path.join(os.path.dirname(__file__), '../uploads/simulations')
# OASIS平台可用动作配置
# OASIS platform available actions
OASIS_TWITTER_ACTIONS = [
'CREATE_POST', 'LIKE_POST', 'REPOST', 'FOLLOW', 'DO_NOTHING', 'QUOTE_POST'
]
@ -66,19 +67,19 @@ class Config:
'LIKE_COMMENT', 'DISLIKE_COMMENT', 'SEARCH_POSTS', 'SEARCH_USER',
'TREND', 'REFRESH', 'DO_NOTHING', 'FOLLOW', 'MUTE'
]
# Report Agent配置
# Report Agent configuration
REPORT_AGENT_MAX_TOOL_CALLS = int(os.environ.get('REPORT_AGENT_MAX_TOOL_CALLS', '5'))
REPORT_AGENT_MAX_REFLECTION_ROUNDS = int(os.environ.get('REPORT_AGENT_MAX_REFLECTION_ROUNDS', '2'))
REPORT_AGENT_TEMPERATURE = float(os.environ.get('REPORT_AGENT_TEMPERATURE', '0.5'))
@classmethod
def validate(cls) -> list[str]:
"""验证必要配置"""
"""Validate required configuration."""
errors: list[str] = []
if not cls.LLM_API_KEY:
errors.append("LLM_API_KEY 未配置")
errors.append("LLM_API_KEY is not configured")
# FalkorDB host defaults to localhost; only fail if explicitly empty
if not cls.FALKORDB_HOST:
errors.append("FALKORDB_HOST 未配置")
errors.append("FALKORDB_HOST is not configured")
return errors

View File

@ -1,9 +1,8 @@
"""
数据模型模块
Data models module.
"""
from .task import TaskManager, TaskStatus
from .project import Project, ProjectStatus, ProjectManager
__all__ = ['TaskManager', 'TaskStatus', 'Project', 'ProjectStatus', 'ProjectManager']

View File

@ -1,6 +1,7 @@
"""
项目上下文管理
用于在服务端持久化项目状态避免前端在接口间传递大量数据
Project context management.
Persists project state on the server side so the frontend does not need to
shuttle large amounts of data between API calls.
"""
import os
@ -15,45 +16,45 @@ from ..config import Config
class ProjectStatus(str, Enum):
"""项目状态"""
CREATED = "created" # 刚创建,文件已上传
ONTOLOGY_GENERATED = "ontology_generated" # 本体已生成
GRAPH_BUILDING = "graph_building" # 图谱构建中
GRAPH_COMPLETED = "graph_completed" # 图谱构建完成
FAILED = "failed" # 失败
"""Project lifecycle status."""
CREATED = "created" # just created, files uploaded
ONTOLOGY_GENERATED = "ontology_generated" # ontology has been generated
GRAPH_BUILDING = "graph_building" # graph is being built
GRAPH_COMPLETED = "graph_completed" # graph build complete
FAILED = "failed" # failed
@dataclass
class Project:
"""项目数据模型"""
"""Project data model."""
project_id: str
name: str
status: ProjectStatus
created_at: str
updated_at: str
# 文件信息
# File metadata
files: List[Dict[str, str]] = field(default_factory=list) # [{filename, path, size}]
total_text_length: int = 0
# 本体信息接口1生成后填充
# Ontology info (populated after endpoint 1)
ontology: Optional[Dict[str, Any]] = None
analysis_summary: Optional[str] = None
# 图谱信息接口2完成后填充
# Graph info (populated after endpoint 2 completes)
graph_id: Optional[str] = None
graph_build_task_id: Optional[str] = None
# 配置
# Configuration
simulation_requirement: Optional[str] = None
chunk_size: int = 500
chunk_overlap: int = 50
# 错误信息
# Error information
error: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
"""转换为字典"""
"""Convert to a plain dictionary."""
return {
"project_id": self.project_id,
"name": self.name,
@ -71,14 +72,14 @@ class Project:
"chunk_overlap": self.chunk_overlap,
"error": self.error
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Project':
"""从字典创建"""
"""Create a Project from a plain dictionary."""
status = data.get('status', 'created')
if isinstance(status, str):
status = ProjectStatus(status)
return cls(
project_id=data['project_id'],
name=data.get('name', 'Unnamed Project'),
@ -99,52 +100,52 @@ class Project:
class ProjectManager:
"""项目管理器 - 负责项目的持久化存储和检索"""
# 项目存储根目录
"""Project manager - responsible for persistent storage and retrieval of projects."""
# Project storage root directory
PROJECTS_DIR = os.path.join(Config.UPLOAD_FOLDER, 'projects')
@classmethod
def _ensure_projects_dir(cls):
"""确保项目目录存在"""
"""Make sure the projects directory exists."""
os.makedirs(cls.PROJECTS_DIR, exist_ok=True)
@classmethod
def _get_project_dir(cls, project_id: str) -> str:
"""获取项目目录路径"""
"""Get the directory path for a project."""
return os.path.join(cls.PROJECTS_DIR, project_id)
@classmethod
def _get_project_meta_path(cls, project_id: str) -> str:
"""获取项目元数据文件路径"""
"""Get the path to a project's metadata file."""
return os.path.join(cls._get_project_dir(project_id), 'project.json')
@classmethod
def _get_project_files_dir(cls, project_id: str) -> str:
"""获取项目文件存储目录"""
"""Get the directory where a project's files are stored."""
return os.path.join(cls._get_project_dir(project_id), 'files')
@classmethod
def _get_project_text_path(cls, project_id: str) -> str:
"""获取项目提取文本存储路径"""
"""Get the path to a project's extracted text."""
return os.path.join(cls._get_project_dir(project_id), 'extracted_text.txt')
@classmethod
def create_project(cls, name: str = "Unnamed Project") -> Project:
"""
创建新项目
Create a new project.
Args:
name: 项目名称
name: project name.
Returns:
新创建的Project对象
The newly created Project object.
"""
cls._ensure_projects_dir()
project_id = f"proj_{uuid.uuid4().hex[:12]}"
now = datetime.now().isoformat()
project = Project(
project_id=project_id,
name=name,
@ -152,154 +153,153 @@ class ProjectManager:
created_at=now,
updated_at=now
)
# 创建项目目录结构
# Create the project directory structure
project_dir = cls._get_project_dir(project_id)
files_dir = cls._get_project_files_dir(project_id)
os.makedirs(project_dir, exist_ok=True)
os.makedirs(files_dir, exist_ok=True)
# 保存项目元数据
# Persist the project metadata
cls.save_project(project)
return project
@classmethod
def save_project(cls, project: Project) -> None:
"""保存项目元数据"""
"""Persist project metadata to disk."""
project.updated_at = datetime.now().isoformat()
meta_path = cls._get_project_meta_path(project.project_id)
with open(meta_path, 'w', encoding='utf-8') as f:
json.dump(project.to_dict(), f, ensure_ascii=False, indent=2)
@classmethod
def get_project(cls, project_id: str) -> Optional[Project]:
"""
获取项目
Retrieve a project by ID.
Args:
project_id: 项目ID
project_id: project ID.
Returns:
Project对象如果不存在返回None
The Project object, or None if it does not exist.
"""
meta_path = cls._get_project_meta_path(project_id)
if not os.path.exists(meta_path):
return None
with open(meta_path, 'r', encoding='utf-8') as f:
data = json.load(f)
return Project.from_dict(data)
@classmethod
def list_projects(cls, limit: int = 50) -> List[Project]:
"""
列出所有项目
List all projects.
Args:
limit: 返回数量限制
limit: maximum number of results to return.
Returns:
项目列表按创建时间倒序
A list of projects, sorted newest-first by creation time.
"""
cls._ensure_projects_dir()
projects = []
for project_id in os.listdir(cls.PROJECTS_DIR):
project = cls.get_project(project_id)
if project:
projects.append(project)
# 按创建时间倒序排序
# Sort newest-first by creation time
projects.sort(key=lambda p: p.created_at, reverse=True)
return projects[:limit]
@classmethod
def delete_project(cls, project_id: str) -> bool:
"""
删除项目及其所有文件
Delete a project and all of its files.
Args:
project_id: 项目ID
project_id: project ID.
Returns:
是否删除成功
True if the project was deleted, False otherwise.
"""
project_dir = cls._get_project_dir(project_id)
if not os.path.exists(project_dir):
return False
shutil.rmtree(project_dir)
return True
@classmethod
def save_file_to_project(cls, project_id: str, file_storage, original_filename: str) -> Dict[str, str]:
"""
保存上传的文件到项目目录
Save an uploaded file into the project's directory.
Args:
project_id: 项目ID
file_storage: Flask的FileStorage对象
original_filename: 原始文件名
project_id: project ID.
file_storage: Flask FileStorage object.
original_filename: original filename.
Returns:
文件信息字典 {filename, path, size}
Dictionary with file metadata: {filename, path, size}.
"""
files_dir = cls._get_project_files_dir(project_id)
os.makedirs(files_dir, exist_ok=True)
# 生成安全的文件名
# Build a safe filename
ext = os.path.splitext(original_filename)[1].lower()
safe_filename = f"{uuid.uuid4().hex[:8]}{ext}"
file_path = os.path.join(files_dir, safe_filename)
# 保存文件
# Save the file
file_storage.save(file_path)
# 获取文件大小
# Read back its size
file_size = os.path.getsize(file_path)
return {
"original_filename": original_filename,
"saved_filename": safe_filename,
"path": file_path,
"size": file_size
}
@classmethod
def save_extracted_text(cls, project_id: str, text: str) -> None:
"""保存提取的文本"""
"""Persist extracted text to disk."""
text_path = cls._get_project_text_path(project_id)
with open(text_path, 'w', encoding='utf-8') as f:
f.write(text)
@classmethod
def get_extracted_text(cls, project_id: str) -> Optional[str]:
"""获取提取的文本"""
"""Read extracted text from disk."""
text_path = cls._get_project_text_path(project_id)
if not os.path.exists(text_path):
return None
with open(text_path, 'r', encoding='utf-8') as f:
return f.read()
@classmethod
def get_project_files(cls, project_id: str) -> List[str]:
"""获取项目的所有文件路径"""
"""Return the absolute paths of all files in the project."""
files_dir = cls._get_project_files_dir(project_id)
if not os.path.exists(files_dir):
return []
return [
os.path.join(files_dir, f)
for f in os.listdir(files_dir)
os.path.join(files_dir, f)
for f in os.listdir(files_dir)
if os.path.isfile(os.path.join(files_dir, f))
]

View File

@ -1,6 +1,6 @@
"""
任务状态管理
用于跟踪长时间运行的任务如图谱构建
Task status management.
Tracks long-running tasks (such as graph building) and their progress.
"""
import uuid
@ -14,30 +14,30 @@ from ..utils.locale import t
class TaskStatus(str, Enum):
"""任务状态枚举"""
PENDING = "pending" # 等待中
PROCESSING = "processing" # 处理中
COMPLETED = "completed" # 已完成
FAILED = "failed" # 失败
"""Task status enum."""
PENDING = "pending" # waiting
PROCESSING = "processing" # in progress
COMPLETED = "completed" # finished
FAILED = "failed" # failed
@dataclass
class Task:
"""任务数据类"""
"""Task data class."""
task_id: str
task_type: str
status: TaskStatus
created_at: datetime
updated_at: datetime
progress: int = 0 # 总进度百分比 0-100
message: str = "" # 状态消息
result: Optional[Dict] = None # 任务结果
error: Optional[str] = None # 错误信息
metadata: Dict = field(default_factory=dict) # 额外元数据
progress_detail: Dict = field(default_factory=dict) # 详细进度信息
progress: int = 0 # overall progress percentage 0-100
message: str = "" # status message
result: Optional[Dict] = None # task result payload
error: Optional[str] = None # error message
metadata: Dict = field(default_factory=dict) # extra metadata
progress_detail: Dict = field(default_factory=dict) # detailed progress
def to_dict(self) -> Dict[str, Any]:
"""转换为字典"""
"""Convert to a plain dictionary."""
return {
"task_id": self.task_id,
"task_type": self.task_type,
@ -55,15 +55,15 @@ class Task:
class TaskManager:
"""
任务管理器
线程安全的任务状态管理
Task manager.
Thread-safe tracking of task state and progress.
"""
_instance = None
_lock = threading.Lock()
def __new__(cls):
"""单例模式"""
"""Singleton accessor."""
if cls._instance is None:
with cls._lock:
if cls._instance is None:
@ -71,21 +71,21 @@ class TaskManager:
cls._instance._tasks: Dict[str, Task] = {}
cls._instance._task_lock = threading.Lock()
return cls._instance
def create_task(self, task_type: str, metadata: Optional[Dict] = None) -> str:
"""
创建新任务
Create a new task.
Args:
task_type: 任务类型
metadata: 额外元数据
task_type: task type.
metadata: extra metadata.
Returns:
任务ID
The new task ID.
"""
task_id = str(uuid.uuid4())
now = datetime.now()
task = Task(
task_id=task_id,
task_type=task_type,
@ -94,17 +94,17 @@ class TaskManager:
updated_at=now,
metadata=metadata or {}
)
with self._task_lock:
self._tasks[task_id] = task
return task_id
def get_task(self, task_id: str) -> Optional[Task]:
"""获取任务"""
"""Fetch a task by ID."""
with self._task_lock:
return self._tasks.get(task_id)
def update_task(
self,
task_id: str,
@ -116,16 +116,16 @@ class TaskManager:
progress_detail: Optional[Dict] = None
):
"""
更新任务状态
Update task state.
Args:
task_id: 任务ID
status: 新状态
progress: 进度
message: 消息
result: 结果
error: 错误信息
progress_detail: 详细进度信息
task_id: task ID.
status: new status.
progress: progress percentage.
message: status message.
result: result payload.
error: error message.
progress_detail: detailed progress information.
"""
with self._task_lock:
task = self._tasks.get(task_id)
@ -143,9 +143,9 @@ class TaskManager:
task.error = error
if progress_detail is not None:
task.progress_detail = progress_detail
def complete_task(self, task_id: str, result: Dict):
"""标记任务完成"""
"""Mark a task as completed."""
self.update_task(
task_id,
status=TaskStatus.COMPLETED,
@ -153,29 +153,29 @@ class TaskManager:
message=t('progress.taskComplete'),
result=result
)
def fail_task(self, task_id: str, error: str):
"""标记任务失败"""
"""Mark a task as failed."""
self.update_task(
task_id,
status=TaskStatus.FAILED,
message=t('progress.taskFailed'),
error=error
)
def list_tasks(self, task_type: Optional[str] = None) -> list:
"""列出任务"""
"""List tasks, optionally filtered by type."""
with self._task_lock:
tasks = list(self._tasks.values())
if task_type:
tasks = [t for t in tasks if t.task_type == task_type]
return [t.to_dict() for t in sorted(tasks, key=lambda x: x.created_at, reverse=True)]
def cleanup_old_tasks(self, max_age_hours: int = 24):
"""清理旧任务"""
"""Remove tasks older than the given threshold."""
from datetime import timedelta
cutoff = datetime.now() - timedelta(hours=max_age_hours)
with self._task_lock:
old_ids = [
tid for tid, task in self._tasks.items()
@ -183,4 +183,3 @@ class TaskManager:
]
for tid in old_ids:
del self._tasks[tid]

View File

@ -1,5 +1,5 @@
"""
业务服务模块
Business services module.
"""
from .ontology_generator import OntologyGenerator
@ -9,7 +9,7 @@ from .zep_entity_reader import ZepEntityReader, EntityNode, FilteredEntities
from .oasis_profile_generator import OasisProfileGenerator, OasisAgentProfile
from .simulation_manager import SimulationManager, SimulationState, SimulationStatus
from .simulation_config_generator import (
SimulationConfigGenerator,
SimulationConfigGenerator,
SimulationParameters,
AgentActivityConfig,
TimeSimulationConfig,
@ -38,8 +38,8 @@ from .simulation_ipc import (
)
__all__ = [
'OntologyGenerator',
'GraphBuilderService',
'OntologyGenerator',
'GraphBuilderService',
'TextProcessor',
'ZepEntityReader',
'EntityNode',
@ -70,4 +70,3 @@ __all__ = [
'CommandType',
'CommandStatus',
]

View File

@ -1,6 +1,6 @@
"""
图谱构建服务
接口2使用Zep API构建Standalone Graph
Graph build service
Interface 2: build a Standalone Graph via the Zep API.
"""
import os
@ -40,12 +40,12 @@ from ..utils.locale import t, get_locale, set_locale
@dataclass
class GraphInfo:
"""图谱信息"""
"""Graph info"""
graph_id: str
node_count: int
edge_count: int
entity_types: List[str]
def to_dict(self) -> Dict[str, Any]:
return {
"graph_id": self.graph_id,
@ -57,17 +57,17 @@ class GraphInfo:
class GraphBuilderService:
"""
图谱构建服务
负责调用Zep API构建知识图谱
Graph build service
Responsible for calling the Zep API to build the knowledge graph.
"""
def __init__(self, api_key: Optional[str] = None):
# Zep's per-project graph becomes Graphiti's group_id (a partition).
# The single shared GraphitiAdapter handles all projects.
self.api_key = api_key # kept for signature compat; no longer required
self.client = get_graphiti_adapter()
self.task_manager = TaskManager()
def build_graph_async(
self,
text: str,
@ -78,20 +78,20 @@ class GraphBuilderService:
batch_size: int = 3
) -> str:
"""
异步构建图谱
Build the graph asynchronously.
Args:
text: 输入文本
ontology: 本体定义来自接口1的输出
graph_name: 图谱名称
chunk_size: 文本块大小
chunk_overlap: 块重叠大小
batch_size: 每批发送的块数量
text: Input text
ontology: Ontology definition (output of interface 1)
graph_name: Graph name
chunk_size: Text chunk size
chunk_overlap: Chunk overlap size
batch_size: Number of chunks sent per batch
Returns:
任务ID
Task ID
"""
# 创建任务
# Create task
task_id = self.task_manager.create_task(
task_type="graph_build",
metadata={
@ -100,20 +100,20 @@ class GraphBuilderService:
"text_length": len(text),
}
)
# Capture locale before spawning background thread
current_locale = get_locale()
# 在后台线程中执行构建
# Execute the build in a background thread
thread = threading.Thread(
target=self._build_graph_worker,
args=(task_id, text, ontology, graph_name, chunk_size, chunk_overlap, batch_size, current_locale)
)
thread.daemon = True
thread.start()
return task_id
def _build_graph_worker(
self,
task_id: str,
@ -125,7 +125,7 @@ class GraphBuilderService:
batch_size: int,
locale: str = 'zh'
):
"""图谱构建工作线程"""
"""Graph build worker thread"""
set_locale(locale)
try:
self.task_manager.update_task(
@ -134,24 +134,24 @@ class GraphBuilderService:
progress=5,
message=t('progress.startBuildingGraph')
)
# 1. 创建图谱
# 1. Create the graph
graph_id = self.create_graph(graph_name)
self.task_manager.update_task(
task_id,
progress=10,
message=t('progress.graphCreated', graphId=graph_id)
)
# 2. 设置本体
# 2. Set the ontology
self.set_ontology(graph_id, ontology)
self.task_manager.update_task(
task_id,
progress=15,
message=t('progress.ontologySet')
)
# 3. 文本分块
# 3. Split the text into chunks
chunks = TextProcessor.split_text(text, chunk_size, chunk_overlap)
total_chunks = len(chunks)
self.task_manager.update_task(
@ -159,8 +159,8 @@ class GraphBuilderService:
progress=20,
message=t('progress.textSplit', count=total_chunks)
)
# 4. 分批发送数据
# 4. Send data in batches
episode_uuids = self.add_text_batches(
graph_id, chunks, batch_size,
lambda msg, prog: self.task_manager.update_task(
@ -169,14 +169,14 @@ class GraphBuilderService:
message=msg
)
)
# 5. 等待Zep处理完成
# 5. Wait for Zep processing to finish
self.task_manager.update_task(
task_id,
progress=60,
message=t('progress.waitingZepProcess')
)
self._wait_for_episodes(
episode_uuids,
lambda msg, prog: self.task_manager.update_task(
@ -185,30 +185,30 @@ class GraphBuilderService:
message=msg
)
)
# 6. 获取图谱信息
# 6. Fetch graph info
self.task_manager.update_task(
task_id,
progress=90,
message=t('progress.fetchingGraphInfo')
)
graph_info = self._get_graph_info(graph_id)
# 完成
# Complete
self.task_manager.complete_task(task_id, {
"graph_id": graph_id,
"graph_info": graph_info.to_dict(),
"chunks_processed": total_chunks,
})
except Exception as e:
import traceback
error_msg = f"{str(e)}\n{traceback.format_exc()}"
self.task_manager.fail_task(task_id, error_msg)
def create_graph(self, name: str) -> str:
"""创建图谱(公开方法)"""
"""Create a graph (public method)"""
graph_id = f"mirofish_{uuid.uuid4().hex[:16]}"
# Graphiti creates the partition implicitly on first add_episode; this
# call just reserves the group_id.
@ -217,11 +217,11 @@ class GraphBuilderService:
name=name,
description="MiroFish Social Simulation Graph",
)
return graph_id
def set_ontology(self, graph_id: str, ontology: Dict[str, Any]):
"""设置图谱本体(公开方法)"""
"""Set the graph ontology (public method)"""
import warnings
from typing import Optional
from pydantic import Field
@ -242,69 +242,70 @@ class GraphBuilderService:
from pydantic import BaseModel as EntityModel # type: ignore[assignment,misc]
from pydantic import BaseModel as EdgeModel # type: ignore[assignment,misc]
EntityText = str # type: ignore[assignment,misc]
# 抑制 Pydantic v2 关于 Field(default=None) 的警告
# 这是 Zep SDK 要求的用法,警告来自动态类创建,可以安全忽略
# Suppress Pydantic v2 warning about Field(default=None)
# This is required by the Zep SDK; the warning comes from dynamic
# class creation and can safely be ignored.
warnings.filterwarnings('ignore', category=UserWarning, module='pydantic')
# Zep 保留名称,不能作为属性名
# Zep reserved names; cannot be used as attribute names
RESERVED_NAMES = {'uuid', 'name', 'group_id', 'name_embedding', 'summary', 'created_at'}
def safe_attr_name(attr_name: str) -> str:
"""将保留名称转换为安全名称"""
"""Convert a reserved name into a safe name"""
if attr_name.lower() in RESERVED_NAMES:
return f"entity_{attr_name}"
return attr_name
# 动态创建实体类型
# Dynamically create entity types
entity_types = {}
for entity_def in ontology.get("entity_types", []):
name = entity_def["name"]
description = entity_def.get("description", f"A {name} entity.")
# 创建属性字典和类型注解Pydantic v2 需要)
# Build the attribute dict and type annotations (required by Pydantic v2)
attrs = {"__doc__": description}
annotations = {}
for attr_def in entity_def.get("attributes", []):
attr_name = safe_attr_name(attr_def["name"]) # 使用安全名称
attr_name = safe_attr_name(attr_def["name"]) # Use the safe name
attr_desc = attr_def.get("description", attr_name)
# Zep API 需要 Field 的 description这是必需的
# Zep API requires a description on Field; this is required
attrs[attr_name] = Field(description=attr_desc, default=None)
annotations[attr_name] = Optional[EntityText] # 类型注解
annotations[attr_name] = Optional[EntityText] # type annotation
attrs["__annotations__"] = annotations
# 动态创建类
# Dynamically create the class
entity_class = type(name, (EntityModel,), attrs)
entity_class.__doc__ = description
entity_types[name] = entity_class
# 动态创建边类型
# Dynamically create edge types
edge_definitions = {}
for edge_def in ontology.get("edge_types", []):
name = edge_def["name"]
description = edge_def.get("description", f"A {name} relationship.")
# 创建属性字典和类型注解
# Build the attribute dict and type annotations
attrs = {"__doc__": description}
annotations = {}
for attr_def in edge_def.get("attributes", []):
attr_name = safe_attr_name(attr_def["name"]) # 使用安全名称
attr_name = safe_attr_name(attr_def["name"]) # Use the safe name
attr_desc = attr_def.get("description", attr_name)
# Zep API 需要 Field 的 description这是必需的
# Zep API requires a description on Field; this is required
attrs[attr_name] = Field(description=attr_desc, default=None)
annotations[attr_name] = Optional[str] # 边属性用str类型
annotations[attr_name] = Optional[str] # Edge attributes use str type
attrs["__annotations__"] = annotations
# 动态创建类
# Dynamically create the class
class_name = ''.join(word.capitalize() for word in name.split('_'))
edge_class = type(class_name, (EdgeModel,), attrs)
edge_class.__doc__ = description
# 构建source_targets
# Build source_targets
source_targets = []
for st in edge_def.get("source_targets", []):
source_targets.append(
@ -313,10 +314,10 @@ class GraphBuilderService:
target=st.get("target", "Entity")
)
)
if source_targets:
edge_definitions[name] = (edge_class, source_targets)
# Graphiti accepts the original JSON ontology directly. The Pydantic
# class construction above was a Zep-specific quirk; we can skip it
# for Graphiti and just forward the input. Keep the code path the same
@ -325,7 +326,7 @@ class GraphBuilderService:
# uses the adapter with the source-of-truth JSON.
if entity_types or edge_definitions:
self.client.set_ontology(graph_id=graph_id, ontology=ontology)
def add_text_batches(
self,
graph_id: str,
@ -333,56 +334,56 @@ class GraphBuilderService:
batch_size: int = 3,
progress_callback: Optional[Callable] = None
) -> List[str]:
"""分批添加文本到图谱,返回所有 episode 的 uuid 列表"""
"""Add text to the graph in batches, returning a list of episode uuids"""
episode_uuids = []
total_chunks = len(chunks)
for i in range(0, total_chunks, batch_size):
batch_chunks = chunks[i:i + batch_size]
batch_num = i // batch_size + 1
total_batches = (total_chunks + batch_size - 1) // batch_size
if progress_callback:
progress = (i + len(batch_chunks)) / total_chunks
progress_callback(
t('progress.sendingBatch', current=batch_num, total=total_batches, chunks=len(batch_chunks)),
progress
)
# 构建episode数据
# Build episode data
episodes = [
EpisodeData(data=chunk, type="text")
for chunk in batch_chunks
]
# 发送到图谱Graphiti via adapter — returns after sync extraction
# Send to the graph (Graphiti via adapter — returns after sync extraction)
try:
batch_result = self.client.add_batch(
graph_id=graph_id,
episodes=episodes,
)
# 收集返回的 episode uuid
# Collect returned episode uuids
if batch_result and isinstance(batch_result, list):
for ep in batch_result:
ep_uuid = getattr(ep, 'uuid_', None) or getattr(ep, 'uuid', None)
if ep_uuid:
episode_uuids.append(ep_uuid)
except Exception as e:
if progress_callback:
progress_callback(t('progress.batchFailed', batch=batch_num, error=str(e)), 0)
raise
return episode_uuids
def _wait_for_episodes(
self,
episode_uuids: List[str],
progress_callback: Optional[Callable] = None,
timeout: int = 600
):
"""等待所有 episode 处理完成。
"""Wait for all episodes to finish processing.
Graphiti's add_episode is synchronous (extraction completes before the
call returns), so all episodes returned by add_batch are already done.
@ -397,16 +398,16 @@ class GraphBuilderService:
if progress_callback:
progress_callback(t('progress.waitingEpisodes', count=len(episode_uuids)), 0)
progress_callback(t('progress.processingComplete', completed=len(episode_uuids), total=len(episode_uuids)), 1.0)
def _get_graph_info(self, graph_id: str) -> GraphInfo:
"""获取图谱信息"""
# 获取节点(分页)
"""Get graph info"""
# Get nodes (paginated)
nodes = fetch_all_nodes(self.client, graph_id)
# 获取边(分页)
# Get edges (paginated)
edges = fetch_all_edges(self.client, graph_id)
# 统计实体类型
# Aggregate entity types
entity_types = set()
for node in nodes:
if node.labels:
@ -420,32 +421,33 @@ class GraphBuilderService:
edge_count=len(edges),
entity_types=list(entity_types)
)
def get_graph_data(self, graph_id: str) -> Dict[str, Any]:
"""
获取完整图谱数据包含详细信息
Get the complete graph data (including detailed info).
Args:
graph_id: 图谱ID
graph_id: Graph ID
Returns:
包含nodes和edges的字典包括时间信息属性等详细数据
A dict containing nodes and edges, including timing info,
attributes, and other detailed data.
"""
nodes = fetch_all_nodes(self.client, graph_id)
edges = fetch_all_edges(self.client, graph_id)
# 创建节点映射用于获取节点名称
# Build a node-name map
node_map = {}
for node in nodes:
node_map[node.uuid_] = node.name or ""
nodes_data = []
for node in nodes:
# 获取创建时间
# Get creation time
created_at = getattr(node, 'created_at', None)
if created_at:
created_at = str(created_at)
nodes_data.append({
"uuid": node.uuid_,
"name": node.name,
@ -454,25 +456,25 @@ class GraphBuilderService:
"attributes": node.attributes or {},
"created_at": created_at,
})
edges_data = []
for edge in edges:
# 获取时间信息
# Get time info
created_at = getattr(edge, 'created_at', None)
valid_at = getattr(edge, 'valid_at', None)
invalid_at = getattr(edge, 'invalid_at', None)
expired_at = getattr(edge, 'expired_at', None)
# 获取 episodes
# Get episodes
episodes = getattr(edge, 'episodes', None) or getattr(edge, 'episode_ids', None)
if episodes and not isinstance(episodes, list):
episodes = [str(episodes)]
elif episodes:
episodes = [str(e) for e in episodes]
# 获取 fact_type
# Get fact_type
fact_type = getattr(edge, 'fact_type', None) or edge.name or ""
edges_data.append({
"uuid": edge.uuid_,
"name": edge.name or "",
@ -489,7 +491,7 @@ class GraphBuilderService:
"expired_at": str(expired_at) if expired_at else None,
"episodes": episodes or [],
})
return {
"graph_id": graph_id,
"nodes": nodes_data,
@ -499,6 +501,6 @@ class GraphBuilderService:
}
def delete_graph(self, graph_id: str):
"""删除图谱"""
"""Delete a graph"""
self.client.delete_graph(graph_id=graph_id)

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
"""
本体生成服务
接口1分析文本内容生成适合社会模拟的实体和关系类型定义
Ontology generation service
Interface 1: analyze text content to generate entity and relationship type definitions suitable for social simulation
"""
import json
@ -14,169 +14,169 @@ logger = logging.getLogger(__name__)
def _to_pascal_case(name: str) -> str:
"""将任意格式的名称转换为 PascalCase 'works_for' -> 'WorksFor', 'person' -> 'Person'"""
# 按非字母数字字符分割
"""Convert any-format name to PascalCase (e.g. 'works_for' -> 'WorksFor', 'person' -> 'Person')"""
# Split by non-alphanumeric characters
parts = re.split(r'[^a-zA-Z0-9]+', name)
# 再按 camelCase 边界分割(如 'camelCase' -> ['camel', 'Case']
# Then split by camelCase boundaries (e.g. 'camelCase' -> ['camel', 'Case'])
words = []
for part in parts:
words.extend(re.sub(r'([a-z])([A-Z])', r'\1_\2', part).split('_'))
# 每个词首字母大写,过滤空串
# Capitalize each word, filter out empty strings
result = ''.join(word.capitalize() for word in words if word)
return result if result else 'Unknown'
# 本体生成的系统提示词
ONTOLOGY_SYSTEM_PROMPT = """你是一个专业的知识图谱本体设计专家。你的任务是分析给定的文本内容和模拟需求,设计适合**社交媒体舆论模拟**的实体类型和关系类型。
# System prompt for ontology generation
ONTOLOGY_SYSTEM_PROMPT = """You are a professional knowledge-graph ontology design expert. Your task is to analyze the given text content and simulation requirement, design entity types and relationship types suitable for **social media public-opinion simulation**.
**重要你必须输出有效的JSON格式数据不要输出任何其他内容**
**IMPORTANT: You MUST output valid JSON format data, and nothing else.**
## 核心任务背景
## Core Task Background
我们正在构建一个**社交媒体舆论模拟系统**在这个系统中
- 每个实体都是一个可以在社交媒体上发声互动传播信息的"账号""主体"
- 实体之间会相互影响转发评论回应
- 我们需要模拟舆论事件中各方的反应和信息传播路径
We are building a **social media public-opinion simulation system**. In this system:
- Each entity is an "account" or "subject" that can speak out, interact, and spread information on social media
- Entities influence each other, repost, comment, and respond
- We need to simulate the reactions of all parties and the information propagation paths in public-opinion events
因此**实体必须是现实中真实存在的可以在社媒上发声和互动的主体**
Therefore, **Entities must be real-world subjects that actually exist and can speak out and interact on social media**:
**可以是**
- 具体的个人公众人物当事人意见领袖专家学者普通人
- 公司企业包括其官方账号
- 组织机构大学协会NGO工会等
- 政府部门监管机构
- 媒体机构报纸电视台自媒体网站
- 社交媒体平台本身
- 特定群体代表如校友会粉丝团维权群体等
**Can be**:
- Specific individuals (public figures, parties involved, opinion leaders, experts and scholars, ordinary people)
- Companies, enterprises (including their official accounts)
- Organizations (universities, associations, NGOs, unions, etc.)
- Government departments, regulatory agencies
- Media organizations (newspapers, TV stations, self-media, websites)
- Social media platforms themselves
- Representatives of specific groups (e.g., alumni associations, fan groups, rights-advocacy groups, etc.)
**不可以是**
- 抽象概念"舆论""情绪""趋势"
- 主题/话题"学术诚信""教育改革"
- 观点/态度"支持方""反对方"
**Cannot be**:
- Abstract concepts (e.g., "public opinion", "emotion", "trend")
- Themes/topics (e.g., "academic integrity", "education reform")
- Views/attitudes (e.g., "supporters", "opponents")
## 输出格式
## Output Format
请输出JSON格式包含以下结构
Please output JSON format containing the following structure:
```json
{
"entity_types": [
{
"name": "实体类型名称英文PascalCase",
"description": "简短描述英文不超过100字符",
"name": "Entity type name (English, PascalCase)",
"description": "Short description (English, no more than 100 characters)",
"attributes": [
{
"name": "属性名英文snake_case",
"name": "Attribute name (English, snake_case)",
"type": "text",
"description": "属性描述"
"description": "Attribute description"
}
],
"examples": ["示例实体1", "示例实体2"]
"examples": ["Example entity 1", "Example entity 2"]
}
],
"edge_types": [
{
"name": "关系类型名称英文UPPER_SNAKE_CASE",
"description": "简短描述英文不超过100字符",
"name": "Relationship type name (English, UPPER_SNAKE_CASE)",
"description": "Short description (English, no more than 100 characters)",
"source_targets": [
{"source": "源实体类型", "target": "目标实体类型"}
{"source": "Source entity type", "target": "Target entity type"}
],
"attributes": []
}
],
"analysis_summary": "对文本内容的简要分析说明"
"analysis_summary": "Brief analysis and description of the text content"
}
```
## 设计指南(极其重要!)
## Design Guidelines (EXTREMELY IMPORTANT!)
### 1. 实体类型设计 - 必须严格遵守
### 1. Entity Type Design - MUST be strictly followed
**数量要求必须正好10个实体类型**
**Quantity requirement: must be exactly 10 entity types**
**层次结构要求必须同时包含具体类型和兜底类型**
**Hierarchy requirement (must include both specific types and fallback types)**:
你的10个实体类型必须包含以下层次
Your 10 entity types must include the following levels:
A. **兜底类型必须包含放在列表最后2个**
- `Person`: 任何自然人个体的兜底类型当一个人不属于其他更具体的人物类型时归入此类
- `Organization`: 任何组织机构的兜底类型当一个组织不属于其他更具体的组织类型时归入此类
A. **Fallback types (must be included, placed last 2 in the list)**:
- `Person`: Fallback type for any individual person. When a person does not fit any other more specific person type, they are classified here.
- `Organization`: Fallback type for any organization. When an organization does not fit any other more specific organization type, it is classified here.
B. **具体类型8根据文本内容设计**
- 针对文本中出现的主要角色设计更具体的类型
- 例如如果文本涉及学术事件可以有 `Student`, `Professor`, `University`
- 例如如果文本涉及商业事件可以有 `Company`, `CEO`, `Employee`
B. **Specific types (8, designed from the text content)**:
- Design more specific types for the main characters appearing in the text
- For example: if the text involves an academic event, you may have `Student`, `Professor`, `University`
- For example: if the text involves a business event, you may have `Company`, `CEO`, `Employee`
**为什么需要兜底类型**
- 文本中会出现各种人物"中小学教师""路人甲""某位网友"
- 如果没有专门的类型匹配他们应该被归入 `Person`
- 同理小型组织临时团体等应该归入 `Organization`
**Why fallback types are needed**:
- The text will contain various characters, such as "primary/secondary school teachers", "passerby A", "a certain netizen"
- If no specific type matches them, they should be classified as `Person`
- Similarly, small organizations, temporary groups, etc. should be classified as `Organization`
**具体类型的设计原则**
- 从文本中识别出高频出现或关键的角色类型
- 每个具体类型应该有明确的边界避免重叠
- description 必须清晰说明这个类型和兜底类型的区别
**Design principles for specific types**:
- Identify frequently occurring or key character types from the text
- Each specific type should have clear boundaries to avoid overlap
- The description must clearly explain the difference between this type and the fallback types
### 2. 关系类型设计
### 2. Relationship Type Design
- 数量6-10
- 关系应该反映社媒互动中的真实联系
- 确保关系的 source_targets 涵盖你定义的实体类型
- Quantity: 6-10
- Relationships should reflect real connections in social-media interactions
- Ensure the source_targets of relationships cover the entity types you defined
### 3. 属性设计
### 3. Attribute Design
- 每个实体类型1-3个关键属性
- **注意**属性名不能使用 `name``uuid``group_id``created_at``summary`这些是系统保留字
- 推荐使用`full_name`, `title`, `role`, `position`, `location`, `description`
- 1-3 key attributes per entity type
- **Note**: Attribute names must NOT use `name`, `uuid`, `group_id`, `created_at`, `summary` (these are system reserved words)
- Recommended: `full_name`, `title`, `role`, `position`, `location`, `description`, etc.
## 实体类型参考
## Entity Type Reference
**个人类具体**
- Student: 学生
- Professor: 教授/学者
- Journalist: 记者
- Celebrity: 明星/网红
- Executive: 高管
- Official: 政府官员
- Lawyer: 律师
- Doctor: 医生
**Person types (specific)**:
- Student: Student
- Professor: Professor/Scholar
- Journalist: Journalist
- Celebrity: Celebrity/Internet celebrity
- Executive: Executive
- Official: Government official
- Lawyer: Lawyer
- Doctor: Doctor
**个人类兜底**
- Person: 任何自然人不属于上述具体类型时使用
**Person types (fallback)**:
- Person: Any individual (used when not fitting the specific types above)
**组织类具体**
- University: 高校
- Company: 公司企业
- GovernmentAgency: 政府机构
- MediaOutlet: 媒体机构
- Hospital: 医院
- School: 中小学
- NGO: 非政府组织
**Organization types (specific)**:
- University: University/College
- Company: Company/Enterprise
- GovernmentAgency: Government agency
- MediaOutlet: Media organization
- Hospital: Hospital
- School: Primary/Secondary school
- NGO: Non-governmental organization
**组织类兜底**
- Organization: 任何组织机构不属于上述具体类型时使用
**Organization types (fallback)**:
- Organization: Any organization (used when not fitting the specific types above)
## 关系类型参考
## Relationship Type Reference
- WORKS_FOR: 工作于
- STUDIES_AT: 就读于
- AFFILIATED_WITH: 隶属于
- REPRESENTS: 代表
- REGULATES: 监管
- REPORTS_ON: 报道
- COMMENTS_ON: 评论
- RESPONDS_TO: 回应
- SUPPORTS: 支持
- OPPOSES: 反对
- COLLABORATES_WITH: 合作
- COMPETES_WITH: 竞争
- WORKS_FOR: Works for
- STUDIES_AT: Studies at
- AFFILIATED_WITH: Affiliated with
- REPRESENTS: Represents
- REGULATES: Regulates
- REPORTS_ON: Reports on
- COMMENTS_ON: Comments on
- RESPONDS_TO: Responds to
- SUPPORTS: Supports
- OPPOSES: Opposes
- COLLABORATES_WITH: Collaborates with
- COMPETES_WITH: Competes with
"""
class OntologyGenerator:
"""
本体生成器
分析文本内容生成实体和关系类型定义
Ontology generator
Analyze text content and generate entity and relationship type definitions
"""
def __init__(self, llm_client: Optional[LLMClient] = None):
@ -189,17 +189,17 @@ class OntologyGenerator:
additional_context: Optional[str] = None
) -> Dict[str, Any]:
"""
生成本体定义
Generate the ontology definition
Args:
document_texts: 文档文本列表
simulation_requirement: 模拟需求描述
additional_context: 额外上下文
document_texts: List of document texts
simulation_requirement: Simulation requirement description
additional_context: Additional context
Returns:
本体定义entity_types, edge_types等
Ontology definition (entity_types, edge_types, etc.)
"""
# 构建用户消息
# Build user message
user_message = self._build_user_message(
document_texts,
simulation_requirement,
@ -213,7 +213,7 @@ class OntologyGenerator:
{"role": "user", "content": user_message}
]
# 调用LLM
# Call the LLM
try:
result = self.llm_client.chat_json(
messages=messages,
@ -224,7 +224,7 @@ class OntologyGenerator:
logger.warning(
f"[ontology] first attempt failed (likely M3 JSON truncation): {json_err}; retrying with compact prompt"
)
# 紧凑重试:限制 entity/edge types 数量,砍掉 attributes
# Compact retry: limit the number of entity/edge types, drop attributes
compact_doc = "\n".join(document_texts)[:30000]
compact_messages = [
{
@ -249,12 +249,12 @@ class OntologyGenerator:
max_tokens=8192,
)
# 验证和后处理
# Validate and post-process
result = self._validate_and_process(result)
return result
# 传给 LLM 的文本最大长度5万字
# Max text length to send to the LLM (50,000 chars)
MAX_TEXT_LENGTH_FOR_LLM = 50000
def _build_user_message(
@ -263,50 +263,50 @@ class OntologyGenerator:
simulation_requirement: str,
additional_context: Optional[str]
) -> str:
"""构建用户消息"""
"""Build user message"""
# 合并文本
# Merge text
combined_text = "\n\n---\n\n".join(document_texts)
original_length = len(combined_text)
# 如果文本超过5万字截断仅影响传给LLM的内容不影响图谱构建
# If text exceeds 50,000 chars, truncate (only affects what is sent to the LLM, not the graph construction)
if len(combined_text) > self.MAX_TEXT_LENGTH_FOR_LLM:
combined_text = combined_text[:self.MAX_TEXT_LENGTH_FOR_LLM]
combined_text += f"\n\n...(原文共{original_length}字,已截取前{self.MAX_TEXT_LENGTH_FOR_LLM}字用于本体分析)..."
combined_text += f"\n\n...(original text is {original_length} chars, truncated to the first {self.MAX_TEXT_LENGTH_FOR_LLM} chars for ontology analysis)..."
message = f"""## 模拟需求
message = f"""## Simulation requirements
{simulation_requirement}
## 文档内容
## Document content
{combined_text}
"""
if additional_context:
message += f"""
## 额外说明
## Additional notes
{additional_context}
"""
message += """
请根据以上内容设计适合社会舆论模拟的实体类型和关系类型
Please design entity types and edge types suitable for social opinion simulation based on the content above.
**必须遵守的规则**
1. 必须正好输出10个实体类型
2. 最后2个必须是兜底类型Person个人兜底 Organization组织兜底
3. 前8个是根据文本内容设计的具体类型
4. 所有实体类型必须是现实中可以发声的主体不能是抽象概念
5. 属性名不能使用 nameuuidgroup_id 等保留字 full_nameorg_name 等替代
**Rules that must be followed**:
1. Output exactly 10 entity types
2. The last 2 must be fallback types: Person (individual fallback) and Organization (organization fallback)
3. The first 8 should be specific types designed from the text content
4. All entity types must be real-world entities capable of speaking out, not abstract concepts
5. Attribute names must not use reserved words like name, uuid, group_id; use full_name, org_name, etc. instead
"""
return message
def _validate_and_process(self, result: Dict[str, Any]) -> Dict[str, Any]:
"""验证和后处理结果"""
"""Validate and post-process the result"""
# 确保必要字段存在
# Ensure required fields exist
if "entity_types" not in result:
result["entity_types"] = []
if "edge_types" not in result:
@ -314,11 +314,11 @@ class OntologyGenerator:
if "analysis_summary" not in result:
result["analysis_summary"] = ""
# 验证实体类型
# 记录原始名称到 PascalCase 的映射,用于后续修正 edge 的 source_targets 引用
# Validate entity types
# Record the raw-name -> PascalCase mapping, used later to fix edge source_targets references
entity_name_map = {}
for entity in result["entity_types"]:
# 强制将 entity name 转为 PascalCaseZep API 要求)
# Force-convert entity name to PascalCase (Zep API requirement)
if "name" in entity:
original_name = entity["name"]
entity["name"] = _to_pascal_case(original_name)
@ -329,19 +329,19 @@ class OntologyGenerator:
entity["attributes"] = []
if "examples" not in entity:
entity["examples"] = []
# 确保description不超过100字符
# Ensure description does not exceed 100 characters
if len(entity.get("description", "")) > 100:
entity["description"] = entity["description"][:97] + "..."
# 验证关系类型
# Validate edge types
for edge in result["edge_types"]:
# 强制将 edge name 转为 SCREAMING_SNAKE_CASEZep API 要求)
# Force-convert edge name to SCREAMING_SNAKE_CASE (Zep API requirement)
if "name" in edge:
original_name = edge["name"]
edge["name"] = original_name.upper()
if edge["name"] != original_name:
logger.warning(f"Edge type name '{original_name}' auto-converted to '{edge['name']}'")
# 修正 source_targets 中的实体名称引用,与转换后的 PascalCase 保持一致
# Fix entity name references in source_targets to match the converted PascalCase
for st in edge.get("source_targets", []):
if st.get("source") in entity_name_map:
st["source"] = entity_name_map[st["source"]]
@ -354,11 +354,11 @@ class OntologyGenerator:
if len(edge.get("description", "")) > 100:
edge["description"] = edge["description"][:97] + "..."
# Zep API 限制:最多 10 个自定义实体类型,最多 10 个自定义边类型
# Zep API limit: at most 10 custom entity types, at most 10 custom edge types
MAX_ENTITY_TYPES = 10
MAX_EDGE_TYPES = 10
# 去重:按 name 去重,保留首次出现的
# Deduplicate by name, keep the first occurrence
seen_names = set()
deduped = []
for entity in result["entity_types"]:
@ -370,7 +370,7 @@ class OntologyGenerator:
logger.warning(f"Duplicate entity type '{name}' removed during validation")
result["entity_types"] = deduped
# 兜底类型定义
# Fallback type definitions
person_fallback = {
"name": "Person",
"description": "Any individual person not fitting other specific person types.",
@ -391,12 +391,12 @@ class OntologyGenerator:
"examples": ["small business", "community group"]
}
# 检查是否已有兜底类型
# Check whether fallback types already exist
entity_names = {e["name"] for e in result["entity_types"]}
has_person = "Person" in entity_names
has_organization = "Organization" in entity_names
# 需要添加的兜底类型
# Fallback types that need to be added
fallbacks_to_add = []
if not has_person:
fallbacks_to_add.append(person_fallback)
@ -407,17 +407,17 @@ class OntologyGenerator:
current_count = len(result["entity_types"])
needed_slots = len(fallbacks_to_add)
# 如果添加后会超过 10 个,需要移除一些现有类型
# If adding would exceed 10, we need to drop some existing types
if current_count + needed_slots > MAX_ENTITY_TYPES:
# 计算需要移除多少个
# Compute how many to remove
to_remove = current_count + needed_slots - MAX_ENTITY_TYPES
# 从末尾移除(保留前面更重要的具体类型)
# Remove from the end (keep the more important specific types at the front)
result["entity_types"] = result["entity_types"][:-to_remove]
# 添加兜底类型
# Add fallback types
result["entity_types"].extend(fallbacks_to_add)
# 最终确保不超过限制(防御性编程)
# Final guard to ensure the limit is not exceeded (defensive coding)
if len(result["entity_types"]) > MAX_ENTITY_TYPES:
result["entity_types"] = result["entity_types"][:MAX_ENTITY_TYPES]
@ -428,29 +428,29 @@ class OntologyGenerator:
def generate_python_code(self, ontology: Dict[str, Any]) -> str:
"""
将本体定义转换为Python代码类似ontology.py
Convert the ontology definition to Python code (similar to ontology.py)
Args:
ontology: 本体定义
ontology: Ontology definition
Returns:
Python代码字符串
Python code string
"""
code_lines = [
'"""',
'自定义实体类型定义',
'由MiroFish自动生成用于社会舆论模拟',
'Custom entity type definitions',
'Auto-generated by MiroFish, for social opinion simulation',
'"""',
'',
'from pydantic import Field',
'from zep_cloud.external_clients.ontology import EntityModel, EntityText, EdgeModel',
'',
'',
'# ============== 实体类型定义 ==============',
'# ============== Entity type definitions ==============',
'',
]
# 生成实体类型
# Generate entity types
for entity in ontology.get("entity_types", []):
name = entity["name"]
desc = entity.get("description", f"A {name} entity.")
@ -473,13 +473,13 @@ class OntologyGenerator:
code_lines.append('')
code_lines.append('')
code_lines.append('# ============== 关系类型定义 ==============')
code_lines.append('# ============== Edge type definitions ==============')
code_lines.append('')
# 生成关系类型
# Generate edge types
for edge in ontology.get("edge_types", []):
name = edge["name"]
# 转换为PascalCase类名
# Convert to PascalCase class name
class_name = ''.join(word.capitalize() for word in name.split('_'))
desc = edge.get("description", f"A {name} relationship.")
@ -501,8 +501,8 @@ class OntologyGenerator:
code_lines.append('')
code_lines.append('')
# 生成类型字典
code_lines.append('# ============== 类型配置 ==============')
# Generate type dictionary
code_lines.append('# ============== Type configuration ==============')
code_lines.append('')
code_lines.append('ENTITY_TYPES = {')
for entity in ontology.get("entity_types", []):
@ -518,7 +518,7 @@ class OntologyGenerator:
code_lines.append('}')
code_lines.append('')
# 生成边的source_targets映射
# Generate edge source_targets mapping
code_lines.append('EDGE_SOURCE_TARGETS = {')
for edge in ontology.get("edge_types", []):
name = edge["name"]

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +1,13 @@
"""
模拟IPC通信模块
用于Flask后端和模拟脚本之间的进程间通信
Simulation IPC communication module
Used for inter-process communication between the Flask backend
and the simulation script.
通过文件系统实现简单的命令/响应模式
1. Flask写入命令到 commands/ 目录
2. 模拟脚本轮询命令目录执行命令并写入响应到 responses/ 目录
3. Flask轮询响应目录获取结果
Implements a simple command/response pattern via the filesystem:
1. Flask writes commands into the commands/ directory
2. The simulation script polls the commands directory, executes the
commands, and writes responses into the responses/ directory
3. Flask polls the responses directory to fetch results
"""
import os
@ -23,14 +25,14 @@ logger = get_logger('mirofish.simulation_ipc')
class CommandType(str, Enum):
"""命令类型"""
INTERVIEW = "interview" # 单个Agent采访
BATCH_INTERVIEW = "batch_interview" # 批量采访
CLOSE_ENV = "close_env" # 关闭环境
"""Command type"""
INTERVIEW = "interview" # Single-agent interview
BATCH_INTERVIEW = "batch_interview" # Batch interview
CLOSE_ENV = "close_env" # Close environment
class CommandStatus(str, Enum):
"""命令状态"""
"""Command status"""
PENDING = "pending"
PROCESSING = "processing"
COMPLETED = "completed"
@ -39,12 +41,12 @@ class CommandStatus(str, Enum):
@dataclass
class IPCCommand:
"""IPC命令"""
"""IPC command"""
command_id: str
command_type: CommandType
args: Dict[str, Any]
timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
def to_dict(self) -> Dict[str, Any]:
return {
"command_id": self.command_id,
@ -52,7 +54,7 @@ class IPCCommand:
"args": self.args,
"timestamp": self.timestamp
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'IPCCommand':
return cls(
@ -65,13 +67,13 @@ class IPCCommand:
@dataclass
class IPCResponse:
"""IPC响应"""
"""IPC response"""
command_id: str
status: CommandStatus
result: Optional[Dict[str, Any]] = None
error: Optional[str] = None
timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
def to_dict(self) -> Dict[str, Any]:
return {
"command_id": self.command_id,
@ -80,7 +82,7 @@ class IPCResponse:
"error": self.error,
"timestamp": self.timestamp
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'IPCResponse':
return cls(
@ -94,26 +96,26 @@ class IPCResponse:
class SimulationIPCClient:
"""
模拟IPC客户端Flask端使用
用于向模拟进程发送命令并等待响应
Simulation IPC client (used on the Flask side)
Sends commands to the simulation process and waits for responses.
"""
def __init__(self, simulation_dir: str):
"""
初始化IPC客户端
Initialize the IPC client.
Args:
simulation_dir: 模拟数据目录
simulation_dir: Simulation data directory
"""
self.simulation_dir = simulation_dir
self.commands_dir = os.path.join(simulation_dir, "ipc_commands")
self.responses_dir = os.path.join(simulation_dir, "ipc_responses")
# 确保目录存在
# Ensure directories exist
os.makedirs(self.commands_dir, exist_ok=True)
os.makedirs(self.responses_dir, exist_ok=True)
def send_command(
self,
command_type: CommandType,
@ -122,19 +124,19 @@ class SimulationIPCClient:
poll_interval: float = 0.5
) -> IPCResponse:
"""
发送命令并等待响应
Send a command and wait for a response.
Args:
command_type: 命令类型
args: 命令参数
timeout: 超时时间
poll_interval: 轮询间隔
command_type: Command type
args: Command arguments
timeout: Timeout in seconds
poll_interval: Poll interval in seconds
Returns:
IPCResponse
Raises:
TimeoutError: 等待响应超时
TimeoutError: Timed out waiting for a response
"""
command_id = str(uuid.uuid4())
command = IPCCommand(
@ -142,50 +144,50 @@ class SimulationIPCClient:
command_type=command_type,
args=args
)
# 写入命令文件
# Write the command file
command_file = os.path.join(self.commands_dir, f"{command_id}.json")
with open(command_file, 'w', encoding='utf-8') as f:
json.dump(command.to_dict(), f, ensure_ascii=False, indent=2)
logger.info(f"发送IPC命令: {command_type.value}, command_id={command_id}")
# 等待响应
logger.info(f"Sent IPC command: {command_type.value}, command_id={command_id}")
# Wait for the response
response_file = os.path.join(self.responses_dir, f"{command_id}.json")
start_time = time.time()
while time.time() - start_time < timeout:
if os.path.exists(response_file):
try:
with open(response_file, 'r', encoding='utf-8') as f:
response_data = json.load(f)
response = IPCResponse.from_dict(response_data)
# 清理命令和响应文件
# Clean up command and response files
try:
os.remove(command_file)
os.remove(response_file)
except OSError:
pass
logger.info(f"收到IPC响应: command_id={command_id}, status={response.status.value}")
logger.info(f"Received IPC response: command_id={command_id}, status={response.status.value}")
return response
except (json.JSONDecodeError, KeyError) as e:
logger.warning(f"解析响应失败: {e}")
logger.warning(f"Failed to parse response: {e}")
time.sleep(poll_interval)
# 超时
logger.error(f"等待IPC响应超时: command_id={command_id}")
# 清理命令文件
# Timeout
logger.error(f"Timeout waiting for IPC response: command_id={command_id}")
# Clean up the command file
try:
os.remove(command_file)
except OSError:
pass
raise TimeoutError(f"等待命令响应超时 ({timeout})")
raise TimeoutError(f"Timed out waiting for command response ({timeout}s)")
def send_interview(
self,
agent_id: int,
@ -194,19 +196,21 @@ class SimulationIPCClient:
timeout: float = 60.0
) -> IPCResponse:
"""
发送单个Agent采访命令
Send a single-Agent interview command.
Args:
agent_id: Agent ID
prompt: 采访问题
platform: 指定平台可选
- "twitter": 只采访Twitter平台
- "reddit": 只采访Reddit平台
- None: 双平台模拟时同时采访两个平台单平台模拟时采访该平台
timeout: 超时时间
prompt: Interview question
platform: Specific platform (optional)
- "twitter": only interview the Twitter platform
- "reddit": only interview the Reddit platform
- None: in a dual-platform simulation interview both
platforms; in a single-platform simulation interview
that platform
timeout: Timeout
Returns:
IPCResponseresult字段包含采访结果
IPCResponse; the result field contains the interview result
"""
args = {
"agent_id": agent_id,
@ -214,13 +218,13 @@ class SimulationIPCClient:
}
if platform:
args["platform"] = platform
return self.send_command(
command_type=CommandType.INTERVIEW,
args=args,
timeout=timeout
)
def send_batch_interview(
self,
interviews: List[Dict[str, Any]],
@ -228,36 +232,39 @@ class SimulationIPCClient:
timeout: float = 120.0
) -> IPCResponse:
"""
发送批量采访命令
Send a batch interview command.
Args:
interviews: 采访列表每个元素包含 {"agent_id": int, "prompt": str, "platform": str(可选)}
platform: 默认平台可选会被每个采访项的platform覆盖
- "twitter": 默认只采访Twitter平台
- "reddit": 默认只采访Reddit平台
- None: 双平台模拟时每个Agent同时采访两个平台
timeout: 超时时间
interviews: List of interviews; each item is
{"agent_id": int, "prompt": str, "platform": str (optional)}
platform: Default platform (optional; overridden by each
interview item's platform)
- "twitter": default to interviewing only Twitter
- "reddit": default to interviewing only Reddit
- None: in a dual-platform simulation interview each
Agent on both platforms
timeout: Timeout
Returns:
IPCResponseresult字段包含所有采访结果
IPCResponse; the result field contains all interview results
"""
args = {"interviews": interviews}
if platform:
args["platform"] = platform
return self.send_command(
command_type=CommandType.BATCH_INTERVIEW,
args=args,
timeout=timeout
)
def send_close_env(self, timeout: float = 30.0) -> IPCResponse:
"""
发送关闭环境命令
Send a close-environment command.
Args:
timeout: 超时时间
timeout: Timeout
Returns:
IPCResponse
"""
@ -266,17 +273,17 @@ class SimulationIPCClient:
args={},
timeout=timeout
)
def check_env_alive(self) -> bool:
"""
检查模拟环境是否存活
通过检查 env_status.json 文件来判断
Check whether the simulation environment is alive.
Decides by checking the env_status.json file.
"""
status_file = os.path.join(self.simulation_dir, "env_status.json")
if not os.path.exists(status_file):
return False
try:
with open(status_file, 'r', encoding='utf-8') as f:
status = json.load(f)
@ -287,106 +294,107 @@ class SimulationIPCClient:
class SimulationIPCServer:
"""
模拟IPC服务器模拟脚本端使用
轮询命令目录执行命令并返回响应
Simulation IPC server (used on the simulation-script side)
Polls the commands directory, executes commands, and returns
responses.
"""
def __init__(self, simulation_dir: str):
"""
初始化IPC服务器
Initialize the IPC server.
Args:
simulation_dir: 模拟数据目录
simulation_dir: Simulation data directory
"""
self.simulation_dir = simulation_dir
self.commands_dir = os.path.join(simulation_dir, "ipc_commands")
self.responses_dir = os.path.join(simulation_dir, "ipc_responses")
# 确保目录存在
# Ensure directories exist
os.makedirs(self.commands_dir, exist_ok=True)
os.makedirs(self.responses_dir, exist_ok=True)
# 环境状态
# Environment status
self._running = False
def start(self):
"""标记服务器为运行状态"""
"""Mark the server as running"""
self._running = True
self._update_env_status("alive")
def stop(self):
"""标记服务器为停止状态"""
"""Mark the server as stopped"""
self._running = False
self._update_env_status("stopped")
def _update_env_status(self, status: str):
"""更新环境状态文件"""
"""Update the environment-status file"""
status_file = os.path.join(self.simulation_dir, "env_status.json")
with open(status_file, 'w', encoding='utf-8') as f:
json.dump({
"status": status,
"timestamp": datetime.now().isoformat()
}, f, ensure_ascii=False, indent=2)
def poll_commands(self) -> Optional[IPCCommand]:
"""
轮询命令目录返回第一个待处理的命令
Poll the commands directory; return the first pending command.
Returns:
IPCCommand None
IPCCommand or None
"""
if not os.path.exists(self.commands_dir):
return None
# 按时间排序获取命令文件
# Sort command files by modification time
command_files = []
for filename in os.listdir(self.commands_dir):
if filename.endswith('.json'):
filepath = os.path.join(self.commands_dir, filename)
command_files.append((filepath, os.path.getmtime(filepath)))
command_files.sort(key=lambda x: x[1])
for filepath, _ in command_files:
try:
with open(filepath, 'r', encoding='utf-8') as f:
data = json.load(f)
return IPCCommand.from_dict(data)
except (json.JSONDecodeError, KeyError, OSError) as e:
logger.warning(f"读取命令文件失败: {filepath}, {e}")
logger.warning(f"Failed to read command file: {filepath}, {e}")
continue
return None
def send_response(self, response: IPCResponse):
"""
发送响应
Send a response.
Args:
response: IPC响应
response: IPC response
"""
response_file = os.path.join(self.responses_dir, f"{response.command_id}.json")
with open(response_file, 'w', encoding='utf-8') as f:
json.dump(response.to_dict(), f, ensure_ascii=False, indent=2)
# 删除命令文件
# Delete the command file
command_file = os.path.join(self.commands_dir, f"{response.command_id}.json")
try:
os.remove(command_file)
except OSError:
pass
def send_success(self, command_id: str, result: Dict[str, Any]):
"""发送成功响应"""
"""Send a success response"""
self.send_response(IPCResponse(
command_id=command_id,
status=CommandStatus.COMPLETED,
result=result
))
def send_error(self, command_id: str, error: str):
"""发送错误响应"""
"""Send an error response"""
self.send_response(IPCResponse(
command_id=command_id,
status=CommandStatus.FAILED,

View File

@ -1,7 +1,7 @@
"""
OASIS模拟管理器
管理Twitter和Reddit双平台并行模拟
使用预设脚本 + LLM智能生成配置参数
OASIS Simulation Manager
Manages parallel Twitter and Reddit dual-platform simulations
Uses preset scripts + LLM-intelligent configuration parameter generation
"""
import os
@ -23,60 +23,60 @@ logger = get_logger('mirofish.simulation')
class SimulationStatus(str, Enum):
"""模拟状态"""
"""Simulation status"""
CREATED = "created"
PREPARING = "preparing"
READY = "ready"
RUNNING = "running"
PAUSED = "paused"
STOPPED = "stopped" # 模拟被手动停止
COMPLETED = "completed" # 模拟自然完成
STOPPED = "stopped" # Simulation was manually stopped
COMPLETED = "completed" # Simulation completed naturally
FAILED = "failed"
class PlatformType(str, Enum):
"""平台类型"""
"""Platform type"""
TWITTER = "twitter"
REDDIT = "reddit"
@dataclass
class SimulationState:
"""模拟状态"""
"""Simulation state"""
simulation_id: str
project_id: str
graph_id: str
# 平台启用状态
# Platform enable status
enable_twitter: bool = True
enable_reddit: bool = True
# 状态
# Status
status: SimulationStatus = SimulationStatus.CREATED
# 准备阶段数据
# Preparation stage data
entities_count: int = 0
profiles_count: int = 0
entity_types: List[str] = field(default_factory=list)
# 配置生成信息
# Config generation info
config_generated: bool = False
config_reasoning: str = ""
# 运行时数据
# Runtime data
current_round: int = 0
twitter_status: str = "not_started"
reddit_status: str = "not_started"
# 时间戳
# Timestamps
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
updated_at: str = field(default_factory=lambda: datetime.now().isoformat())
# 错误信息
# Error info
error: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
"""完整状态字典(内部使用)"""
"""Full state dictionary (for internal use)"""
return {
"simulation_id": self.simulation_id,
"project_id": self.project_id,
@ -98,7 +98,7 @@ class SimulationState:
}
def to_simple_dict(self) -> Dict[str, Any]:
"""简化状态字典API返回使用"""
"""Simplified state dictionary (for API response)"""
return {
"simulation_id": self.simulation_id,
"project_id": self.project_id,
@ -114,36 +114,36 @@ class SimulationState:
class SimulationManager:
"""
模拟管理器
核心功能
1. 从Zep图谱读取实体并过滤
2. 生成OASIS Agent Profile
3. 使用LLM智能生成模拟配置参数
4. 准备预设脚本所需的所有文件
Simulation Manager
Core features:
1. Read entities from Zep graph and filter
2. Generate OASIS Agent Profile
3. Use LLM to intelligently generate simulation configuration parameters
4. Prepare all files required by the preset scripts
"""
# 模拟数据存储目录
# Simulation data storage directory
SIMULATION_DATA_DIR = os.path.join(
os.path.dirname(__file__),
os.path.dirname(__file__),
'../../uploads/simulations'
)
def __init__(self):
# 确保目录存在
# Ensure the directory exists
os.makedirs(self.SIMULATION_DATA_DIR, exist_ok=True)
# 内存中的模拟状态缓存
# In-memory simulation state cache
self._simulations: Dict[str, SimulationState] = {}
def _get_simulation_dir(self, simulation_id: str) -> str:
"""获取模拟数据目录"""
"""Get the simulation data directory"""
sim_dir = os.path.join(self.SIMULATION_DATA_DIR, simulation_id)
os.makedirs(sim_dir, exist_ok=True)
return sim_dir
def _save_simulation_state(self, state: SimulationState):
"""保存模拟状态到文件"""
"""Save the simulation state to file"""
sim_dir = self._get_simulation_dir(state.simulation_id)
state_file = os.path.join(sim_dir, "state.json")
@ -155,7 +155,7 @@ class SimulationManager:
self._simulations[state.simulation_id] = state
def _load_simulation_state(self, simulation_id: str) -> Optional[SimulationState]:
"""从文件加载模拟状态"""
"""Load the simulation state from file"""
if simulation_id in self._simulations:
return self._simulations[simulation_id]
@ -199,20 +199,20 @@ class SimulationManager:
enable_reddit: bool = True,
) -> SimulationState:
"""
创建新的模拟
Create a new simulation
Args:
project_id: 项目ID
graph_id: Zep图谱ID
enable_twitter: 是否启用Twitter模拟
enable_reddit: 是否启用Reddit模拟
project_id: Project ID
graph_id: Zep graph ID
enable_twitter: Whether to enable Twitter simulation
enable_reddit: Whether to enable Reddit simulation
Returns:
SimulationState
"""
import uuid
simulation_id = f"sim_{uuid.uuid4().hex[:12]}"
state = SimulationState(
simulation_id=simulation_id,
project_id=project_id,
@ -221,9 +221,9 @@ class SimulationManager:
enable_reddit=enable_reddit,
status=SimulationStatus.CREATED,
)
self._save_simulation_state(state)
logger.info(f"创建模拟: {simulation_id}, project={project_id}, graph={graph_id}")
logger.info(f"Created simulation: {simulation_id}, project={project_id}, graph={graph_id}")
return state
@ -238,30 +238,30 @@ class SimulationManager:
parallel_profile_count: int = 3
) -> SimulationState:
"""
准备模拟环境全程自动化
步骤
1. 从Zep图谱读取并过滤实体
2. 为每个实体生成OASIS Agent Profile可选LLM增强支持并行
3. 使用LLM智能生成模拟配置参数时间活跃度发言频率等
4. 保存配置文件和Profile文件
5. 复制预设脚本到模拟目录
Prepare the simulation environment (fully automated)
Steps:
1. Read and filter entities from the Zep graph
2. Generate OASIS Agent Profile for each entity (optional LLM enhancement, supports parallelism)
3. Use LLM to intelligently generate simulation configuration parameters (time, activity, posting frequency, etc.)
4. Save config and profile files
5. Copy preset scripts to the simulation directory
Args:
simulation_id: 模拟ID
simulation_requirement: 模拟需求描述用于LLM生成配置
document_text: 原始文档内容用于LLM理解背景
defined_entity_types: 预定义的实体类型可选
use_llm_for_profiles: 是否使用LLM生成详细人设
progress_callback: 进度回调函数 (stage, progress, message)
parallel_profile_count: 并行生成人设的数量默认3
simulation_id: Simulation ID
simulation_requirement: Simulation requirement description (used for LLM config generation)
document_text: Original document content (used for LLM to understand context)
defined_entity_types: Predefined entity types (optional)
use_llm_for_profiles: Whether to use LLM to generate detailed personas
progress_callback: Progress callback function (stage, progress, message)
parallel_profile_count: Number of personas to generate in parallel, default 3
Returns:
SimulationState
"""
state = self._load_simulation_state(simulation_id)
if not state:
raise ValueError(f"模拟不存在: {simulation_id}")
raise ValueError(f"Simulation not found: {simulation_id}")
try:
state.status = SimulationStatus.PREPARING
@ -269,24 +269,24 @@ class SimulationManager:
sim_dir = self._get_simulation_dir(simulation_id)
# ========== 阶段1: 读取并过滤实体 ==========
# ========== Stage 1: Read and filter entities ==========
if progress_callback:
progress_callback("reading", 0, t('progress.connectingZepGraph'))
reader = ZepEntityReader()
if progress_callback:
progress_callback("reading", 30, t('progress.readingNodeData'))
filtered = reader.filter_defined_entities(
graph_id=state.graph_id,
defined_entity_types=defined_entity_types,
enrich_with_edges=True
)
state.entities_count = filtered.filtered_count
state.entity_types = list(filtered.entity_types)
if progress_callback:
progress_callback(
"reading", 100,
@ -294,16 +294,16 @@ class SimulationManager:
current=filtered.filtered_count,
total=filtered.filtered_count
)
if filtered.filtered_count == 0:
state.status = SimulationStatus.FAILED
state.error = "没有找到符合条件的实体,请检查图谱是否正确构建"
state.error = "No entities matching the criteria were found. Please check whether the graph was built correctly."
self._save_simulation_state(state)
return state
# ========== 阶段2: 生成Agent Profile ==========
# ========== Stage 2: Generate Agent Profile ==========
total_entities = len(filtered.entities)
if progress_callback:
progress_callback(
"generating_profiles", 0,
@ -311,22 +311,22 @@ class SimulationManager:
current=0,
total=total_entities
)
# 传入graph_id以启用Zep检索功能获取更丰富的上下文
# Pass graph_id to enable Zep retrieval for richer context
generator = OasisProfileGenerator(graph_id=state.graph_id)
def profile_progress(current, total, msg):
if progress_callback:
progress_callback(
"generating_profiles",
int(current / total * 100),
"generating_profiles",
int(current / total * 100),
msg,
current=current,
total=total,
item_name=msg
)
# 设置实时保存的文件路径(优先使用 Reddit JSON 格式)
# Configure real-time save path (prefer Reddit JSON format)
realtime_output_path = None
realtime_platform = "reddit"
if state.enable_reddit:
@ -335,21 +335,21 @@ class SimulationManager:
elif state.enable_twitter:
realtime_output_path = os.path.join(sim_dir, "twitter_profiles.csv")
realtime_platform = "twitter"
profiles = generator.generate_profiles_from_entities(
entities=filtered.entities,
use_llm=use_llm_for_profiles,
progress_callback=profile_progress,
graph_id=state.graph_id, # 传入graph_id用于Zep检索
parallel_count=parallel_profile_count, # 并行生成数量
realtime_output_path=realtime_output_path, # 实时保存路径
output_platform=realtime_platform # 输出格式
graph_id=state.graph_id, # Pass graph_id for Zep retrieval
parallel_count=parallel_profile_count, # Parallel generation count
realtime_output_path=realtime_output_path, # Real-time save path
output_platform=realtime_platform # Output format
)
state.profiles_count = len(profiles)
# 保存Profile文件注意Twitter使用CSV格式Reddit使用JSON格式
# Reddit 已经在生成过程中实时保存了,这里再保存一次确保完整性
# Save profile files (note: Twitter uses CSV, Reddit uses JSON)
# Reddit has already been saved in real time during generation; saving once more here ensures completeness
if progress_callback:
progress_callback(
"generating_profiles", 95,
@ -357,16 +357,16 @@ class SimulationManager:
current=total_entities,
total=total_entities
)
if state.enable_reddit:
generator.save_profiles(
profiles=profiles,
file_path=os.path.join(sim_dir, "reddit_profiles.json"),
platform="reddit"
)
if state.enable_twitter:
# Twitter使用CSV格式这是OASIS的要求
# Twitter uses CSV format! This is required by OASIS
generator.save_profiles(
profiles=profiles,
file_path=os.path.join(sim_dir, "twitter_profiles.csv"),
@ -381,7 +381,7 @@ class SimulationManager:
total=len(profiles)
)
# ========== 阶段3: LLM智能生成模拟配置 ==========
# ========== Stage 3: LLM-intelligent generation of simulation config ==========
if progress_callback:
progress_callback(
"generating_config", 0,
@ -389,9 +389,9 @@ class SimulationManager:
current=0,
total=3
)
config_generator = SimulationConfigGenerator()
if progress_callback:
progress_callback(
"generating_config", 30,
@ -399,7 +399,7 @@ class SimulationManager:
current=1,
total=3
)
sim_params = config_generator.generate_config(
simulation_id=simulation_id,
project_id=state.project_id,
@ -410,7 +410,7 @@ class SimulationManager:
enable_twitter=state.enable_twitter,
enable_reddit=state.enable_reddit
)
if progress_callback:
progress_callback(
"generating_config", 70,
@ -418,15 +418,15 @@ class SimulationManager:
current=2,
total=3
)
# 保存配置文件
# Save config file
config_path = os.path.join(sim_dir, "simulation_config.json")
with open(config_path, 'w', encoding='utf-8') as f:
f.write(sim_params.to_json())
state.config_generated = True
state.config_reasoning = sim_params.generation_reasoning
if progress_callback:
progress_callback(
"generating_config", 100,
@ -434,82 +434,83 @@ class SimulationManager:
current=3,
total=3
)
# 注意:运行脚本保留在 backend/scripts/ 目录,不再复制到模拟目录
# 启动模拟时simulation_runner 会从 scripts/ 目录运行脚本
# 更新状态
# Note: run scripts are kept in backend/scripts/ and are no longer copied into the simulation dir
# When starting a simulation, simulation_runner runs scripts from the scripts/ directory
# Update state
state.status = SimulationStatus.READY
self._save_simulation_state(state)
logger.info(f"模拟准备完成: {simulation_id}, "
logger.info(f"Simulation preparation complete: {simulation_id}, "
f"entities={state.entities_count}, profiles={state.profiles_count}")
return state
except Exception as e:
logger.error(f"模拟准备失败: {simulation_id}, error={str(e)}")
logger.error(f"Simulation preparation failed: {simulation_id}, error={str(e)}")
import traceback
logger.error(traceback.format_exc())
state.status = SimulationStatus.FAILED
state.error = str(e)
self._save_simulation_state(state)
raise
def get_simulation(self, simulation_id: str) -> Optional[SimulationState]:
"""获取模拟状态"""
"""Get the simulation state"""
return self._load_simulation_state(simulation_id)
def list_simulations(self, project_id: Optional[str] = None) -> List[SimulationState]:
"""列出所有模拟"""
"""List all simulations"""
simulations = []
if os.path.exists(self.SIMULATION_DATA_DIR):
for sim_id in os.listdir(self.SIMULATION_DATA_DIR):
# 跳过隐藏文件(如 .DS_Store和非目录文件
# Skip hidden files (e.g. .DS_Store) and non-directory entries
sim_path = os.path.join(self.SIMULATION_DATA_DIR, sim_id)
if sim_id.startswith('.') or not os.path.isdir(sim_path):
continue
state = self._load_simulation_state(sim_id)
if state:
if project_id is None or state.project_id == project_id:
simulations.append(state)
return simulations
def get_profiles(self, simulation_id: str, platform: str = "reddit") -> List[Dict[str, Any]]:
"""获取模拟的Agent Profile"""
"""Get the Agent Profiles of a simulation"""
state = self._load_simulation_state(simulation_id)
if not state:
raise ValueError(f"模拟不存在: {simulation_id}")
raise ValueError(f"Simulation not found: {simulation_id}")
sim_dir = self._get_simulation_dir(simulation_id)
profile_path = os.path.join(sim_dir, f"{platform}_profiles.json")
if not os.path.exists(profile_path):
return []
with open(profile_path, 'r', encoding='utf-8') as f:
return json.load(f)
def get_simulation_config(self, simulation_id: str) -> Optional[Dict[str, Any]]:
"""获取模拟配置"""
"""Get the simulation config"""
sim_dir = self._get_simulation_dir(simulation_id)
config_path = os.path.join(sim_dir, "simulation_config.json")
if not os.path.exists(config_path):
return None
with open(config_path, 'r', encoding='utf-8') as f:
return json.load(f)
def get_run_instructions(self, simulation_id: str) -> Dict[str, str]:
"""获取运行说明"""
"""Get the run instructions"""
sim_dir = self._get_simulation_dir(simulation_id)
config_path = os.path.join(sim_dir, "simulation_config.json")
scripts_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../scripts'))
return {
"simulation_dir": sim_dir,
"scripts_dir": scripts_dir,
@ -520,10 +521,10 @@ class SimulationManager:
"parallel": f"python {scripts_dir}/run_parallel_simulation.py --config {config_path}",
},
"instructions": (
f"1. 激活conda环境: conda activate MiroFish\n"
f"2. 运行模拟 (脚本位于 {scripts_dir}):\n"
f" - 单独运行Twitter: python {scripts_dir}/run_twitter_simulation.py --config {config_path}\n"
f" - 单独运行Reddit: python {scripts_dir}/run_reddit_simulation.py --config {config_path}\n"
f" - 并行运行双平台: python {scripts_dir}/run_parallel_simulation.py --config {config_path}"
f"1. Activate the conda environment: conda activate MiroFish\n"
f"2. Run the simulation (scripts are at {scripts_dir}):\n"
f" - Twitter only: python {scripts_dir}/run_twitter_simulation.py --config {config_path}\n"
f" - Reddit only: python {scripts_dir}/run_reddit_simulation.py --config {config_path}\n"
f" - Both platforms in parallel: python {scripts_dir}/run_parallel_simulation.py --config {config_path}"
)
}

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
"""
文本处理服务
Text processing service
"""
from typing import List, Optional
@ -7,11 +7,11 @@ from ..utils.file_parser import FileParser, split_text_into_chunks
class TextProcessor:
"""文本处理器"""
"""Text processor"""
@staticmethod
def extract_from_files(file_paths: List[str]) -> str:
"""从多个文件提取文本"""
"""Extract text from multiple files"""
return FileParser.extract_from_multiple(file_paths)
@staticmethod
@ -21,40 +21,40 @@ class TextProcessor:
overlap: int = 50
) -> List[str]:
"""
分割文本
Split text into chunks
Args:
text: 原始文本
chunk_size: 块大小
overlap: 重叠大小
text: raw text
chunk_size: chunk size
overlap: overlap size
Returns:
文本块列表
list of text chunks
"""
return split_text_into_chunks(text, chunk_size, overlap)
@staticmethod
def preprocess_text(text: str) -> str:
"""
预处理文本
- 移除多余空白
- 标准化换行
Preprocess text
- Strip extra whitespace
- Normalize newlines
Args:
text: 原始文本
text: raw text
Returns:
处理后的文本
processed text
"""
import re
# 标准化换行
# Normalize newlines
text = text.replace('\r\n', '\n').replace('\r', '\n')
# 移除连续空行(保留最多两个换行
# Collapse consecutive blank linesKeep at most two newlines in a row
text = re.sub(r'\n{3,}', '\n\n', text)
# 移除行首行尾空白
# Strip leading/trailing whitespace per line
lines = [line.strip() for line in text.split('\n')]
text = '\n'.join(lines)
@ -62,7 +62,7 @@ class TextProcessor:
@staticmethod
def get_text_stats(text: str) -> dict:
"""获取文本统计信息"""
"""Get text statistics"""
return {
"total_chars": len(text),
"total_lines": text.count('\n') + 1,

View File

@ -1,6 +1,7 @@
"""
Zep实体读取与过滤服务
从Zep图谱中读取节点筛选出符合预定义实体类型的节点
Zep entity read & filter service
Reads nodes from the Zep graph and filters out nodes that match
predefined entity types.
"""
import time
@ -25,23 +26,23 @@ from ..utils.zep_paging import fetch_all_nodes, fetch_all_edges
logger = get_logger('mirofish.zep_entity_reader')
# 用于泛型返回类型
# Used for generic return type
T = TypeVar('T')
@dataclass
class EntityNode:
"""实体节点数据结构"""
"""Entity node data structure"""
uuid: str
name: str
labels: List[str]
summary: str
attributes: Dict[str, Any]
# 相关的边信息
# Related edge info
related_edges: List[Dict[str, Any]] = field(default_factory=list)
# 相关的其他节点信息
# Other related-node info
related_nodes: List[Dict[str, Any]] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
return {
"uuid": self.uuid,
@ -52,9 +53,9 @@ class EntityNode:
"related_edges": self.related_edges,
"related_nodes": self.related_nodes,
}
def get_entity_type(self) -> Optional[str]:
"""获取实体类型排除默认的Entity标签"""
"""Get the entity type (excluding the default Entity label)"""
for label in self.labels:
if label not in ["Entity", "Node"]:
return label
@ -63,12 +64,12 @@ class EntityNode:
@dataclass
class FilteredEntities:
"""过滤后的实体集合"""
"""Filtered entity collection"""
entities: List[EntityNode]
entity_types: Set[str]
total_count: int
filtered_count: int
def to_dict(self) -> Dict[str, Any]:
return {
"entities": [e.to_dict() for e in self.entities],
@ -80,12 +81,13 @@ class FilteredEntities:
class ZepEntityReader:
"""
Zep实体读取与过滤服务
主要功能
1. 从Zep图谱读取所有节点
2. 筛选出符合预定义实体类型的节点Labels不只是Entity的节点
3. 获取每个实体的相关边和关联节点信息
Zep entity read & filter service
Main features:
1. Read all nodes from the Zep graph
2. Filter out nodes that match predefined entity types (nodes whose
labels are not just "Entity")
3. Get the related edges and adjacent node info for each entity
"""
def __init__(self, api_key: Optional[str] = None):
@ -94,27 +96,27 @@ class ZepEntityReader:
self.client = get_graphiti_adapter()
def _call_with_retry(
self,
func: Callable[[], T],
self,
func: Callable[[], T],
operation_name: str,
max_retries: int = 3,
initial_delay: float = 2.0
) -> T:
"""
带重试机制的Zep API调用
Zep API call with retry logic.
Args:
func: 要执行的函数无参数的lambda或callable
operation_name: 操作名称用于日志
max_retries: 最大重试次数默认3次即最多尝试3次
initial_delay: 初始延迟秒数
func: Function (parameterless lambda or callable) to execute
operation_name: Operation name, used in logs
max_retries: Maximum number of retries (default 3, i.e. at most 3 attempts)
initial_delay: Initial delay in seconds
Returns:
API调用结果
The result of the API call
"""
last_exception = None
delay = initial_delay
for attempt in range(max_retries):
try:
return func()
@ -122,27 +124,27 @@ class ZepEntityReader:
last_exception = e
if attempt < max_retries - 1:
logger.warning(
f"Zep {operation_name} {attempt + 1} 次尝试失败: {str(e)[:100]}, "
f"{delay:.1f}秒后重试..."
f"Zep {operation_name} attempt {attempt + 1} failed: {str(e)[:100]}, "
f"retrying in {delay:.1f}s..."
)
time.sleep(delay)
delay *= 2 # 指数退避
delay *= 2 # Exponential backoff
else:
logger.error(f"Zep {operation_name} {max_retries} 次尝试后仍失败: {str(e)}")
logger.error(f"Zep {operation_name} still failing after {max_retries} attempts: {str(e)}")
raise last_exception
def get_all_nodes(self, graph_id: str) -> List[Dict[str, Any]]:
"""
获取图谱的所有节点分页获取
Get all nodes of the graph (paginated).
Args:
graph_id: 图谱ID
graph_id: Graph ID
Returns:
节点列表
List of nodes
"""
logger.info(f"获取图谱 {graph_id} 的所有节点...")
logger.info(f"Fetching all nodes for graph {graph_id}...")
nodes = fetch_all_nodes(self.client, graph_id)
@ -156,20 +158,20 @@ class ZepEntityReader:
"attributes": node.attributes or {},
})
logger.info(f"共获取 {len(nodes_data)} 个节点")
logger.info(f"Fetched {len(nodes_data)} nodes in total")
return nodes_data
def get_all_edges(self, graph_id: str) -> List[Dict[str, Any]]:
"""
获取图谱的所有边分页获取
Get all edges of the graph (paginated).
Args:
graph_id: 图谱ID
graph_id: Graph ID
Returns:
边列表
List of edges
"""
logger.info(f"获取图谱 {graph_id} 的所有边...")
logger.info(f"Fetching all edges for graph {graph_id}...")
edges = fetch_all_edges(self.client, graph_id)
@ -184,26 +186,26 @@ class ZepEntityReader:
"attributes": edge.attributes or {},
})
logger.info(f"共获取 {len(edges_data)} 条边")
logger.info(f"Fetched {len(edges_data)} edges in total")
return edges_data
def get_node_edges(self, node_uuid: str) -> List[Dict[str, Any]]:
"""
获取指定节点的所有相关边带重试机制
Get all related edges of a given node (with retry logic).
Args:
node_uuid: 节点UUID
node_uuid: Node UUID
Returns:
边列表
List of edges
"""
try:
# Graphiti via adapter
edges = self._call_with_retry(
func=lambda: self.client.get_node_edges(node_uuid=node_uuid),
operation_name=f"获取节点边(node={node_uuid[:8]}...)"
operation_name=f"get_node_edges(node={node_uuid[:8]}...)"
)
edges_data = []
for edge in edges:
edges_data.append({
@ -214,60 +216,63 @@ class ZepEntityReader:
"target_node_uuid": edge.target_node_uuid,
"attributes": edge.attributes or {},
})
return edges_data
except Exception as e:
logger.warning(f"获取节点 {node_uuid} 的边失败: {str(e)}")
logger.warning(f"Failed to fetch edges for node {node_uuid}: {str(e)}")
return []
def filter_defined_entities(
self,
self,
graph_id: str,
defined_entity_types: Optional[List[str]] = None,
enrich_with_edges: bool = True
) -> FilteredEntities:
"""
筛选出符合预定义实体类型的节点
筛选逻辑
- 如果节点的Labels只有一个"Entity"说明这个实体不符合我们预定义的类型跳过
- 如果节点的Labels包含除"Entity""Node"之外的标签说明符合预定义类型保留
Filter out nodes that match predefined entity types.
Filter logic:
- If a node's labels contain only "Entity", it does not match
any of our predefined types and is skipped.
- If a node's labels include something other than "Entity" and
"Node", it matches a predefined type and is kept.
Args:
graph_id: 图谱ID
defined_entity_types: 预定义的实体类型列表可选如果提供则只保留这些类型
enrich_with_edges: 是否获取每个实体的相关边信息
graph_id: Graph ID
defined_entity_types: List of predefined entity types (optional; if
provided, only these types are kept)
enrich_with_edges: Whether to fetch related edges for each entity
Returns:
FilteredEntities: 过滤后的实体集合
FilteredEntities: the filtered entity collection
"""
logger.info(f"开始筛选图谱 {graph_id} 的实体...")
# 获取所有节点
logger.info(f"Starting entity filtering for graph {graph_id}...")
# Get all nodes
all_nodes = self.get_all_nodes(graph_id)
total_count = len(all_nodes)
# 获取所有边(用于后续关联查找)
# Get all edges (used later for adjacency lookup)
all_edges = self.get_all_edges(graph_id) if enrich_with_edges else []
# 构建节点UUID到节点数据的映射
# Build a UUID -> node map
node_map = {n["uuid"]: n for n in all_nodes}
# 筛选符合条件的实体
# Filter entities that meet the criteria
filtered_entities = []
entity_types_found = set()
for node in all_nodes:
labels = node.get("labels", [])
# 筛选逻辑Labels必须包含除"Entity"和"Node"之外的标签
# Filter logic: labels must contain something other than "Entity" and "Node"
custom_labels = [l for l in labels if l not in ["Entity", "Node"]]
if not custom_labels:
# 只有默认标签,跳过
# Only the default labels, skip
continue
# 如果指定了预定义类型,检查是否匹配
# If predefined types are specified, check for a match
if defined_entity_types:
matching_labels = [l for l in custom_labels if l in defined_entity_types]
if not matching_labels:
@ -275,10 +280,10 @@ class ZepEntityReader:
entity_type = matching_labels[0]
else:
entity_type = custom_labels[0]
entity_types_found.add(entity_type)
# 创建实体节点对象
# Build the entity node object
entity = EntityNode(
uuid=node["uuid"],
name=node["name"],
@ -286,12 +291,12 @@ class ZepEntityReader:
summary=node["summary"],
attributes=node["attributes"],
)
# 获取相关边和节点
# Fetch related edges and nodes
if enrich_with_edges:
related_edges = []
related_node_uuids = set()
for edge in all_edges:
if edge["source_node_uuid"] == node["uuid"]:
related_edges.append({
@ -309,10 +314,10 @@ class ZepEntityReader:
"source_node_uuid": edge["source_node_uuid"],
})
related_node_uuids.add(edge["source_node_uuid"])
entity.related_edges = related_edges
# 获取关联节点的基本信息
# Get basic info for related nodes
related_nodes = []
for related_uuid in related_node_uuids:
if related_uuid in node_map:
@ -323,57 +328,57 @@ class ZepEntityReader:
"labels": related_node["labels"],
"summary": related_node.get("summary", ""),
})
entity.related_nodes = related_nodes
filtered_entities.append(entity)
logger.info(f"筛选完成: 总节点 {total_count}, 符合条件 {len(filtered_entities)}, "
f"实体类型: {entity_types_found}")
logger.info(f"Filtering complete: total nodes {total_count}, matching {len(filtered_entities)}, "
f"entity types: {entity_types_found}")
return FilteredEntities(
entities=filtered_entities,
entity_types=entity_types_found,
total_count=total_count,
filtered_count=len(filtered_entities),
)
def get_entity_with_context(
self,
graph_id: str,
self,
graph_id: str,
entity_uuid: str
) -> Optional[EntityNode]:
"""
获取单个实体及其完整上下文边和关联节点带重试机制
Get a single entity with its full context (edges and related nodes, with retry).
Args:
graph_id: 图谱ID
entity_uuid: 实体UUID
graph_id: Graph ID
entity_uuid: Entity UUID
Returns:
EntityNodeNone
EntityNode or None
"""
try:
# Graphiti via adapter
node = self._call_with_retry(
func=lambda: self.client.get_node(node_uuid=entity_uuid),
operation_name=f"获取节点详情(uuid={entity_uuid[:8]}...)"
operation_name=f"get_node_detail(uuid={entity_uuid[:8]}...)"
)
if not node:
return None
# 获取节点的边
# Get the node's edges
edges = self.get_node_edges(entity_uuid)
# 获取所有节点用于关联查找
# Get all nodes for adjacency lookup
all_nodes = self.get_all_nodes(graph_id)
node_map = {n["uuid"]: n for n in all_nodes}
# 处理相关边和节点
# Process related edges and nodes
related_edges = []
related_node_uuids = set()
for edge in edges:
if edge["source_node_uuid"] == entity_uuid:
related_edges.append({
@ -391,8 +396,8 @@ class ZepEntityReader:
"source_node_uuid": edge["source_node_uuid"],
})
related_node_uuids.add(edge["source_node_uuid"])
# 获取关联节点信息
# Get related node info
related_nodes = []
for related_uuid in related_node_uuids:
if related_uuid in node_map:
@ -403,7 +408,7 @@ class ZepEntityReader:
"labels": related_node["labels"],
"summary": related_node.get("summary", ""),
})
return EntityNode(
uuid=getattr(node, 'uuid_', None) or getattr(node, 'uuid', ''),
name=node.name or "",
@ -413,27 +418,27 @@ class ZepEntityReader:
related_edges=related_edges,
related_nodes=related_nodes,
)
except Exception as e:
logger.error(f"获取实体 {entity_uuid} 失败: {str(e)}")
logger.error(f"Failed to fetch entity {entity_uuid}: {str(e)}")
return None
def get_entities_by_type(
self,
graph_id: str,
self,
graph_id: str,
entity_type: str,
enrich_with_edges: bool = True
) -> List[EntityNode]:
"""
获取指定类型的所有实体
Get all entities of a given type.
Args:
graph_id: 图谱ID
entity_type: 实体类型 "Student", "PublicFigure"
enrich_with_edges: 是否获取相关边信息
graph_id: Graph ID
entity_type: Entity type (e.g. "Student", "PublicFigure")
enrich_with_edges: Whether to fetch related edges
Returns:
实体列表
List of entities
"""
result = self.filter_defined_entities(
graph_id=graph_id,

View File

@ -1,6 +1,6 @@
"""
Zep图谱记忆更新服务
将模拟中的Agent活动动态更新到Zep图谱中
Zep Graph Memory Updater Service
Dynamically updates agent activities from the simulation into the Zep graph
"""
import os
@ -29,7 +29,7 @@ logger = get_logger('mirofish.zep_graph_memory_updater')
@dataclass
class AgentActivity:
"""Agent活动记录"""
"""Agent activity record"""
platform: str # twitter / reddit
agent_id: int
agent_name: str
@ -37,15 +37,15 @@ class AgentActivity:
action_args: Dict[str, Any]
round_num: int
timestamp: str
def to_episode_text(self) -> str:
"""
将活动转换为可以发送给Zep的文本描述
采用自然语言描述格式让Zep能够从中提取实体和关系
不添加模拟相关的前缀避免误导图谱更新
Convert the activity into a text description that can be sent to Zep.
Uses a natural-language description format so Zep can extract entities and relations from it.
No simulation-related prefix is added to avoid misleading the graph update.
"""
# 根据不同的动作类型生成不同的描述
# Generate a different description for each action type
action_descriptions = {
"CREATE_POST": self._describe_create_post,
"LIKE_POST": self._describe_like_post,
@ -60,224 +60,226 @@ class AgentActivity:
"SEARCH_USER": self._describe_search_user,
"MUTE": self._describe_mute,
}
describe_func = action_descriptions.get(self.action_type, self._describe_generic)
description = describe_func()
# 直接返回 "agent名称: 活动描述" 格式,不添加模拟前缀
# Return directly in "agent_name: activity description" format, no simulation prefix
return f"{self.agent_name}: {description}"
def _describe_create_post(self) -> str:
content = self.action_args.get("content", "")
if content:
return f"发布了一条帖子:「{content}"
return "发布了一条帖子"
return f"published a post: \"{content}\""
return "published a post"
def _describe_like_post(self) -> str:
"""点赞帖子 - 包含帖子原文和作者信息"""
"""Like a post — includes the post text and author info"""
post_content = self.action_args.get("post_content", "")
post_author = self.action_args.get("post_author_name", "")
if post_content and post_author:
return f"点赞了{post_author}的帖子:「{post_content}"
return f"liked {post_author}'s post: \"{post_content}\""
elif post_content:
return f"点赞了一条帖子:「{post_content}"
return f"liked a post: \"{post_content}\""
elif post_author:
return f"点赞了{post_author}的一条帖子"
return "点赞了一条帖子"
return f"liked a post by {post_author}"
return "liked a post"
def _describe_dislike_post(self) -> str:
"""踩帖子 - 包含帖子原文和作者信息"""
"""Dislike a post — includes the post text and author info"""
post_content = self.action_args.get("post_content", "")
post_author = self.action_args.get("post_author_name", "")
if post_content and post_author:
return f"踩了{post_author}的帖子:「{post_content}"
return f"disliked {post_author}'s post: \"{post_content}\""
elif post_content:
return f"踩了一条帖子:「{post_content}"
return f"disliked a post: \"{post_content}\""
elif post_author:
return f"踩了{post_author}的一条帖子"
return "踩了一条帖子"
return f"disliked a post by {post_author}"
return "disliked a post"
def _describe_repost(self) -> str:
"""转发帖子 - 包含原帖内容和作者信息"""
"""Repost — includes the original post text and author info"""
original_content = self.action_args.get("original_content", "")
original_author = self.action_args.get("original_author_name", "")
if original_content and original_author:
return f"转发了{original_author}的帖子:「{original_content}"
return f"reposted {original_author}'s post: \"{original_content}\""
elif original_content:
return f"转发了一条帖子:「{original_content}"
return f"reposted a post: \"{original_content}\""
elif original_author:
return f"转发了{original_author}的一条帖子"
return "转发了一条帖子"
return f"reposted a post by {original_author}"
return "reposted a post"
def _describe_quote_post(self) -> str:
"""引用帖子 - 包含原帖内容、作者信息和引用评论"""
"""Quote a post — includes the original post text, author info, and quote comment"""
original_content = self.action_args.get("original_content", "")
original_author = self.action_args.get("original_author_name", "")
quote_content = self.action_args.get("quote_content", "") or self.action_args.get("content", "")
base = ""
if original_content and original_author:
base = f"引用了{original_author}的帖子「{original_content}"
base = f"quoted {original_author}'s post \"{original_content}\""
elif original_content:
base = f"引用了一条帖子「{original_content}"
base = f"quoted a post \"{original_content}\""
elif original_author:
base = f"引用了{original_author}的一条帖子"
base = f"quoted a post by {original_author}"
else:
base = "引用了一条帖子"
base = "quoted a post"
if quote_content:
base += f",并评论道:「{quote_content}"
base += f", and commented: \"{quote_content}\""
return base
def _describe_follow(self) -> str:
"""关注用户 - 包含被关注用户的名称"""
"""Follow a user — includes the name of the followed user"""
target_user_name = self.action_args.get("target_user_name", "")
if target_user_name:
return f"关注了用户「{target_user_name}"
return "关注了一个用户"
return f"followed user \"{target_user_name}\""
return "followed a user"
def _describe_create_comment(self) -> str:
"""发表评论 - 包含评论内容和所评论的帖子信息"""
"""Post a comment — includes the comment content and the commented post info"""
content = self.action_args.get("content", "")
post_content = self.action_args.get("post_content", "")
post_author = self.action_args.get("post_author_name", "")
if content:
if post_content and post_author:
return f"{post_author}的帖子「{post_content}」下评论道:「{content}"
return f"commented on {post_author}'s post \"{post_content}\": \"{content}\""
elif post_content:
return f"在帖子「{post_content}」下评论道:「{content}"
return f"commented on the post \"{post_content}\": \"{content}\""
elif post_author:
return f"{post_author}的帖子下评论道:「{content}"
return f"评论道:「{content}"
return "发表了评论"
return f"commented on {post_author}'s post: \"{content}\""
return f"commented: \"{content}\""
return "posted a comment"
def _describe_like_comment(self) -> str:
"""点赞评论 - 包含评论内容和作者信息"""
"""Like a comment — includes the comment text and author info"""
comment_content = self.action_args.get("comment_content", "")
comment_author = self.action_args.get("comment_author_name", "")
if comment_content and comment_author:
return f"点赞了{comment_author}的评论:「{comment_content}"
return f"liked {comment_author}'s comment: \"{comment_content}\""
elif comment_content:
return f"点赞了一条评论:「{comment_content}"
return f"liked a comment: \"{comment_content}\""
elif comment_author:
return f"点赞了{comment_author}的一条评论"
return "点赞了一条评论"
return f"liked a comment by {comment_author}"
return "liked a comment"
def _describe_dislike_comment(self) -> str:
"""踩评论 - 包含评论内容和作者信息"""
"""Dislike a comment — includes the comment text and author info"""
comment_content = self.action_args.get("comment_content", "")
comment_author = self.action_args.get("comment_author_name", "")
if comment_content and comment_author:
return f"踩了{comment_author}的评论:「{comment_content}"
return f"disliked {comment_author}'s comment: \"{comment_content}\""
elif comment_content:
return f"踩了一条评论:「{comment_content}"
return f"disliked a comment: \"{comment_content}\""
elif comment_author:
return f"踩了{comment_author}的一条评论"
return "踩了一条评论"
return f"disliked a comment by {comment_author}"
return "disliked a comment"
def _describe_search(self) -> str:
"""搜索帖子 - 包含搜索关键词"""
"""Search posts — includes the search keyword"""
query = self.action_args.get("query", "") or self.action_args.get("keyword", "")
return f"搜索了「{query}" if query else "进行了搜索"
return f"searched \"{query}\"" if query else "performed a search"
def _describe_search_user(self) -> str:
"""搜索用户 - 包含搜索关键词"""
"""Search users — includes the search keyword"""
query = self.action_args.get("query", "") or self.action_args.get("username", "")
return f"搜索了用户「{query}" if query else "搜索了用户"
return f"searched for user \"{query}\"" if query else "searched for a user"
def _describe_mute(self) -> str:
"""屏蔽用户 - 包含被屏蔽用户的名称"""
"""Mute a user — includes the name of the muted user"""
target_user_name = self.action_args.get("target_user_name", "")
if target_user_name:
return f"屏蔽了用户「{target_user_name}"
return "屏蔽了一个用户"
return f"muted user \"{target_user_name}\""
return "muted a user"
def _describe_generic(self) -> str:
# 对于未知的动作类型,生成通用描述
return f"执行了{self.action_type}操作"
# For unknown action types, generate a generic description
return f"performed {self.action_type} operation"
class ZepGraphMemoryUpdater:
"""
Zep图谱记忆更新器
监控模拟的actions日志文件将新的agent活动实时更新到Zep图谱中
按平台分组每累积BATCH_SIZE条活动后批量发送到Zep
所有有意义的行为都会被更新到Zepaction_args中会包含完整的上下文信息
- 点赞/踩的帖子原文
- 转发/引用的帖子原文
- 关注/屏蔽的用户名
- 点赞/踩的评论原文
Zep graph memory updater.
Monitors the simulation's actions log file and updates new agent activities
into the Zep graph in real time. Activities are grouped by platform, and
a batch is sent to Zep once BATCH_SIZE items have accumulated.
All meaningful behaviors are pushed to Zep; action_args carries the full
context for each one:
- the original text of liked/disliked posts
- the original text of reposted/quoted posts
- the name of followed/muted users
- the original text of liked/disliked comments
"""
# 批量发送大小(每个平台累积多少条后发送)
# Batch send size (how many activities to accumulate per platform before sending)
BATCH_SIZE = 5
# 平台名称映射(用于控制台显示)
# Platform name mapping (for console display)
PLATFORM_DISPLAY_NAMES = {
'twitter': '世界1',
'reddit': '世界2',
'twitter': 'World 1',
'reddit': 'World 2',
}
# 发送间隔(秒),避免请求过快
# Send interval (seconds) to avoid hitting the API too fast
SEND_INTERVAL = 0.5
# 重试配置
# Retry configuration
MAX_RETRIES = 3
RETRY_DELAY = 2 #
RETRY_DELAY = 2 # seconds
def __init__(self, graph_id: str, api_key: Optional[str] = None):
"""
初始化更新器
Initialize the updater.
Args:
graph_id: Zep图谱ID
api_key: Zep API Key可选默认从配置读取
graph_id: Zep graph ID
api_key: Zep API key (optional; read from config by default)
"""
self.graph_id = graph_id
self.api_key = api_key # kept for signature compat; no longer required
from .graphiti_service import get_graphiti_adapter
self.client = get_graphiti_adapter()
# 活动队列
# Activity queue
self._activity_queue: Queue = Queue()
# 按平台分组的活动缓冲区每个平台各自累积到BATCH_SIZE后批量发送
# Per-platform activity buffer (each platform accumulates to BATCH_SIZE then sends)
self._platform_buffers: Dict[str, List[AgentActivity]] = {
'twitter': [],
'reddit': [],
}
self._buffer_lock = threading.Lock()
# 控制标志
# Control flags
self._running = False
self._worker_thread: Optional[threading.Thread] = None
# 统计
self._total_activities = 0 # 实际添加到队列的活动数
self._total_sent = 0 # 成功发送到Zep的批次数
self._total_items_sent = 0 # 成功发送到Zep的活动条数
self._failed_count = 0 # 发送失败的批次数
self._skipped_count = 0 # 被过滤跳过的活动数DO_NOTHING
logger.info(f"ZepGraphMemoryUpdater 初始化完成: graph_id={graph_id}, batch_size={self.BATCH_SIZE}")
# Stats
self._total_activities = 0 # number of activities actually added to the queue
self._total_sent = 0 # number of batches successfully sent to Zep
self._total_items_sent = 0 # number of activities successfully sent to Zep
self._failed_count = 0 # number of failed batches
self._skipped_count = 0 # activities filtered out (DO_NOTHING)
logger.info(f"ZepGraphMemoryUpdater initialization complete: graph_id={graph_id}, batch_size={self.BATCH_SIZE}")
def _get_platform_display_name(self, platform: str) -> str:
"""获取平台的显示名称"""
"""Get the display name of a platform"""
return self.PLATFORM_DISPLAY_NAMES.get(platform.lower(), platform)
def start(self):
"""启动后台工作线程"""
"""Start the background worker thread"""
if self._running:
return
@ -292,19 +294,19 @@ class ZepGraphMemoryUpdater:
name=f"ZepMemoryUpdater-{self.graph_id[:8]}"
)
self._worker_thread.start()
logger.info(f"ZepGraphMemoryUpdater 已启动: graph_id={self.graph_id}")
logger.info(f"ZepGraphMemoryUpdater started: graph_id={self.graph_id}")
def stop(self):
"""停止后台工作线程"""
"""Stop the background worker thread"""
self._running = False
# 发送剩余的活动
# Flush remaining activities
self._flush_remaining()
if self._worker_thread and self._worker_thread.is_alive():
self._worker_thread.join(timeout=10)
logger.info(f"ZepGraphMemoryUpdater 已停止: graph_id={self.graph_id}, "
logger.info(f"ZepGraphMemoryUpdater stopped: graph_id={self.graph_id}, "
f"total_activities={self._total_activities}, "
f"batches_sent={self._total_sent}, "
f"items_sent={self._total_items_sent}, "
@ -313,46 +315,46 @@ class ZepGraphMemoryUpdater:
def add_activity(self, activity: AgentActivity):
"""
添加一个agent活动到队列
所有有意义的行为都会被添加到队列包括
- CREATE_POST发帖
- CREATE_COMMENT评论
- QUOTE_POST引用帖子
- SEARCH_POSTS搜索帖子
- SEARCH_USER搜索用户
- LIKE_POST/DISLIKE_POST点赞/踩帖子
- REPOST转发
- FOLLOW关注
- MUTE屏蔽
- LIKE_COMMENT/DISLIKE_COMMENT点赞/踩评论
action_args中会包含完整的上下文信息如帖子原文用户名等
Add an agent activity to the queue.
All meaningful behaviors are added to the queue, including:
- CREATE_POST (publish a post)
- CREATE_COMMENT (post a comment)
- QUOTE_POST (quote a post)
- SEARCH_POSTS (search posts)
- SEARCH_USER (search users)
- LIKE_POST/DISLIKE_POST (like/dislike a post)
- REPOST (repost)
- FOLLOW (follow)
- MUTE (mute)
- LIKE_COMMENT/DISLIKE_COMMENT (like/dislike a comment)
action_args carries the full context (original post text, username, etc.).
Args:
activity: Agent活动记录
activity: Agent activity record
"""
# 跳过DO_NOTHING类型的活动
# Skip DO_NOTHING activities
if activity.action_type == "DO_NOTHING":
self._skipped_count += 1
return
self._activity_queue.put(activity)
self._total_activities += 1
logger.debug(f"添加活动到Zep队列: {activity.agent_name} - {activity.action_type}")
logger.debug(f"Added activity to Zep queue: {activity.agent_name} - {activity.action_type}")
def add_activity_from_dict(self, data: Dict[str, Any], platform: str):
"""
从字典数据添加活动
Add an activity from a dictionary.
Args:
data: 从actions.jsonl解析的字典数据
platform: 平台名称 (twitter/reddit)
data: Dictionary parsed from actions.jsonl
platform: Platform name (twitter/reddit)
"""
# 跳过事件类型的条目
# Skip entries that are events
if "event_type" in data:
return
activity = AgentActivity(
platform=platform,
agent_id=data.get("agent_id", 0),
@ -362,57 +364,57 @@ class ZepGraphMemoryUpdater:
round_num=data.get("round", 0),
timestamp=data.get("timestamp", datetime.now().isoformat()),
)
self.add_activity(activity)
def _worker_loop(self, locale: str = 'zh'):
"""后台工作循环 - 按平台批量发送活动到Zep"""
"""Background worker loop — batch-sends activities to Zep per platform"""
set_locale(locale)
while self._running or not self._activity_queue.empty():
try:
# 尝试从队列获取活动超时1秒
# Try to fetch an activity from the queue (1s timeout)
try:
activity = self._activity_queue.get(timeout=1)
# 将活动添加到对应平台的缓冲区
# Append the activity to the corresponding platform buffer
platform = activity.platform.lower()
with self._buffer_lock:
if platform not in self._platform_buffers:
self._platform_buffers[platform] = []
self._platform_buffers[platform].append(activity)
# 检查该平台是否达到批量大小
# Check if this platform reached the batch size
if len(self._platform_buffers[platform]) >= self.BATCH_SIZE:
batch = self._platform_buffers[platform][:self.BATCH_SIZE]
self._platform_buffers[platform] = self._platform_buffers[platform][self.BATCH_SIZE:]
# 释放锁后再发送
# Release the lock before sending
self._send_batch_activities(batch, platform)
# 发送间隔,避免请求过快
# Send interval to avoid hitting the API too fast
time.sleep(self.SEND_INTERVAL)
except Empty:
pass
except Exception as e:
logger.error(f"工作循环异常: {e}")
logger.error(f"Worker loop error: {e}")
time.sleep(1)
def _send_batch_activities(self, activities: List[AgentActivity], platform: str):
"""
批量发送活动到Zep图谱合并为一条文本
Batch-send activities to the Zep graph (merged into a single text).
Args:
activities: Agent活动列表
platform: 平台名称
activities: List of agent activities
platform: Platform name
"""
if not activities:
return
# 将多条活动合并为一条文本,用换行分隔
# Merge multiple activities into one text, separated by newlines
episode_texts = [activity.to_episode_text() for activity in activities]
combined_text = "\n".join(episode_texts)
# 带重试的发送
# Send with retry
for attempt in range(self.MAX_RETRIES):
try:
# Graphiti via adapter: one episode per batch (extraction is sync)
@ -420,25 +422,25 @@ class ZepGraphMemoryUpdater:
graph_id=self.graph_id,
episodes=[{"data": combined_text, "type": "text"}],
)
self._total_sent += 1
self._total_items_sent += len(activities)
display_name = self._get_platform_display_name(platform)
logger.info(f"成功批量发送 {len(activities)}{display_name}活动到图谱 {self.graph_id}")
logger.debug(f"批量内容预览: {combined_text[:200]}...")
logger.info(f"Successfully batch-sent {len(activities)} {display_name} activities to graph {self.graph_id}")
logger.debug(f"Batch content preview: {combined_text[:200]}...")
return
except Exception as e:
if attempt < self.MAX_RETRIES - 1:
logger.warning(f"批量发送到Zep失败 (尝试 {attempt + 1}/{self.MAX_RETRIES}): {e}")
logger.warning(f"Batch send to Zep failed (attempt {attempt + 1}/{self.MAX_RETRIES}): {e}")
time.sleep(self.RETRY_DELAY * (attempt + 1))
else:
logger.error(f"批量发送到Zep失败已重试{self.MAX_RETRIES}: {e}")
logger.error(f"Batch send to Zep failed after {self.MAX_RETRIES} retries: {e}")
self._failed_count += 1
def _flush_remaining(self):
"""发送队列和缓冲区中剩余的活动"""
# 首先处理队列中剩余的活动,添加到缓冲区
"""Flush remaining activities in the queue and buffers"""
# First, drain the queue into the buffers
while not self._activity_queue.empty():
try:
activity = self._activity_queue.get_nowait()
@ -449,110 +451,110 @@ class ZepGraphMemoryUpdater:
self._platform_buffers[platform].append(activity)
except Empty:
break
# 然后发送各平台缓冲区中剩余的活动即使不足BATCH_SIZE条
# Then send any remaining activities in the per-platform buffers (even if < BATCH_SIZE)
with self._buffer_lock:
for platform, buffer in self._platform_buffers.items():
if buffer:
display_name = self._get_platform_display_name(platform)
logger.info(f"发送{display_name}平台剩余的 {len(buffer)} 条活动")
logger.info(f"Flushing remaining {len(buffer)} activities from {display_name}")
self._send_batch_activities(buffer, platform)
# 清空所有缓冲区
# Clear all buffers
for platform in self._platform_buffers:
self._platform_buffers[platform] = []
def get_stats(self) -> Dict[str, Any]:
"""获取统计信息"""
"""Get stats"""
with self._buffer_lock:
buffer_sizes = {p: len(b) for p, b in self._platform_buffers.items()}
return {
"graph_id": self.graph_id,
"batch_size": self.BATCH_SIZE,
"total_activities": self._total_activities, # 添加到队列的活动总数
"batches_sent": self._total_sent, # 成功发送的批次数
"items_sent": self._total_items_sent, # 成功发送的活动条数
"failed_count": self._failed_count, # 发送失败的批次数
"skipped_count": self._skipped_count, # 被过滤跳过的活动数DO_NOTHING
"total_activities": self._total_activities, # total activities added to the queue
"batches_sent": self._total_sent, # successfully sent batches
"items_sent": self._total_items_sent, # successfully sent activities
"failed_count": self._failed_count, # failed batches
"skipped_count": self._skipped_count, # activities filtered out (DO_NOTHING)
"queue_size": self._activity_queue.qsize(),
"buffer_sizes": buffer_sizes, # 各平台缓冲区大小
"buffer_sizes": buffer_sizes, # per-platform buffer sizes
"running": self._running,
}
class ZepGraphMemoryManager:
"""
管理多个模拟的Zep图谱记忆更新器
每个模拟可以有自己的更新器实例
Manages Zep graph memory updaters for multiple simulations.
Each simulation can have its own updater instance.
"""
_updaters: Dict[str, ZepGraphMemoryUpdater] = {}
_lock = threading.Lock()
@classmethod
def create_updater(cls, simulation_id: str, graph_id: str) -> ZepGraphMemoryUpdater:
"""
为模拟创建图谱记忆更新器
Create a graph memory updater for a simulation.
Args:
simulation_id: 模拟ID
graph_id: Zep图谱ID
simulation_id: Simulation ID
graph_id: Zep graph ID
Returns:
ZepGraphMemoryUpdater实例
A ZepGraphMemoryUpdater instance
"""
with cls._lock:
# 如果已存在,先停止旧的
# If one already exists, stop it first
if simulation_id in cls._updaters:
cls._updaters[simulation_id].stop()
updater = ZepGraphMemoryUpdater(graph_id)
updater.start()
cls._updaters[simulation_id] = updater
logger.info(f"创建图谱记忆更新器: simulation_id={simulation_id}, graph_id={graph_id}")
logger.info(f"Created graph memory updater: simulation_id={simulation_id}, graph_id={graph_id}")
return updater
@classmethod
def get_updater(cls, simulation_id: str) -> Optional[ZepGraphMemoryUpdater]:
"""获取模拟的更新器"""
"""Get the updater for a simulation"""
return cls._updaters.get(simulation_id)
@classmethod
def stop_updater(cls, simulation_id: str):
"""停止并移除模拟的更新器"""
"""Stop and remove the updater for a simulation"""
with cls._lock:
if simulation_id in cls._updaters:
cls._updaters[simulation_id].stop()
del cls._updaters[simulation_id]
logger.info(f"已停止图谱记忆更新器: simulation_id={simulation_id}")
# 防止 stop_all 重复调用的标志
logger.info(f"Stopped graph memory updater: simulation_id={simulation_id}")
# Flag to prevent repeated stop_all calls
_stop_all_done = False
@classmethod
def stop_all(cls):
"""停止所有更新器"""
# 防止重复调用
"""Stop all updaters"""
# Prevent repeated calls
if cls._stop_all_done:
return
cls._stop_all_done = True
with cls._lock:
if cls._updaters:
for simulation_id, updater in list(cls._updaters.items()):
try:
updater.stop()
except Exception as e:
logger.error(f"停止更新器失败: simulation_id={simulation_id}, error={e}")
logger.error(f"Failed to stop updater: simulation_id={simulation_id}, error={e}")
cls._updaters.clear()
logger.info("已停止所有图谱记忆更新器")
logger.info("Stopped all graph memory updaters")
@classmethod
def get_all_stats(cls) -> Dict[str, Dict[str, Any]]:
"""获取所有更新器的统计信息"""
"""Get stats for all updaters"""
return {
sim_id: updater.get_stats()
sim_id: updater.get_stats()
for sim_id, updater in cls._updaters.items()
}

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
"""
工具模块
Utility module.
"""
from .file_parser import FileParser
@ -7,4 +7,3 @@ from .llm_client import LLMClient
from .locale import t, get_locale, set_locale, get_language_instruction
__all__ = ['FileParser', 'LLMClient', 't', 'get_locale', 'set_locale', 'get_language_instruction']

View File

@ -1,6 +1,6 @@
"""
文件解析工具
支持PDFMarkdownTXT文件的文本提取
File parser utilities
Supports text extraction from PDF, Markdown, and TXT files
"""
import os
@ -10,29 +10,29 @@ from typing import List, Optional
def _read_text_with_fallback(file_path: str) -> str:
"""
读取文本文件UTF-8失败时自动探测编码
Read a text file with UTF-8; auto-detect encoding on failure.
采用多级回退策略
1. 首先尝试 UTF-8 解码
2. 使用 charset_normalizer 检测编码
3. 回退到 chardet 检测编码
4. 最终使用 UTF-8 + errors='replace' 兜底
Uses a multi-level fallback strategy:
1. 1. First try UTF-8 decoding
2. 2. Use charset_normalizer to detect encoding
3. 3. Fall back to chardet
4. 4. Final fallback: UTF-8 with errors="replace"
Args:
file_path: 文件路径
file_path: file path
Returns:
解码后的文本内容
decoded text content
"""
data = Path(file_path).read_bytes()
# 首先尝试 UTF-8
# First try UTF-8
try:
return data.decode('utf-8')
except UnicodeDecodeError:
pass
# 尝试使用 charset_normalizer 检测编码
# 2. Use charset_normalizer to detect encoding
encoding = None
try:
from charset_normalizer import from_bytes
@ -42,7 +42,7 @@ def _read_text_with_fallback(file_path: str) -> str:
except Exception:
pass
# 回退到 chardet
# Fall back to chardet
if not encoding:
try:
import chardet
@ -51,7 +51,7 @@ def _read_text_with_fallback(file_path: str) -> str:
except Exception:
pass
# 最终兜底:使用 UTF-8 + replace
# Final fallback: UTF-8 with replace
if not encoding:
encoding = 'utf-8'
@ -59,20 +59,20 @@ def _read_text_with_fallback(file_path: str) -> str:
class FileParser:
"""文件解析器"""
"""File parser"""
SUPPORTED_EXTENSIONS = {'.pdf', '.md', '.markdown', '.txt'}
@classmethod
def is_supported(cls, file_path: str) -> bool:
"""
检查文件是否为支持的格式
Check whether a file is in a supported format
Args:
file_path: 文件路径
file_path: file path
Returns:
如果文件格式受支持则返回 True
Return True if the file format is supported
"""
suffix = Path(file_path).suffix.lower()
return suffix in cls.SUPPORTED_EXTENSIONS
@ -80,23 +80,23 @@ class FileParser:
@classmethod
def extract_text(cls, file_path: str) -> str:
"""
从文件中提取文本
Extract text from a file
Args:
file_path: 文件路径
file_path: file path
Returns:
提取的文本内容
extracted text content
"""
path = Path(file_path)
if not path.exists():
raise FileNotFoundError(f"文件不存在: {file_path}")
raise FileNotFoundError(f"File does not exist: {file_path}")
suffix = path.suffix.lower()
if suffix not in cls.SUPPORTED_EXTENSIONS:
raise ValueError(f"不支持的文件格式: {suffix}")
raise ValueError(f"Unsupported file format: {suffix}")
if suffix == '.pdf':
return cls._extract_from_pdf(file_path)
@ -105,15 +105,15 @@ class FileParser:
elif suffix == '.txt':
return cls._extract_from_txt(file_path)
raise ValueError(f"无法处理的文件格式: {suffix}")
raise ValueError(f"Cannot process file format: {suffix}")
@staticmethod
def _extract_from_pdf(file_path: str) -> str:
"""从PDF提取文本"""
"""Extract text from PDF"""
try:
import fitz # PyMuPDF
except ImportError:
raise ImportError("需要安装PyMuPDF: pip install PyMuPDF")
raise ImportError("PyMuPDF is required: pip install PyMuPDF")
text_parts = []
with fitz.open(file_path) as doc:
@ -126,24 +126,24 @@ class FileParser:
@staticmethod
def _extract_from_md(file_path: str) -> str:
"""从Markdown提取文本支持自动编码检测"""
"""Extract text from Markdown with auto-encoding detection"""
return _read_text_with_fallback(file_path)
@staticmethod
def _extract_from_txt(file_path: str) -> str:
"""从TXT提取文本支持自动编码检测"""
"""Extract text from TXT with auto-encoding detection"""
return _read_text_with_fallback(file_path)
@classmethod
def extract_from_multiple(cls, file_paths: List[str]) -> str:
"""
从多个文件提取文本并合并
Extract and merge text from multiple files
Args:
file_paths: 文件路径列表
file_paths: list of file paths
Returns:
合并后的文本
merged text
"""
all_texts = []
@ -151,9 +151,9 @@ class FileParser:
try:
text = cls.extract_text(file_path)
filename = Path(file_path).name
all_texts.append(f"=== 文档 {i}: {filename} ===\n{text}")
all_texts.append(f"=== Document {i}: {filename} ===\n{text}")
except Exception as e:
all_texts.append(f"=== 文档 {i}: {file_path} (提取失败: {str(e)}) ===")
all_texts.append(f"=== Document {i}: {file_path} (extraction failed: {str(e)}) ===")
return "\n\n".join(all_texts)
@ -164,15 +164,15 @@ def split_text_into_chunks(
overlap: int = 50
) -> List[str]:
"""
将文本分割成小块
Split text into chunks
Args:
text: 原始文本
chunk_size: 每块的字符数
overlap: 重叠字符数
text: raw text
chunk_size: characters per chunk
overlap: overlap character count
Returns:
文本块列表
list of text chunks
"""
if len(text) <= chunk_size:
return [text] if text.strip() else []
@ -183,9 +183,9 @@ def split_text_into_chunks(
while start < len(text):
end = start + chunk_size
# 尝试在句子边界处分割
# Try to split on sentence boundaries
if end < len(text):
# 查找最近的句子结束符
# Find the nearest sentence-ending punctuation
for sep in ['', '', '', '.\n', '!\n', '?\n', '\n\n', '. ', '! ', '? ']:
last_sep = text[start:end].rfind(sep)
if last_sep != -1 and last_sep > chunk_size * 0.3:
@ -196,7 +196,7 @@ def split_text_into_chunks(
if chunk:
chunks.append(chunk)
# 下一个块从重叠位置开始
# The next chunk starts at the overlap position
start = end - overlap if end < len(text) else len(text)
return chunks

View File

@ -1,6 +1,6 @@
"""
LLM客户端封装
统一使用OpenAI格式调用
LLM client wrapper
Unified OpenAI-format invocation
"""
import json
@ -12,7 +12,7 @@ from ..config import Config
class LLMClient:
"""LLM客户端"""
"""LLM client"""
def __init__(
self,
@ -25,7 +25,7 @@ class LLMClient:
self.model = model or Config.LLM_MODEL_NAME
if not self.api_key:
raise ValueError("LLM_API_KEY 未配置")
raise ValueError("LLM_API_KEY is not configured")
self.client = OpenAI(
api_key=self.api_key,
@ -40,16 +40,16 @@ class LLMClient:
response_format: Optional[Dict] = None
) -> str:
"""
发送聊天请求
Send a chat completion request
Args:
messages: 消息列表
temperature: 温度参数
max_tokens: 最大token数
response_format: 响应格式如JSON模式
messages: message list
temperature: sampling temperature
max_tokens: max tokens
response_format: response formate.g. JSON mode
Returns:
模型响应文本
model response text
"""
kwargs = {
"model": self.model,
@ -63,7 +63,7 @@ class LLMClient:
response = self.client.chat.completions.create(**kwargs)
content = response.choices[0].message.content
# 部分模型如MiniMax M2.5会在content中包含<think>思考内容,需要移除
# Some models (e.g. MiniMax M2.5) embed <think>...</think> reasoning in content — strip it
content = re.sub(r'<think>[\s\S]*?</think>', '', content).strip()
return content
@ -74,16 +74,16 @@ class LLMClient:
max_tokens: int = 16384
) -> Dict[str, Any]:
"""
发送聊天请求并返回JSON
Send a chat completion requestand parse the response as JSON
Args:
messages: 消息列表
temperature: 温度参数
max_tokens: 最大token数 (默认 16384 MiniMax M3 在本体/配置文件
这类长 JSON 模式下,4096 tokens 经常会把 JSON 截断在中间)
messages: message list
temperature: sampling temperature
max_tokens: max tokens (default 16384 MiniMax M3 in ontology / config
long-JSON mode 4096 tokens frequently truncates the JSON in the middle)
Returns:
解析后的JSON对象
parsed JSON object
"""
response = self.chat(
messages=messages,
@ -91,7 +91,7 @@ class LLMClient:
max_tokens=max_tokens,
response_format={"type": "json_object"}
)
# 清理markdown代码块标记
# Strip markdown code-block fences
# MiniMax M3 ignores response_format=json_object and wraps JSON in
# markdown fences. Be aggressive about stripping them, including the
# ```json\n and trailing ``` even when surrounded by other content.
@ -111,5 +111,5 @@ class LLMClient:
try:
return json.loads(cleaned_response)
except json.JSONDecodeError:
raise ValueError(f"LLM返回的JSON格式无效: {cleaned_response[:200]}")
raise ValueError(f"LLM returned invalid JSON: {cleaned_response[:200]}")

View File

@ -1,3 +1,9 @@
"""
Locale / i18n helpers used by the backend. Loads translation files from
``/opt/data/work/MiroFish/locales`` and exposes ``t(key)`` lookups plus a
per-thread locale setting.
"""
import json
import os
import threading
@ -64,6 +70,10 @@ def t(key: str, **kwargs) -> str:
def get_language_instruction() -> str:
"""
Return the LLM language-injection string for the active locale.
Falls back to a Chinese instruction if no configuration is found.
"""
locale = get_locale()
lang_config = _languages.get(locale, _languages.get('zh', {}))
return lang_config.get('llmInstruction', '请使用中文回答。')
return lang_config.get('llmInstruction', 'Please reply in English.')

View File

@ -1,6 +1,6 @@
"""
日志配置模块
提供统一的日志管理同时输出到控制台和文件
Logging configuration module
Provides unified log management, output to both console and file
"""
import os
@ -12,47 +12,47 @@ from logging.handlers import RotatingFileHandler
def _ensure_utf8_stdout():
"""
确保 stdout/stderr 使用 UTF-8 编码
解决 Windows 控制台中文乱码问题
Ensure stdout/stderr use UTF-8
Work around Windows console garbled Chinese
"""
if sys.platform == 'win32':
# Windows 下重新配置标准输出为 UTF-8
# Reconfigure stdout to UTF-8 on Windows
if hasattr(sys.stdout, 'reconfigure'):
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
if hasattr(sys.stderr, 'reconfigure'):
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
# 日志目录
# Log directory
LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'logs')
def setup_logger(name: str = 'mirofish', level: int = logging.DEBUG) -> logging.Logger:
"""
设置日志器
Set up a logger
Args:
name: 日志器名称
level: 日志级别
name: logger name
level: log level
Returns:
配置好的日志器
configured logger
"""
# 确保日志目录存在
# Ensure log directory exists
os.makedirs(LOG_DIR, exist_ok=True)
# 创建日志器
# Create logger
logger = logging.getLogger(name)
logger.setLevel(level)
# 阻止日志向上传播到根 logger避免重复输出
# Prevent logs from propagating to the root logger (avoid double output)
logger.propagate = False
# 如果已经有处理器,不重复添加
# If a handler is already attached, do not add another
if logger.handlers:
return logger
# 日志格式
# Log format
detailed_formatter = logging.Formatter(
'[%(asctime)s] %(levelname)s [%(name)s.%(funcName)s:%(lineno)d] %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
@ -63,7 +63,7 @@ def setup_logger(name: str = 'mirofish', level: int = logging.DEBUG) -> logging.
datefmt='%H:%M:%S'
)
# 1. 文件处理器 - 详细日志(按日期命名,带轮转)
# 1. File handler — verbose log (date-named, with rotation)
log_filename = datetime.now().strftime('%Y-%m-%d') + '.log'
file_handler = RotatingFileHandler(
os.path.join(LOG_DIR, log_filename),
@ -74,14 +74,14 @@ def setup_logger(name: str = 'mirofish', level: int = logging.DEBUG) -> logging.
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(detailed_formatter)
# 2. 控制台处理器 - 简洁日志INFO及以上
# 确保 Windows 下使用 UTF-8 编码,避免中文乱码
# 2. Console handler — concise log (INFO and above)
# Use UTF-8 on Windows to avoid garbled output
_ensure_utf8_stdout()
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(logging.INFO)
console_handler.setFormatter(simple_formatter)
# 添加处理器
# Add handler
logger.addHandler(file_handler)
logger.addHandler(console_handler)
@ -90,13 +90,13 @@ def setup_logger(name: str = 'mirofish', level: int = logging.DEBUG) -> logging.
def get_logger(name: str = 'mirofish') -> logging.Logger:
"""
获取日志器如果不存在则创建
Get a logger (creating it if it does not exist)
Args:
name: 日志器名称
name: logger name
Returns:
日志器实例
logger instance
"""
logger = logging.getLogger(name)
if not logger.handlers:
@ -104,11 +104,11 @@ def get_logger(name: str = 'mirofish') -> logging.Logger:
return logger
# 创建默认日志器
# Create the default logger
logger = setup_logger()
# 便捷方法
# Convenience methods
def debug(msg: str, *args, **kwargs) -> None:
logger.debug(msg, *args, **kwargs)

View File

@ -1,6 +1,6 @@
"""
API调用重试机制
用于处理LLM等外部API调用的重试逻辑
API-call retry mechanism
Handles retry logic for external API calls (e.g. LLM)
"""
import time
@ -22,16 +22,16 @@ def retry_with_backoff(
on_retry: Optional[Callable[[Exception, int], None]] = None
):
"""
带指数退避的重试装饰器
Retry decorator with exponential backoff
Args:
max_retries: 最大重试次数
initial_delay: 初始延迟
max_delay: 最大延迟
backoff_factor: 退避因子
jitter: 是否添加随机抖动
exceptions: 需要重试的异常类型
on_retry: 重试时的回调函数 (exception, retry_count)
max_retries: maximum retry count
initial_delay: initial delay (seconds)
max_delay: maximum delay (seconds)
backoff_factor: backoff factor
jitter: whether to add random jitter
exceptions: exception types to retry on
on_retry: callback invoked on each retry (exception, retry_count)
Usage:
@retry_with_backoff(max_retries=3)
@ -52,17 +52,17 @@ def retry_with_backoff(
last_exception = e
if attempt == max_retries:
logger.error(f"函数 {func.__name__}{max_retries} 次重试后仍失败: {str(e)}")
logger.error(f"Function {func.__name__} still failed after {max_retries} retries: {str(e)}")
raise
# 计算延迟
# Calculate delay
current_delay = min(delay, max_delay)
if jitter:
current_delay = current_delay * (0.5 + random.random())
logger.warning(
f"函数 {func.__name__}{attempt + 1} 次尝试失败: {str(e)}, "
f"{current_delay:.1f}秒后重试..."
f"function {func.__name__} attempt {attempt + 1} failed: {str(e)}, "
f"{current_delay:.1f}s, retrying..."
)
if on_retry:
@ -87,7 +87,7 @@ def retry_with_backoff_async(
on_retry: Optional[Callable[[Exception, int], None]] = None
):
"""
异步版本的重试装饰器
Async version of the retry decorator
"""
import asyncio
@ -105,7 +105,7 @@ def retry_with_backoff_async(
last_exception = e
if attempt == max_retries:
logger.error(f"异步函数 {func.__name__}{max_retries} 次重试后仍失败: {str(e)}")
logger.error(f"async function {func.__name__} still failed after {max_retries} retries: {str(e)}")
raise
current_delay = min(delay, max_delay)
@ -113,8 +113,8 @@ def retry_with_backoff_async(
current_delay = current_delay * (0.5 + random.random())
logger.warning(
f"异步函数 {func.__name__}{attempt + 1} 次尝试失败: {str(e)}, "
f"{current_delay:.1f}秒后重试..."
f"async function {func.__name__} attempt {attempt + 1} failed: {str(e)}, "
f"{current_delay:.1f}s, retrying..."
)
if on_retry:
@ -131,7 +131,7 @@ def retry_with_backoff_async(
class RetryableAPIClient:
"""
可重试的API客户端封装
Retry-capable API client wrapper
"""
def __init__(
@ -154,16 +154,16 @@ class RetryableAPIClient:
**kwargs
) -> Any:
"""
执行函数调用并在失败时重试
Execute a function with retry on failure
Args:
func: 要调用的函数
*args: 函数参数
exceptions: 需要重试的异常类型
**kwargs: 函数关键字参数
func: function to call
*args: function positional args
exceptions: exception types to retry on
**kwargs: function keyword args
Returns:
函数返回值
function return value
"""
last_exception = None
delay = self.initial_delay
@ -176,15 +176,15 @@ class RetryableAPIClient:
last_exception = e
if attempt == self.max_retries:
logger.error(f"API调用在 {self.max_retries} 次重试后仍失败: {str(e)}")
logger.error(f"API call still failed after {self.max_retries} retries: {str(e)}")
raise
current_delay = min(delay, self.max_delay)
current_delay = current_delay * (0.5 + random.random())
logger.warning(
f"API调用第 {attempt + 1} 次尝试失败: {str(e)}, "
f"{current_delay:.1f}秒后重试..."
f"API callattempt {attempt + 1} failed: {str(e)}, "
f"{current_delay:.1f}s, retrying..."
)
time.sleep(current_delay)
@ -200,16 +200,16 @@ class RetryableAPIClient:
continue_on_failure: bool = True
) -> Tuple[list, list]:
"""
批量调用并对每个失败项单独重试
Batch call with per-item retry on failure
Args:
items: 要处理的项目列表
process_func: 处理函数接收单个item作为参数
exceptions: 需要重试的异常类型
continue_on_failure: 单项失败后是否继续处理其他项
items: list of items to process
process_func: processing function, takes a single item
exceptions: exception types to retry on
continue_on_failure: whether to keep going after an individual item fails
Returns:
(成功结果列表, 失败项列表)
(list of successful results, list of failed items)
"""
results = []
failures = []
@ -224,7 +224,7 @@ class RetryableAPIClient:
results.append(result)
except Exception as e:
logger.error(f"处理第 {idx + 1} 项失败: {str(e)}")
logger.error(f"Processing item {idx + 1} failed: {str(e)}")
failures.append({
"index": idx,
"item": item,

View File

@ -1,21 +1,21 @@
"""
MiroFish Backend 启动入口
MiroFish backend entry point
"""
import os
import sys
# 解决 Windows 控制台中文乱码问题:在所有导入之前设置 UTF-8 编码
# Work around Windows console garbled Chinese outputSet UTF-8 encoding before any imports
if sys.platform == 'win32':
# 设置环境变量确保 Python 使用 UTF-8
# Set environment variable to ensure Python uses UTF-8
os.environ.setdefault('PYTHONIOENCODING', 'utf-8')
# 重新配置标准输出流为 UTF-8
# Reconfigure stdout to UTF-8
if hasattr(sys.stdout, 'reconfigure'):
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
if hasattr(sys.stderr, 'reconfigure'):
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
# 添加项目根目录到路径
# Add the project root to sys.path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from app import create_app
@ -23,25 +23,25 @@ from app.config import Config
def main():
"""主函数"""
# 验证配置
"""Main entry point"""
# Validate configuration
errors = Config.validate()
if errors:
print("配置错误:")
print("Configuration error:")
for err in errors:
print(f" - {err}")
print("\n请检查 .env 文件中的配置")
print("\nPlease check the configuration in .env")
sys.exit(1)
# 创建应用
# Create app
app = create_app()
# 获取运行配置
# Read runtime config
host = os.environ.get('FLASK_HOST', '0.0.0.0')
port = int(os.environ.get('FLASK_PORT', 5001))
debug = Config.DEBUG
# 启动服务
# Start the server
app.run(host=host, port=port, debug=debug, threaded=True)

View File

@ -1,15 +1,15 @@
"""
动作日志记录器
用于记录OASIS模拟中每个Agent的动作供后端监控使用
Action logger
Records every Agent's actions in the OASIS simulation for backend monitoring.
日志结构:
Log structure:
sim_xxx/
twitter/
actions.jsonl # Twitter 平台动作日志
actions.jsonl # Twitter platform action log
reddit/
actions.jsonl # Reddit 平台动作日志
simulation.log # 主模拟进程日志
run_state.json # 运行状态API 查询用)
actions.jsonl # Reddit platform action log
simulation.log # Main simulation process log
run_state.json # Run state (used for API queries)
"""
import json
@ -20,26 +20,26 @@ from typing import Dict, Any, Optional
class PlatformActionLogger:
"""单平台动作日志记录器"""
"""Single-platform action logger"""
def __init__(self, platform: str, base_dir: str):
"""
初始化日志记录器
Initialize the logger.
Args:
platform: 平台名称 (twitter/reddit)
base_dir: 模拟目录的基础路径
platform: Platform name (twitter/reddit)
base_dir: Base path of the simulation directory
"""
self.platform = platform
self.base_dir = base_dir
self.log_dir = os.path.join(base_dir, platform)
self.log_path = os.path.join(self.log_dir, "actions.jsonl")
self._ensure_dir()
def _ensure_dir(self):
"""确保目录存在"""
"""Ensure the directory exists"""
os.makedirs(self.log_dir, exist_ok=True)
def log_action(
self,
round_num: int,
@ -50,7 +50,7 @@ class PlatformActionLogger:
result: Optional[str] = None,
success: bool = True
):
"""记录一个动作"""
"""Record a single action"""
entry = {
"round": round_num,
"timestamp": datetime.now().isoformat(),
@ -66,31 +66,31 @@ class PlatformActionLogger:
f.write(json.dumps(entry, ensure_ascii=False) + '\n')
def log_round_start(self, round_num: int, simulated_hour: int):
"""记录轮次开始"""
"""Record round start"""
entry = {
"round": round_num,
"timestamp": datetime.now().isoformat(),
"event_type": "round_start",
"simulated_hour": simulated_hour,
}
with open(self.log_path, 'a', encoding='utf-8') as f:
f.write(json.dumps(entry, ensure_ascii=False) + '\n')
def log_round_end(self, round_num: int, actions_count: int):
"""记录轮次结束"""
"""Record round end"""
entry = {
"round": round_num,
"timestamp": datetime.now().isoformat(),
"event_type": "round_end",
"actions_count": actions_count,
}
with open(self.log_path, 'a', encoding='utf-8') as f:
f.write(json.dumps(entry, ensure_ascii=False) + '\n')
def log_simulation_start(self, config: Dict[str, Any]):
"""记录模拟开始"""
"""Record simulation start"""
entry = {
"timestamp": datetime.now().isoformat(),
"event_type": "simulation_start",
@ -98,12 +98,12 @@ class PlatformActionLogger:
"total_rounds": config.get("time_config", {}).get("total_simulation_hours", 72) * 2,
"agents_count": len(config.get("agent_configs", [])),
}
with open(self.log_path, 'a', encoding='utf-8') as f:
f.write(json.dumps(entry, ensure_ascii=False) + '\n')
def log_simulation_end(self, total_rounds: int, total_actions: int):
"""记录模拟结束"""
"""Record simulation end"""
entry = {
"timestamp": datetime.now().isoformat(),
"event_type": "simulation_end",
@ -118,35 +118,35 @@ class PlatformActionLogger:
class SimulationLogManager:
"""
模拟日志管理器
统一管理所有日志文件按平台分离
Simulation log manager
Unifies management of all log files, separated by platform.
"""
def __init__(self, simulation_dir: str):
"""
初始化日志管理器
Initialize the log manager.
Args:
simulation_dir: 模拟目录路径
simulation_dir: Path of the simulation directory
"""
self.simulation_dir = simulation_dir
self.twitter_logger: Optional[PlatformActionLogger] = None
self.reddit_logger: Optional[PlatformActionLogger] = None
self._main_logger: Optional[logging.Logger] = None
# 设置主日志
# Set up the main logger
self._setup_main_logger()
def _setup_main_logger(self):
"""设置主模拟日志"""
"""Set up the main simulation logger"""
log_path = os.path.join(self.simulation_dir, "simulation.log")
# 创建 logger
# Create the logger
self._main_logger = logging.getLogger(f"simulation.{os.path.basename(self.simulation_dir)}")
self._main_logger.setLevel(logging.INFO)
self._main_logger.handlers.clear()
# 文件处理器
# File handler
file_handler = logging.FileHandler(log_path, encoding='utf-8', mode='w')
file_handler.setLevel(logging.INFO)
file_handler.setFormatter(logging.Formatter(
@ -154,8 +154,8 @@ class SimulationLogManager:
datefmt='%Y-%m-%d %H:%M:%S'
))
self._main_logger.addHandler(file_handler)
# 控制台处理器
# Console handler
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
console_handler.setFormatter(logging.Formatter(
@ -163,23 +163,23 @@ class SimulationLogManager:
datefmt='%H:%M:%S'
))
self._main_logger.addHandler(console_handler)
self._main_logger.propagate = False
def get_twitter_logger(self) -> PlatformActionLogger:
"""获取 Twitter 平台日志记录器"""
"""Get the Twitter platform logger"""
if self.twitter_logger is None:
self.twitter_logger = PlatformActionLogger("twitter", self.simulation_dir)
return self.twitter_logger
def get_reddit_logger(self) -> PlatformActionLogger:
"""获取 Reddit 平台日志记录器"""
"""Get the Reddit platform logger"""
if self.reddit_logger is None:
self.reddit_logger = PlatformActionLogger("reddit", self.simulation_dir)
return self.reddit_logger
def log(self, message: str, level: str = "info"):
"""记录主日志"""
"""Record into the main log"""
if self._main_logger:
getattr(self._main_logger, level.lower(), self._main_logger.info)(message)
@ -196,12 +196,12 @@ class SimulationLogManager:
self.log(message, "debug")
# ============ 兼容旧接口 ============
# ============ Backward-compat interface ============
class ActionLogger:
"""
动作日志记录器兼容旧接口
建议使用 SimulationLogManager 代替
Action logger (backward-compat interface)
Prefer using SimulationLogManager instead.
"""
def __init__(self, log_path: str):
@ -288,12 +288,12 @@ class ActionLogger:
f.write(json.dumps(entry, ensure_ascii=False) + '\n')
# 全局日志实例(兼容旧接口)
# Global log instance (backward-compat interface)
_global_logger: Optional[ActionLogger] = None
def get_logger(log_path: Optional[str] = None) -> ActionLogger:
"""获取全局日志实例(兼容旧接口)"""
"""Get the global log instance (backward-compat interface)"""
global _global_logger
if log_path:

File diff suppressed because it is too large Load Diff

View File

@ -1,16 +1,16 @@
"""
OASIS Reddit模拟预设脚本
此脚本读取配置文件中的参数来执行模拟实现全程自动化
OASIS Reddit simulation preset script
This script reads parameters from a config file to run simulations, fully automated end-to-end
功能特性:
- 完成模拟后不立即关闭环境进入等待命令模式
- 支持通过IPC接收Interview命令
- 支持单个Agent采访和批量采访
- 支持远程关闭环境命令
Features:
- After completing the simulation, do not close the environment immediately; enter wait-for-commands mode
- Support receiving Interview commands via IPC
- Support single-Agent and batch interviews
- Support remote environment-close commands
使用方式:
Usage:
python run_reddit_simulation.py --config /path/to/simulation_config.json
python run_reddit_simulation.py --config /path/to/simulation_config.json --no-wait # 完成后立即关闭
python run_reddit_simulation.py --config /path/to/simulation_config.json --no-wait # close immediately after completion
"""
import argparse
@ -25,18 +25,18 @@ import sqlite3
from datetime import datetime
from typing import Dict, Any, List, Optional
# 全局变量:用于信号处理
# Global variables: for signal handling
_shutdown_event = None
_cleanup_done = False
# 添加项目路径
# Add project paths
_scripts_dir = os.path.dirname(os.path.abspath(__file__))
_backend_dir = os.path.abspath(os.path.join(_scripts_dir, '..'))
_project_root = os.path.abspath(os.path.join(_backend_dir, '..'))
sys.path.insert(0, _scripts_dir)
sys.path.insert(0, _backend_dir)
# 加载项目根目录的 .env 文件(包含 LLM_API_KEY 等配置)
# Load project-root .env file (contains LLM_API_KEY etc.)
from dotenv import load_dotenv
_env_file = os.path.join(_project_root, '.env')
if os.path.exists(_env_file):
@ -51,7 +51,7 @@ import re
class UnicodeFormatter(logging.Formatter):
"""自定义格式化器,将 Unicode 转义序列转换为可读字符"""
"""Custom formatter that converts Unicode escape sequences into readable characters"""
UNICODE_ESCAPE_PATTERN = re.compile(r'\\u([0-9a-fA-F]{4})')
@ -68,24 +68,24 @@ class UnicodeFormatter(logging.Formatter):
class MaxTokensWarningFilter(logging.Filter):
"""过滤掉 camel-ai 关于 max_tokens 的警告(我们故意不设置 max_tokens让模型自行决定"""
"""Filter out camel-ai warnings about max_tokens (we intentionally do not set max_tokens, letting the model decide)"""
def filter(self, record):
# 过滤掉包含 max_tokens 警告的日志
# Filter out log records containing the max_tokens warning
if "max_tokens" in record.getMessage() and "Invalid or missing" in record.getMessage():
return False
return True
# 在模块加载时立即添加过滤器,确保在 camel 代码执行前生效
# Add the filter at module load time, ensuring it takes effect before camel code runs
logging.getLogger().addFilter(MaxTokensWarningFilter())
def setup_oasis_logging(log_dir: str):
"""配置 OASIS 的日志,使用固定名称的日志文件"""
"""Configure OASIS logging using fixed-name log files"""
os.makedirs(log_dir, exist_ok=True)
# 清理旧的日志文件
# Clean up old log files
for f in os.listdir(log_dir):
old_log = os.path.join(log_dir, f)
if os.path.isfile(old_log) and f.endswith('.log'):
@ -126,25 +126,25 @@ try:
generate_reddit_agent_graph
)
except ImportError as e:
print(f"错误: 缺少依赖 {e}")
print("请先安装: pip install oasis-ai camel-ai")
print(f"Error: missing dependency {e}")
print("Please install first: pip install oasis-ai camel-ai")
sys.exit(1)
# IPC相关常量
# IPC-related constants
IPC_COMMANDS_DIR = "ipc_commands"
IPC_RESPONSES_DIR = "ipc_responses"
ENV_STATUS_FILE = "env_status.json"
class CommandType:
"""命令类型常量"""
"""Command type constants"""
INTERVIEW = "interview"
BATCH_INTERVIEW = "batch_interview"
CLOSE_ENV = "close_env"
class IPCHandler:
"""IPC命令处理器"""
"""IPC command handler"""
def __init__(self, simulation_dir: str, env, agent_graph):
self.simulation_dir = simulation_dir
@ -155,12 +155,12 @@ class IPCHandler:
self.status_file = os.path.join(simulation_dir, ENV_STATUS_FILE)
self._running = True
# 确保目录存在
# Ensure the directory exists
os.makedirs(self.commands_dir, exist_ok=True)
os.makedirs(self.responses_dir, exist_ok=True)
def update_status(self, status: str):
"""更新环境状态"""
"""Update environment status"""
with open(self.status_file, 'w', encoding='utf-8') as f:
json.dump({
"status": status,
@ -168,11 +168,11 @@ class IPCHandler:
}, f, ensure_ascii=False, indent=2)
def poll_command(self) -> Optional[Dict[str, Any]]:
"""轮询获取待处理命令"""
"""Poll for pending commands"""
if not os.path.exists(self.commands_dir):
return None
# 获取命令文件(按时间排序)
# Get command files (sorted by time)
command_files = []
for filename in os.listdir(self.commands_dir):
if filename.endswith('.json'):
@ -191,7 +191,7 @@ class IPCHandler:
return None
def send_response(self, command_id: str, status: str, result: Dict = None, error: str = None):
"""发送响应"""
"""Send response"""
response = {
"command_id": command_id,
"status": status,
@ -204,7 +204,7 @@ class IPCHandler:
with open(response_file, 'w', encoding='utf-8') as f:
json.dump(response, f, ensure_ascii=False, indent=2)
# 删除命令文件
# Delete command file
command_file = os.path.join(self.commands_dir, f"{command_id}.json")
try:
os.remove(command_file)
@ -213,49 +213,49 @@ class IPCHandler:
async def handle_interview(self, command_id: str, agent_id: int, prompt: str) -> bool:
"""
处理单个Agent采访命令
Handle single-Agent interview command
Returns:
True 表示成功False 表示失败
True on success, False on failure
"""
try:
# 获取Agent
# Get Agent
agent = self.agent_graph.get_agent(agent_id)
# 创建Interview动作
# Create Interview action
interview_action = ManualAction(
action_type=ActionType.INTERVIEW,
action_args={"prompt": prompt}
)
# 执行Interview
# Execute Interview
actions = {agent: interview_action}
await self.env.step(actions)
# 从数据库获取结果
# Get result from database
result = self._get_interview_result(agent_id)
self.send_response(command_id, "completed", result=result)
print(f" Interview完成: agent_id={agent_id}")
print(f" Interview completed: agent_id={agent_id}")
return True
except Exception as e:
error_msg = str(e)
print(f" Interview失败: agent_id={agent_id}, error={error_msg}")
print(f" Interview failed: agent_id={agent_id}, error={error_msg}")
self.send_response(command_id, "failed", error=error_msg)
return False
async def handle_batch_interview(self, command_id: str, interviews: List[Dict]) -> bool:
"""
处理批量采访命令
Handle batch interview command
Args:
interviews: [{"agent_id": int, "prompt": str}, ...]
"""
try:
# 构建动作字典
# Build action dict
actions = {}
agent_prompts = {} # 记录每个agent的prompt
agent_prompts = {} # Record each agent's prompt
for interview in interviews:
agent_id = interview.get("agent_id")
@ -269,16 +269,16 @@ class IPCHandler:
)
agent_prompts[agent_id] = prompt
except Exception as e:
print(f" 警告: 无法获取Agent {agent_id}: {e}")
print(f" Warning: unable to fetch Agent {agent_id}: {e}")
if not actions:
self.send_response(command_id, "failed", error="没有有效的Agent")
self.send_response(command_id, "failed", error="No valid agents")
return False
# 执行批量Interview
# Execute batch Interview
await self.env.step(actions)
# 获取所有结果
# Get all results
results = {}
for agent_id in agent_prompts.keys():
result = self._get_interview_result(agent_id)
@ -288,17 +288,17 @@ class IPCHandler:
"interviews_count": len(results),
"results": results
})
print(f" 批量Interview完成: {len(results)} 个Agent")
print(f" Batch Interview completed: {len(results)} agents")
return True
except Exception as e:
error_msg = str(e)
print(f" 批量Interview失败: {error_msg}")
print(f" Batch Interview failed: {error_msg}")
self.send_response(command_id, "failed", error=error_msg)
return False
def _get_interview_result(self, agent_id: int) -> Dict[str, Any]:
"""从数据库获取最新的Interview结果"""
"""Get the latest Interview results from the database"""
db_path = os.path.join(self.simulation_dir, "reddit_simulation.db")
result = {
@ -314,7 +314,7 @@ class IPCHandler:
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# 查询最新的Interview记录
# Query the latest Interview record
cursor.execute("""
SELECT user_id, info, created_at
FROM trace
@ -336,16 +336,16 @@ class IPCHandler:
conn.close()
except Exception as e:
print(f" 读取Interview结果失败: {e}")
print(f" Failed to read Interview results: {e}")
return result
async def process_commands(self) -> bool:
"""
处理所有待处理命令
Handle all pending commands
Returns:
True 表示继续运行False 表示应该退出
True to continue running, False to exit
"""
command = self.poll_command()
if not command:
@ -355,7 +355,7 @@ class IPCHandler:
command_type = command.get("command_type")
args = command.get("args", {})
print(f"\n收到IPC命令: {command_type}, id={command_id}")
print(f"\nReceived IPC command: {command_type}, id={command_id}")
if command_type == CommandType.INTERVIEW:
await self.handle_interview(
@ -373,19 +373,19 @@ class IPCHandler:
return True
elif command_type == CommandType.CLOSE_ENV:
print("收到关闭环境命令")
self.send_response(command_id, "completed", result={"message": "环境即将关闭"})
print("Received close-environment command")
self.send_response(command_id, "completed", result={"message": "Environment is about to close"})
return False
else:
self.send_response(command_id, "failed", error=f"未知命令类型: {command_type}")
self.send_response(command_id, "failed", error=f"Unknown command type: {command_type}")
return True
class RedditSimulationRunner:
"""Reddit模拟运行器"""
"""Reddit simulation runner"""
# Reddit可用动作不包含INTERVIEWINTERVIEW只能通过ManualAction手动触发
# Reddit available actions (does not include INTERVIEW; INTERVIEW can only be triggered manually via ManualAction)
AVAILABLE_ACTIONS = [
ActionType.LIKE_POST,
ActionType.DISLIKE_POST,
@ -404,11 +404,11 @@ class RedditSimulationRunner:
def __init__(self, config_path: str, wait_for_commands: bool = True):
"""
初始化模拟运行器
Initialize the simulation runner
Args:
config_path: 配置文件路径 (simulation_config.json)
wait_for_commands: 模拟完成后是否等待命令默认True
config_path: Configuration file path (simulation_config.json)
wait_for_commands: Whether to wait for commands after the simulation completes (default True)
"""
self.config_path = config_path
self.config = self._load_config()
@ -419,47 +419,47 @@ class RedditSimulationRunner:
self.ipc_handler = None
def _load_config(self) -> Dict[str, Any]:
"""加载配置文件"""
"""Load configuration file"""
with open(self.config_path, 'r', encoding='utf-8') as f:
return json.load(f)
def _get_profile_path(self) -> str:
"""获取Profile文件路径"""
"""Get Profile file path"""
return os.path.join(self.simulation_dir, "reddit_profiles.json")
def _get_db_path(self) -> str:
"""获取数据库路径"""
"""Get database path"""
return os.path.join(self.simulation_dir, "reddit_simulation.db")
def _create_model(self):
"""
创建LLM模型
Create the LLM model
统一使用项目根目录 .env 文件中的配置优先级最高
- LLM_API_KEY: API密钥
- LLM_BASE_URL: API基础URL
- LLM_MODEL_NAME: 模型名称
Use config from project-root .env file (highest priority):
- LLM_API_KEY: API key
- LLM_BASE_URL: API base URL
- LLM_MODEL_NAME: Model name
"""
# 优先从 .env 读取配置
# Prefer reading config from .env
llm_api_key = os.environ.get("LLM_API_KEY", "")
llm_base_url = os.environ.get("LLM_BASE_URL", "")
llm_model = os.environ.get("LLM_MODEL_NAME", "")
# 如果 .env 中没有,则使用 config 作为备用
# If .env has no value, fall back to config
if not llm_model:
llm_model = self.config.get("llm_model", "gpt-4o-mini")
# 设置 camel-ai 所需的环境变量
# Set environment variables required by camel-ai
if llm_api_key:
os.environ["OPENAI_API_KEY"] = llm_api_key
if not os.environ.get("OPENAI_API_KEY"):
raise ValueError("缺少 API Key 配置,请在项目根目录 .env 文件中设置 LLM_API_KEY")
raise ValueError("Missing API Key config, please set LLM_API_KEY in the project root .env file")
if llm_base_url:
os.environ["OPENAI_API_BASE_URL"] = llm_base_url
print(f"LLM配置: model={llm_model}, base_url={llm_base_url[:40] if llm_base_url else '默认'}...")
print(f"LLM config: model={llm_model}, base_url={llm_base_url[:40] if llm_base_url else 'default'}...")
return ModelFactory.create(
model_platform=ModelPlatformType.OPENAI,
@ -473,7 +473,7 @@ class RedditSimulationRunner:
round_num: int
) -> List:
"""
根据时间和配置决定本轮激活哪些Agent
Determine which Agents to activate this round based on time and config
"""
time_config = self.config.get("time_config", {})
agent_configs = self.config.get("agent_configs", [])
@ -521,16 +521,16 @@ class RedditSimulationRunner:
return active_agents
async def run(self, max_rounds: int = None):
"""运行Reddit模拟
"""Run Reddit simulation
Args:
max_rounds: 最大模拟轮数可选用于截断过长的模拟
max_rounds: Maximum simulation rounds (optional, used to truncate overlong simulations)
"""
print("=" * 60)
print("OASIS Reddit模拟")
print(f"配置文件: {self.config_path}")
print(f"模拟ID: {self.config.get('simulation_id', 'unknown')}")
print(f"等待命令模式: {'启用' if self.wait_for_commands else '禁用'}")
print("OASIS Reddit Simulation")
print(f"Config file: {self.config_path}")
print(f"Simulation ID: {self.config.get('simulation_id', 'unknown')}")
print(f"Wait-for-commands mode: {'enabled' if self.wait_for_commands else 'disabled'}")
print("=" * 60)
time_config = self.config.get("time_config", {})
@ -538,28 +538,28 @@ class RedditSimulationRunner:
minutes_per_round = time_config.get("minutes_per_round", 30)
total_rounds = (total_hours * 60) // minutes_per_round
# 如果指定了最大轮数,则截断
# If a max-rounds is specified, truncate
if max_rounds is not None and max_rounds > 0:
original_rounds = total_rounds
total_rounds = min(total_rounds, max_rounds)
if total_rounds < original_rounds:
print(f"\n轮数已截断: {original_rounds} -> {total_rounds} (max_rounds={max_rounds})")
print(f"\nRounds truncated: {original_rounds} -> {total_rounds} (max_rounds={max_rounds})")
print(f"\n模拟参数:")
print(f" - 总模拟时长: {total_hours}小时")
print(f" - 每轮时间: {minutes_per_round}分钟")
print(f" - 总轮数: {total_rounds}")
print(f"\nSimulation parameters:")
print(f" - Total simulation duration: {total_hours} hours")
print(f" - Time per round: {minutes_per_round} minutes")
print(f" - Total rounds: {total_rounds}")
if max_rounds:
print(f" - 最大轮数限制: {max_rounds}")
print(f" - Agent数量: {len(self.config.get('agent_configs', []))}")
print(f" - Max rounds limit: {max_rounds}")
print(f" - Number of Agents: {len(self.config.get('agent_configs', []))}")
print("\n初始化LLM模型...")
print("\nInitializing LLM model...")
model = self._create_model()
print("加载Agent Profile...")
print("Loading Agent Profile...")
profile_path = self._get_profile_path()
if not os.path.exists(profile_path):
print(f"错误: Profile文件不存在: {profile_path}")
print(f"Error: Profile file does not exist: {profile_path}")
return
self.agent_graph = await generate_reddit_agent_graph(
@ -571,29 +571,29 @@ class RedditSimulationRunner:
db_path = self._get_db_path()
if os.path.exists(db_path):
os.remove(db_path)
print(f"已删除旧数据库: {db_path}")
print(f"Deleted old database: {db_path}")
print("创建OASIS环境...")
print("Creating OASIS environment...")
self.env = oasis.make(
agent_graph=self.agent_graph,
platform=oasis.DefaultPlatformType.REDDIT,
database_path=db_path,
semaphore=30, # 限制最大并发 LLM 请求数,防止 API 过载
semaphore=30, # Limit max concurrent LLM requests to prevent API overload
)
await self.env.reset()
print("环境初始化完成\n")
print("Environment initialization complete\n")
# 初始化IPC处理器
# Initialize IPC handler
self.ipc_handler = IPCHandler(self.simulation_dir, self.env, self.agent_graph)
self.ipc_handler.update_status("running")
# 执行初始事件
# Execute initial events
event_config = self.config.get("event_config", {})
initial_posts = event_config.get("initial_posts", [])
if initial_posts:
print(f"执行初始事件 ({len(initial_posts)}条初始帖子)...")
print(f"Executing initial events ({len(initial_posts)} initial posts)...")
initial_actions = {}
for post in initial_posts:
agent_id = post.get("poster_agent_id", 0)
@ -613,14 +613,14 @@ class RedditSimulationRunner:
action_args={"content": content}
)
except Exception as e:
print(f" 警告: 无法为Agent {agent_id}创建初始帖子: {e}")
print(f" Warning: unable to create initial post for Agent {agent_id}: {e}")
if initial_actions:
await self.env.step(initial_actions)
print(f" 已发布 {len(initial_actions)} 条初始帖子")
print(f" Published {len(initial_actions)} initial posts")
# 主模拟循环
print("\n开始模拟循环...")
# Main simulation loop
print("\nStarting simulation loop...")
start_time = datetime.now()
for round_num in range(total_rounds):
@ -651,20 +651,20 @@ class RedditSimulationRunner:
f"- elapsed: {elapsed:.1f}s")
total_elapsed = (datetime.now() - start_time).total_seconds()
print(f"\n模拟循环完成!")
print(f" - 总耗时: {total_elapsed:.1f}")
print(f" - 数据库: {db_path}")
print(f"\nSimulation loop completed!")
print(f" - Total elapsed: {total_elapsed:.1f} seconds")
print(f" - Database: {db_path}")
# 是否进入等待命令模式
# Whether to enter wait-for-commands mode
if self.wait_for_commands:
print("\n" + "=" * 60)
print("进入等待命令模式 - 环境保持运行")
print("支持的命令: interview, batch_interview, close_env")
print("Entering wait-for-commands mode - environment keeps running")
print("Supported commands: interview, batch_interview, close_env")
print("=" * 60)
self.ipc_handler.update_status("alive")
# 等待命令循环(使用全局 _shutdown_event
# Wait-for-commands loop (using global _shutdown_event)
try:
while not _shutdown_event.is_set():
should_continue = await self.ipc_handler.process_commands()
@ -672,58 +672,58 @@ class RedditSimulationRunner:
break
try:
await asyncio.wait_for(_shutdown_event.wait(), timeout=0.5)
break # 收到退出信号
break # Received exit signal
except asyncio.TimeoutError:
pass
except KeyboardInterrupt:
print("\n收到中断信号")
print("\nReceived interrupt signal")
except asyncio.CancelledError:
print("\n任务被取消")
print("\nTask cancelled")
except Exception as e:
print(f"\n命令处理出错: {e}")
print(f"\nCommand processing error: {e}")
print("\n关闭环境...")
print("\nClosing environment...")
# 关闭环境
# Close the environment
self.ipc_handler.update_status("stopped")
await self.env.close()
print("环境已关闭")
print("Environment closed")
print("=" * 60)
async def main():
parser = argparse.ArgumentParser(description='OASIS Reddit模拟')
parser = argparse.ArgumentParser(description='OASIS Reddit Simulation')
parser.add_argument(
'--config',
type=str,
required=True,
help='配置文件路径 (simulation_config.json)'
help='Configuration file path (simulation_config.json)'
)
parser.add_argument(
'--max-rounds',
type=int,
default=None,
help='最大模拟轮数(可选,用于截断过长的模拟)'
help='Maximum simulation rounds (optional, used to truncate overlong simulations)'
)
parser.add_argument(
'--no-wait',
action='store_true',
default=False,
help='模拟完成后立即关闭环境,不进入等待命令模式'
help='Close environment immediately after simulation completes, do not enter wait-for-commands mode'
)
args = parser.parse_args()
# 在 main 函数开始时创建 shutdown 事件
# Create shutdown event at the start of main()
global _shutdown_event
_shutdown_event = asyncio.Event()
if not os.path.exists(args.config):
print(f"错误: 配置文件不存在: {args.config}")
print(f"Error: configuration file does not exist: {args.config}")
sys.exit(1)
# 初始化日志配置(使用固定文件名,清理旧日志)
# Initialize log config (use fixed log file names, clean up old logs)
simulation_dir = os.path.dirname(args.config) or "."
setup_oasis_logging(os.path.join(simulation_dir, "log"))
@ -736,20 +736,20 @@ async def main():
def setup_signal_handlers():
"""
设置信号处理器确保收到 SIGTERM/SIGINT 时能够正确退出
让程序有机会正常清理资源关闭数据库环境等
Set up signal handlers to ensure clean exit on SIGTERM/SIGINT
Give the program a chance to clean up resources (close DB, env, etc.)
"""
def signal_handler(signum, frame):
global _cleanup_done
sig_name = "SIGTERM" if signum == signal.SIGTERM else "SIGINT"
print(f"\n收到 {sig_name} 信号,正在退出...")
print(f"\nReceived {sig_name} signal, exiting...")
if not _cleanup_done:
_cleanup_done = True
if _shutdown_event:
_shutdown_event.set()
else:
# 重复收到信号才强制退出
print("强制退出...")
# Only force exit if the signal is received again
print("Forced exit...")
sys.exit(1)
signal.signal(signal.SIGTERM, signal_handler)
@ -761,9 +761,9 @@ if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
print("\n程序被中断")
print("\nProgram interrupted")
except SystemExit:
pass
finally:
print("模拟进程已退出")
print("Simulation process exited")

View File

@ -1,16 +1,16 @@
"""
OASIS Twitter模拟预设脚本
此脚本读取配置文件中的参数来执行模拟实现全程自动化
OASIS Twitter simulation preset script
This script reads parameters from a config file to run simulations, fully automated end-to-end
功能特性:
- 完成模拟后不立即关闭环境进入等待命令模式
- 支持通过IPC接收Interview命令
- 支持单个Agent采访和批量采访
- 支持远程关闭环境命令
Features:
- After completing the simulation, do not close the environment immediately; enter wait-for-commands mode
- Support receiving Interview commands via IPC
- Support single-Agent and batch interviews
- Support remote environment-close commands
使用方式:
Usage:
python run_twitter_simulation.py --config /path/to/simulation_config.json
python run_twitter_simulation.py --config /path/to/simulation_config.json --no-wait # 完成后立即关闭
python run_twitter_simulation.py --config /path/to/simulation_config.json --no-wait # close immediately after completion
"""
import argparse
@ -25,18 +25,18 @@ import sqlite3
from datetime import datetime
from typing import Dict, Any, List, Optional
# 全局变量:用于信号处理
# Global variables: for signal handling
_shutdown_event = None
_cleanup_done = False
# 添加项目路径
# Add project paths
_scripts_dir = os.path.dirname(os.path.abspath(__file__))
_backend_dir = os.path.abspath(os.path.join(_scripts_dir, '..'))
_project_root = os.path.abspath(os.path.join(_backend_dir, '..'))
sys.path.insert(0, _scripts_dir)
sys.path.insert(0, _backend_dir)
# 加载项目根目录的 .env 文件(包含 LLM_API_KEY 等配置)
# Load project-root .env file (contains LLM_API_KEY etc.)
from dotenv import load_dotenv
_env_file = os.path.join(_project_root, '.env')
if os.path.exists(_env_file):
@ -51,7 +51,7 @@ import re
class UnicodeFormatter(logging.Formatter):
"""自定义格式化器,将 Unicode 转义序列转换为可读字符"""
"""Custom formatter that converts Unicode escape sequences into readable characters"""
UNICODE_ESCAPE_PATTERN = re.compile(r'\\u([0-9a-fA-F]{4})')
@ -68,24 +68,24 @@ class UnicodeFormatter(logging.Formatter):
class MaxTokensWarningFilter(logging.Filter):
"""过滤掉 camel-ai 关于 max_tokens 的警告(我们故意不设置 max_tokens让模型自行决定"""
"""Filter out camel-ai warnings about max_tokens (we intentionally do not set max_tokens, letting the model decide)"""
def filter(self, record):
# 过滤掉包含 max_tokens 警告的日志
# Filter out log records containing the max_tokens warning
if "max_tokens" in record.getMessage() and "Invalid or missing" in record.getMessage():
return False
return True
# 在模块加载时立即添加过滤器,确保在 camel 代码执行前生效
# Add the filter at module load time, ensuring it takes effect before camel code runs
logging.getLogger().addFilter(MaxTokensWarningFilter())
def setup_oasis_logging(log_dir: str):
"""配置 OASIS 的日志,使用固定名称的日志文件"""
"""Configure OASIS logging using fixed-name log files"""
os.makedirs(log_dir, exist_ok=True)
# 清理旧的日志文件
# Clean up old log files
for f in os.listdir(log_dir):
old_log = os.path.join(log_dir, f)
if os.path.isfile(old_log) and f.endswith('.log'):
@ -126,25 +126,25 @@ try:
generate_twitter_agent_graph
)
except ImportError as e:
print(f"错误: 缺少依赖 {e}")
print("请先安装: pip install oasis-ai camel-ai")
print(f"Error: missing dependency {e}")
print("Please install first: pip install oasis-ai camel-ai")
sys.exit(1)
# IPC相关常量
# IPC-related constants
IPC_COMMANDS_DIR = "ipc_commands"
IPC_RESPONSES_DIR = "ipc_responses"
ENV_STATUS_FILE = "env_status.json"
class CommandType:
"""命令类型常量"""
"""Command type constants"""
INTERVIEW = "interview"
BATCH_INTERVIEW = "batch_interview"
CLOSE_ENV = "close_env"
class IPCHandler:
"""IPC命令处理器"""
"""IPC command handler"""
def __init__(self, simulation_dir: str, env, agent_graph):
self.simulation_dir = simulation_dir
@ -155,12 +155,12 @@ class IPCHandler:
self.status_file = os.path.join(simulation_dir, ENV_STATUS_FILE)
self._running = True
# 确保目录存在
# Ensure the directory exists
os.makedirs(self.commands_dir, exist_ok=True)
os.makedirs(self.responses_dir, exist_ok=True)
def update_status(self, status: str):
"""更新环境状态"""
"""Update environment status"""
with open(self.status_file, 'w', encoding='utf-8') as f:
json.dump({
"status": status,
@ -168,11 +168,11 @@ class IPCHandler:
}, f, ensure_ascii=False, indent=2)
def poll_command(self) -> Optional[Dict[str, Any]]:
"""轮询获取待处理命令"""
"""Poll for pending commands"""
if not os.path.exists(self.commands_dir):
return None
# 获取命令文件(按时间排序)
# Get command files (sorted by time)
command_files = []
for filename in os.listdir(self.commands_dir):
if filename.endswith('.json'):
@ -191,7 +191,7 @@ class IPCHandler:
return None
def send_response(self, command_id: str, status: str, result: Dict = None, error: str = None):
"""发送响应"""
"""Send response"""
response = {
"command_id": command_id,
"status": status,
@ -204,7 +204,7 @@ class IPCHandler:
with open(response_file, 'w', encoding='utf-8') as f:
json.dump(response, f, ensure_ascii=False, indent=2)
# 删除命令文件
# Delete command file
command_file = os.path.join(self.commands_dir, f"{command_id}.json")
try:
os.remove(command_file)
@ -213,49 +213,49 @@ class IPCHandler:
async def handle_interview(self, command_id: str, agent_id: int, prompt: str) -> bool:
"""
处理单个Agent采访命令
Handle single-Agent interview command
Returns:
True 表示成功False 表示失败
True on success, False on failure
"""
try:
# 获取Agent
# Get Agent
agent = self.agent_graph.get_agent(agent_id)
# 创建Interview动作
# Create Interview action
interview_action = ManualAction(
action_type=ActionType.INTERVIEW,
action_args={"prompt": prompt}
)
# 执行Interview
# Execute Interview
actions = {agent: interview_action}
await self.env.step(actions)
# 从数据库获取结果
# Get result from database
result = self._get_interview_result(agent_id)
self.send_response(command_id, "completed", result=result)
print(f" Interview完成: agent_id={agent_id}")
print(f" Interview completed: agent_id={agent_id}")
return True
except Exception as e:
error_msg = str(e)
print(f" Interview失败: agent_id={agent_id}, error={error_msg}")
print(f" Interview failed: agent_id={agent_id}, error={error_msg}")
self.send_response(command_id, "failed", error=error_msg)
return False
async def handle_batch_interview(self, command_id: str, interviews: List[Dict]) -> bool:
"""
处理批量采访命令
Handle batch interview command
Args:
interviews: [{"agent_id": int, "prompt": str}, ...]
"""
try:
# 构建动作字典
# Build action dict
actions = {}
agent_prompts = {} # 记录每个agent的prompt
agent_prompts = {} # Record each agent's prompt
for interview in interviews:
agent_id = interview.get("agent_id")
@ -269,16 +269,16 @@ class IPCHandler:
)
agent_prompts[agent_id] = prompt
except Exception as e:
print(f" 警告: 无法获取Agent {agent_id}: {e}")
print(f" Warning: unable to fetch Agent {agent_id}: {e}")
if not actions:
self.send_response(command_id, "failed", error="没有有效的Agent")
self.send_response(command_id, "failed", error="No valid agents")
return False
# 执行批量Interview
# Execute batch Interview
await self.env.step(actions)
# 获取所有结果
# Get all results
results = {}
for agent_id in agent_prompts.keys():
result = self._get_interview_result(agent_id)
@ -288,17 +288,17 @@ class IPCHandler:
"interviews_count": len(results),
"results": results
})
print(f" 批量Interview完成: {len(results)} 个Agent")
print(f" Batch Interview completed: {len(results)} agents")
return True
except Exception as e:
error_msg = str(e)
print(f" 批量Interview失败: {error_msg}")
print(f" Batch Interview failed: {error_msg}")
self.send_response(command_id, "failed", error=error_msg)
return False
def _get_interview_result(self, agent_id: int) -> Dict[str, Any]:
"""从数据库获取最新的Interview结果"""
"""Get the latest Interview results from the database"""
db_path = os.path.join(self.simulation_dir, "twitter_simulation.db")
result = {
@ -314,7 +314,7 @@ class IPCHandler:
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# 查询最新的Interview记录
# Query the latest Interview record
cursor.execute("""
SELECT user_id, info, created_at
FROM trace
@ -336,16 +336,16 @@ class IPCHandler:
conn.close()
except Exception as e:
print(f" 读取Interview结果失败: {e}")
print(f" Failed to read Interview results: {e}")
return result
async def process_commands(self) -> bool:
"""
处理所有待处理命令
Handle all pending commands
Returns:
True 表示继续运行False 表示应该退出
True to continue running, False to exit
"""
command = self.poll_command()
if not command:
@ -355,7 +355,7 @@ class IPCHandler:
command_type = command.get("command_type")
args = command.get("args", {})
print(f"\n收到IPC命令: {command_type}, id={command_id}")
print(f"\nReceived IPC command: {command_type}, id={command_id}")
if command_type == CommandType.INTERVIEW:
await self.handle_interview(
@ -373,19 +373,19 @@ class IPCHandler:
return True
elif command_type == CommandType.CLOSE_ENV:
print("收到关闭环境命令")
self.send_response(command_id, "completed", result={"message": "环境即将关闭"})
print("Received close-environment command")
self.send_response(command_id, "completed", result={"message": "Environment is about to close"})
return False
else:
self.send_response(command_id, "failed", error=f"未知命令类型: {command_type}")
self.send_response(command_id, "failed", error=f"Unknown command type: {command_type}")
return True
class TwitterSimulationRunner:
"""Twitter模拟运行器"""
"""Twitter simulation runner"""
# Twitter可用动作不包含INTERVIEWINTERVIEW只能通过ManualAction手动触发
# Twitter available actions (does not include INTERVIEW; INTERVIEW can only be triggered manually via ManualAction)
AVAILABLE_ACTIONS = [
ActionType.CREATE_POST,
ActionType.LIKE_POST,
@ -397,11 +397,11 @@ class TwitterSimulationRunner:
def __init__(self, config_path: str, wait_for_commands: bool = True):
"""
初始化模拟运行器
Initialize the simulation runner
Args:
config_path: 配置文件路径 (simulation_config.json)
wait_for_commands: 模拟完成后是否等待命令默认True
config_path: Configuration file path (simulation_config.json)
wait_for_commands: Whether to wait for commands after the simulation completes (default True)
"""
self.config_path = config_path
self.config = self._load_config()
@ -412,47 +412,47 @@ class TwitterSimulationRunner:
self.ipc_handler = None
def _load_config(self) -> Dict[str, Any]:
"""加载配置文件"""
"""Load configuration file"""
with open(self.config_path, 'r', encoding='utf-8') as f:
return json.load(f)
def _get_profile_path(self) -> str:
"""获取Profile文件路径OASIS Twitter使用CSV格式"""
"""Get Profile file path (OASIS Twitter uses CSV format)"""
return os.path.join(self.simulation_dir, "twitter_profiles.csv")
def _get_db_path(self) -> str:
"""获取数据库路径"""
"""Get database path"""
return os.path.join(self.simulation_dir, "twitter_simulation.db")
def _create_model(self):
"""
创建LLM模型
Create the LLM model
统一使用项目根目录 .env 文件中的配置优先级最高
- LLM_API_KEY: API密钥
- LLM_BASE_URL: API基础URL
- LLM_MODEL_NAME: 模型名称
Use config from project-root .env file (highest priority):
- LLM_API_KEY: API key
- LLM_BASE_URL: API base URL
- LLM_MODEL_NAME: Model name
"""
# 优先从 .env 读取配置
# Prefer reading config from .env
llm_api_key = os.environ.get("LLM_API_KEY", "")
llm_base_url = os.environ.get("LLM_BASE_URL", "")
llm_model = os.environ.get("LLM_MODEL_NAME", "")
# 如果 .env 中没有,则使用 config 作为备用
# If .env has no value, fall back to config
if not llm_model:
llm_model = self.config.get("llm_model", "gpt-4o-mini")
# 设置 camel-ai 所需的环境变量
# Set environment variables required by camel-ai
if llm_api_key:
os.environ["OPENAI_API_KEY"] = llm_api_key
if not os.environ.get("OPENAI_API_KEY"):
raise ValueError("缺少 API Key 配置,请在项目根目录 .env 文件中设置 LLM_API_KEY")
raise ValueError("Missing API Key config, please set LLM_API_KEY in the project root .env file")
if llm_base_url:
os.environ["OPENAI_API_BASE_URL"] = llm_base_url
print(f"LLM配置: model={llm_model}, base_url={llm_base_url[:40] if llm_base_url else '默认'}...")
print(f"LLM config: model={llm_model}, base_url={llm_base_url[:40] if llm_base_url else 'default'}...")
return ModelFactory.create(
model_platform=ModelPlatformType.OPENAI,
@ -466,24 +466,24 @@ class TwitterSimulationRunner:
round_num: int
) -> List:
"""
根据时间和配置决定本轮激活哪些Agent
Determine which Agents to activate this round based on time and config
Args:
env: OASIS环境
current_hour: 当前模拟小时0-23
round_num: 当前轮数
env: OASIS environment
current_hour: Current simulation hour (0-23)
round_num: Current round number
Returns:
激活的Agent列表
List of activated Agents
"""
time_config = self.config.get("time_config", {})
agent_configs = self.config.get("agent_configs", [])
# 基础激活数量
# Base activation count
base_min = time_config.get("agents_per_hour_min", 5)
base_max = time_config.get("agents_per_hour_max", 20)
# 根据时段调整
# Adjust by time period
peak_hours = time_config.get("peak_hours", [9, 10, 11, 14, 15, 20, 21, 22])
off_peak_hours = time_config.get("off_peak_hours", [0, 1, 2, 3, 4, 5])
@ -496,28 +496,28 @@ class TwitterSimulationRunner:
target_count = int(random.uniform(base_min, base_max) * multiplier)
# 根据每个Agent的配置计算激活概率
# Compute activation probability from each Agent's config
candidates = []
for cfg in agent_configs:
agent_id = cfg.get("agent_id", 0)
active_hours = cfg.get("active_hours", list(range(8, 23)))
activity_level = cfg.get("activity_level", 0.5)
# 检查是否在活跃时间
# Check if within active hours
if current_hour not in active_hours:
continue
# 根据活跃度计算概率
# Compute probability from activity level
if random.random() < activity_level:
candidates.append(agent_id)
# 随机选择
# Random selection
selected_ids = random.sample(
candidates,
min(target_count, len(candidates))
) if candidates else []
# 转换为Agent对象
# Convert to Agent objects
active_agents = []
for agent_id in selected_ids:
try:
@ -529,50 +529,50 @@ class TwitterSimulationRunner:
return active_agents
async def run(self, max_rounds: int = None):
"""运行Twitter模拟
"""Run Twitter simulation
Args:
max_rounds: 最大模拟轮数可选用于截断过长的模拟
max_rounds: Maximum simulation rounds (optional, used to truncate overlong simulations)
"""
print("=" * 60)
print("OASIS Twitter模拟")
print(f"配置文件: {self.config_path}")
print(f"模拟ID: {self.config.get('simulation_id', 'unknown')}")
print(f"等待命令模式: {'启用' if self.wait_for_commands else '禁用'}")
print("OASIS Twitter Simulation")
print(f"Config file: {self.config_path}")
print(f"Simulation ID: {self.config.get('simulation_id', 'unknown')}")
print(f"Wait-for-commands mode: {'enabled' if self.wait_for_commands else 'disabled'}")
print("=" * 60)
# 加载时间配置
# Load time config
time_config = self.config.get("time_config", {})
total_hours = time_config.get("total_simulation_hours", 72)
minutes_per_round = time_config.get("minutes_per_round", 30)
# 计算总轮数
# Compute total rounds
total_rounds = (total_hours * 60) // minutes_per_round
# 如果指定了最大轮数,则截断
# If a max-rounds is specified, truncate
if max_rounds is not None and max_rounds > 0:
original_rounds = total_rounds
total_rounds = min(total_rounds, max_rounds)
if total_rounds < original_rounds:
print(f"\n轮数已截断: {original_rounds} -> {total_rounds} (max_rounds={max_rounds})")
print(f"\nRounds truncated: {original_rounds} -> {total_rounds} (max_rounds={max_rounds})")
print(f"\n模拟参数:")
print(f" - 总模拟时长: {total_hours}小时")
print(f" - 每轮时间: {minutes_per_round}分钟")
print(f" - 总轮数: {total_rounds}")
print(f"\nSimulation parameters:")
print(f" - Total simulation duration: {total_hours} hours")
print(f" - Time per round: {minutes_per_round} minutes")
print(f" - Total rounds: {total_rounds}")
if max_rounds:
print(f" - 最大轮数限制: {max_rounds}")
print(f" - Agent数量: {len(self.config.get('agent_configs', []))}")
print(f" - Max rounds limit: {max_rounds}")
print(f" - Number of Agents: {len(self.config.get('agent_configs', []))}")
# 创建模型
print("\n初始化LLM模型...")
# Create model
print("\nInitializing LLM model...")
model = self._create_model()
# 加载Agent图
print("加载Agent Profile...")
# Load Agent graph
print("Loading Agent Profile...")
profile_path = self._get_profile_path()
if not os.path.exists(profile_path):
print(f"错误: Profile文件不存在: {profile_path}")
print(f"Error: Profile file does not exist: {profile_path}")
return
self.agent_graph = await generate_twitter_agent_graph(
@ -581,34 +581,34 @@ class TwitterSimulationRunner:
available_actions=self.AVAILABLE_ACTIONS,
)
# 数据库路径
# Database path
db_path = self._get_db_path()
if os.path.exists(db_path):
os.remove(db_path)
print(f"已删除旧数据库: {db_path}")
print(f"Deleted old database: {db_path}")
# 创建环境
print("创建OASIS环境...")
# Create environment
print("Creating OASIS environment...")
self.env = oasis.make(
agent_graph=self.agent_graph,
platform=oasis.DefaultPlatformType.TWITTER,
database_path=db_path,
semaphore=30, # 限制最大并发 LLM 请求数,防止 API 过载
semaphore=30, # Limit max concurrent LLM requests to prevent API overload
)
await self.env.reset()
print("环境初始化完成\n")
print("Environment initialization complete\n")
# 初始化IPC处理器
# Initialize IPC handler
self.ipc_handler = IPCHandler(self.simulation_dir, self.env, self.agent_graph)
self.ipc_handler.update_status("running")
# 执行初始事件
# Execute initial events
event_config = self.config.get("event_config", {})
initial_posts = event_config.get("initial_posts", [])
if initial_posts:
print(f"执行初始事件 ({len(initial_posts)}条初始帖子)...")
print(f"Executing initial events ({len(initial_posts)} initial posts)...")
initial_actions = {}
for post in initial_posts:
agent_id = post.get("poster_agent_id", 0)
@ -620,23 +620,23 @@ class TwitterSimulationRunner:
action_args={"content": content}
)
except Exception as e:
print(f" 警告: 无法为Agent {agent_id}创建初始帖子: {e}")
print(f" Warning: unable to create initial post for Agent {agent_id}: {e}")
if initial_actions:
await self.env.step(initial_actions)
print(f" 已发布 {len(initial_actions)} 条初始帖子")
print(f" Published {len(initial_actions)} initial posts")
# 主模拟循环
print("\n开始模拟循环...")
# Main simulation loop
print("\nStarting simulation loop...")
start_time = datetime.now()
for round_num in range(total_rounds):
# 计算当前模拟时间
# Compute current simulation time
simulated_minutes = round_num * minutes_per_round
simulated_hour = (simulated_minutes // 60) % 24
simulated_day = simulated_minutes // (60 * 24) + 1
# 获取本轮激活的Agent
# Get activated Agents for this round
active_agents = self._get_active_agents_for_round(
self.env, simulated_hour, round_num
)
@ -644,16 +644,16 @@ class TwitterSimulationRunner:
if not active_agents:
continue
# 构建动作
# Build actions
actions = {
agent: LLMAction()
for _, agent in active_agents
}
# 执行动作
# Execute actions
await self.env.step(actions)
# 打印进度
# Print progress
if (round_num + 1) % 10 == 0 or round_num == 0:
elapsed = (datetime.now() - start_time).total_seconds()
progress = (round_num + 1) / total_rounds * 100
@ -663,20 +663,20 @@ class TwitterSimulationRunner:
f"- elapsed: {elapsed:.1f}s")
total_elapsed = (datetime.now() - start_time).total_seconds()
print(f"\n模拟循环完成!")
print(f" - 总耗时: {total_elapsed:.1f}")
print(f" - 数据库: {db_path}")
print(f"\nSimulation loop completed!")
print(f" - Total elapsed: {total_elapsed:.1f} seconds")
print(f" - Database: {db_path}")
# 是否进入等待命令模式
# Whether to enter wait-for-commands mode
if self.wait_for_commands:
print("\n" + "=" * 60)
print("进入等待命令模式 - 环境保持运行")
print("支持的命令: interview, batch_interview, close_env")
print("Entering wait-for-commands mode - environment keeps running")
print("Supported commands: interview, batch_interview, close_env")
print("=" * 60)
self.ipc_handler.update_status("alive")
# 等待命令循环(使用全局 _shutdown_event
# Wait-for-commands loop (using global _shutdown_event)
try:
while not _shutdown_event.is_set():
should_continue = await self.ipc_handler.process_commands()
@ -684,58 +684,58 @@ class TwitterSimulationRunner:
break
try:
await asyncio.wait_for(_shutdown_event.wait(), timeout=0.5)
break # 收到退出信号
break # Received exit signal
except asyncio.TimeoutError:
pass
except KeyboardInterrupt:
print("\n收到中断信号")
print("\nReceived interrupt signal")
except asyncio.CancelledError:
print("\n任务被取消")
print("\nTask cancelled")
except Exception as e:
print(f"\n命令处理出错: {e}")
print(f"\nCommand processing error: {e}")
print("\n关闭环境...")
print("\nClosing environment...")
# 关闭环境
# Close the environment
self.ipc_handler.update_status("stopped")
await self.env.close()
print("环境已关闭")
print("Environment closed")
print("=" * 60)
async def main():
parser = argparse.ArgumentParser(description='OASIS Twitter模拟')
parser = argparse.ArgumentParser(description='OASIS Twitter Simulation')
parser.add_argument(
'--config',
type=str,
required=True,
help='配置文件路径 (simulation_config.json)'
help='Configuration file path (simulation_config.json)'
)
parser.add_argument(
'--max-rounds',
type=int,
default=None,
help='最大模拟轮数(可选,用于截断过长的模拟)'
help='Maximum simulation rounds (optional, used to truncate overlong simulations)'
)
parser.add_argument(
'--no-wait',
action='store_true',
default=False,
help='模拟完成后立即关闭环境,不进入等待命令模式'
help='Close environment immediately after simulation completes, do not enter wait-for-commands mode'
)
args = parser.parse_args()
# 在 main 函数开始时创建 shutdown 事件
# Create shutdown event at the start of main()
global _shutdown_event
_shutdown_event = asyncio.Event()
if not os.path.exists(args.config):
print(f"错误: 配置文件不存在: {args.config}")
print(f"Error: configuration file does not exist: {args.config}")
sys.exit(1)
# 初始化日志配置(使用固定文件名,清理旧日志)
# Initialize log config (use fixed log file names, clean up old logs)
simulation_dir = os.path.dirname(args.config) or "."
setup_oasis_logging(os.path.join(simulation_dir, "log"))
@ -748,20 +748,20 @@ async def main():
def setup_signal_handlers():
"""
设置信号处理器确保收到 SIGTERM/SIGINT 时能够正确退出
让程序有机会正常清理资源关闭数据库环境等
Set up signal handlers to ensure clean exit on SIGTERM/SIGINT
Give the program a chance to clean up resources (close DB, env, etc.)
"""
def signal_handler(signum, frame):
global _cleanup_done
sig_name = "SIGTERM" if signum == signal.SIGTERM else "SIGINT"
print(f"\n收到 {sig_name} 信号,正在退出...")
print(f"\nReceived {sig_name} signal, exiting...")
if not _cleanup_done:
_cleanup_done = True
if _shutdown_event:
_shutdown_event.set()
else:
# 重复收到信号才强制退出
print("强制退出...")
# Only force exit if the signal is received again
print("Forced exit...")
sys.exit(1)
signal.signal(signal.SIGTERM, signal_handler)
@ -773,8 +773,8 @@ if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
print("\n程序被中断")
print("\nProgram interrupted")
except SystemExit:
pass
finally:
print("模拟进程已退出")
print("Simulation process exited")

View File

@ -1,8 +1,8 @@
"""
测试Profile格式生成是否符合OASIS要求
验证
1. Twitter Profile生成CSV格式
2. Reddit Profile生成JSON详细格式
Test that the generated Profile formats conform to OASIS requirements
Verifies:
1. Twitter Profile generated as CSV format
2. Reddit Profile generated as detailed JSON format
"""
import os
@ -11,19 +11,19 @@ import json
import csv
import tempfile
# 添加项目路径
# Add project path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.services.oasis_profile_generator import OasisProfileGenerator, OasisAgentProfile
def test_profile_formats():
"""测试Profile格式"""
"""Test Profile formats"""
print("=" * 60)
print("OASIS Profile格式测试")
print("OASIS Profile format test")
print("=" * 60)
# 创建测试Profile数据
# Create test Profile data
test_profiles = [
OasisAgentProfile(
user_id=0,
@ -60,87 +60,87 @@ def test_profile_formats():
source_entity_type="University",
),
]
generator = OasisProfileGenerator.__new__(OasisProfileGenerator)
# 使用临时目录
# Use a temporary directory
with tempfile.TemporaryDirectory() as temp_dir:
twitter_path = os.path.join(temp_dir, "twitter_profiles.csv")
reddit_path = os.path.join(temp_dir, "reddit_profiles.json")
# 测试Twitter CSV格式
print("\n1. 测试Twitter Profile (CSV格式)")
# Test Twitter CSV format
print("\n1. Test Twitter Profile (CSV format)")
print("-" * 40)
generator._save_twitter_csv(test_profiles, twitter_path)
# 读取并验证CSV
# Read and verify the CSV
with open(twitter_path, 'r', encoding='utf-8') as f:
reader = csv.DictReader(f)
rows = list(reader)
print(f" 文件: {twitter_path}")
print(f" 行数: {len(rows)}")
print(f" 表头: {list(rows[0].keys())}")
print(f"\n 示例数据 (第1行):")
print(f" File: {twitter_path}")
print(f" Row count: {len(rows)}")
print(f" Header: {list(rows[0].keys())}")
print(f"\n Sample data (row 1):")
for key, value in rows[0].items():
print(f" {key}: {value}")
# 验证必需字段
required_twitter_fields = ['user_id', 'user_name', 'name', 'bio',
# Verify required fields
required_twitter_fields = ['user_id', 'user_name', 'name', 'bio',
'friend_count', 'follower_count', 'statuses_count', 'created_at']
missing = set(required_twitter_fields) - set(rows[0].keys())
if missing:
print(f"\n [错误] 缺少字段: {missing}")
print(f"\n [Error] Missing fields: {missing}")
else:
print(f"\n [通过] 所有必需字段都存在")
# 测试Reddit JSON格式
print("\n2. 测试Reddit Profile (JSON详细格式)")
print(f"\n [Pass] All required fields are present")
# Test Reddit JSON format
print("\n2. Test Reddit Profile (detailed JSON format)")
print("-" * 40)
generator._save_reddit_json(test_profiles, reddit_path)
# 读取并验证JSON
# Read and verify the JSON
with open(reddit_path, 'r', encoding='utf-8') as f:
reddit_data = json.load(f)
print(f" 文件: {reddit_path}")
print(f" 条目数: {len(reddit_data)}")
print(f" 字段: {list(reddit_data[0].keys())}")
print(f"\n 示例数据 (第1条):")
print(f" File: {reddit_path}")
print(f" Entry count: {len(reddit_data)}")
print(f" Fields: {list(reddit_data[0].keys())}")
print(f"\n Sample data (entry 1):")
print(json.dumps(reddit_data[0], ensure_ascii=False, indent=4))
# 验证详细格式字段
# Verify detailed-format fields
required_reddit_fields = ['realname', 'username', 'bio', 'persona']
optional_reddit_fields = ['age', 'gender', 'mbti', 'country', 'profession', 'interested_topics']
missing = set(required_reddit_fields) - set(reddit_data[0].keys())
if missing:
print(f"\n [错误] 缺少必需字段: {missing}")
print(f"\n [Error] Missing required fields: {missing}")
else:
print(f"\n [通过] 所有必需字段都存在")
print(f"\n [Pass] All required fields are present")
present_optional = set(optional_reddit_fields) & set(reddit_data[0].keys())
print(f" [信息] 可选字段: {present_optional}")
print(f" [Info] Optional fields: {present_optional}")
print("\n" + "=" * 60)
print("测试完成!")
print("Tests complete!")
print("=" * 60)
def show_expected_formats():
"""显示OASIS期望的格式"""
"""Show the formats expected by OASIS"""
print("\n" + "=" * 60)
print("OASIS 期望的Profile格式参考")
print("OASIS expected Profile format reference")
print("=" * 60)
print("\n1. Twitter Profile (CSV格式)")
print("\n1. Twitter Profile (CSV format)")
print("-" * 40)
twitter_example = """user_id,user_name,name,bio,friend_count,follower_count,statuses_count,created_at
0,user0,User Zero,I am user zero with interests in technology.,100,150,500,2023-01-01
1,user1,User One,Tech enthusiast and coffee lover.,200,250,1000,2023-01-02"""
print(twitter_example)
print("\n2. Reddit Profile (JSON详细格式)")
print("\n2. Reddit Profile (detailed JSON format)")
print("-" * 40)
reddit_example = [
{

View File

@ -1,7 +1,7 @@
services:
mirofish:
image: ghcr.io/666ghj/mirofish:latest
# 加速镜像(如拉取缓慢可替换上方地址)
# Accelerated registry mirror (replace the upstream address above if pulling is slow)
# image: ghcr.nju.edu.cn/666ghj/mirofish:latest
container_name: mirofish
env_file:

View File

@ -1,15 +1,15 @@
<!doctype html>
<html lang="zh">
<html lang="nl">
<head>
<script>document.documentElement.lang = localStorage.getItem('locale') || 'zh'</script>
<script>document.documentElement.lang = localStorage.getItem('locale') || 'nl'</script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@100..800&family=Noto+Sans+SC:wght@300;400;500;700;800;900&family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet">
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="MiroFish - 社交媒体舆论模拟系统" />
<title>MiroFish - 预测万物</title>
<meta name="description" content="MiroFish - Social media opinion simulation system" />
<title>MiroFish - Predict Anything</title>
</head>
<body>
<div id="app"></div>

View File

@ -3,11 +3,11 @@
</template>
<script setup>
// 使 Vue Router
// Use Vue Router to manage pages
</script>
<style>
/* 全局样式重置 */
/* Global style reset */
* {
margin: 0;
padding: 0;
@ -22,7 +22,7 @@
background-color: #ffffff;
}
/* 滚动条样式 */
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
@ -40,7 +40,7 @@
background: #333333;
}
/* 全局按钮样式 */
/* Global button styling */
button {
font-family: inherit;
}

View File

@ -1,12 +1,12 @@
import service, { requestWithRetry } from './index'
/**
* 生成本体上传文档和模拟需求
* @param {Object} data - 包含files, simulation_requirement, project_name等
* Generate the ontology (upload documents + simulation requirement)
* @param {Object} data - Includes files, simulation_requirement, project_name, etc.
* @returns {Promise}
*/
export function generateOntology(formData) {
return requestWithRetry(() =>
return requestWithRetry(() =>
service({
url: '/api/graph/ontology/generate',
method: 'post',
@ -19,8 +19,8 @@ export function generateOntology(formData) {
}
/**
* 构建图谱
* @param {Object} data - 包含project_id, graph_name等
* Build the knowledge graph
* @param {Object} data - Includes project_id, graph_name, etc.
* @returns {Promise}
*/
export function buildGraph(data) {
@ -34,8 +34,8 @@ export function buildGraph(data) {
}
/**
* 查询任务状态
* @param {String} taskId - 任务ID
* Query task status
* @param {String} taskId - Task ID
* @returns {Promise}
*/
export function getTaskStatus(taskId) {
@ -46,8 +46,8 @@ export function getTaskStatus(taskId) {
}
/**
* 获取图谱数据
* @param {String} graphId - 图谱ID
* Get graph data
* @param {String} graphId - Graph ID
* @returns {Promise}
*/
export function getGraphData(graphId) {
@ -58,8 +58,8 @@ export function getGraphData(graphId) {
}
/**
* 获取项目信息
* @param {String} projectId - 项目ID
* Get project info
* @param {String} projectId - Project ID
* @returns {Promise}
*/
export function getProject(projectId) {
@ -70,7 +70,7 @@ export function getProject(projectId) {
}
/**
* 列出项目最近 N 用于首页 history 视图显示
* List the most recent N projects. Used by the home page history view.
* @param {Number} limit
* @returns {Promise}
*/

View File

@ -1,16 +1,16 @@
import axios from 'axios'
import i18n from '../i18n'
// 创建axios实例
// Create the axios instance
const service = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5001',
timeout: 300000, // 5分钟超时(本体生成可能需要较长时间)
timeout: 300000, // 5-minute timeout (ontology generation can take a while)
headers: {
'Content-Type': 'application/json'
}
})
// 请求拦截器
// Request interceptor
service.interceptors.request.use(
config => {
config.headers['Accept-Language'] = i18n.global.locale.value
@ -22,44 +22,44 @@ service.interceptors.request.use(
}
)
// 响应拦截器(容错重试机制)
// Response interceptor (tolerant retry handling)
service.interceptors.response.use(
response => {
const res = response.data
// 如果返回的状态码不是success则抛出错误
// If the response is not flagged as success, reject it
if (!res.success && res.success !== undefined) {
console.error('API Error:', res.error || res.message || 'Unknown error')
return Promise.reject(new Error(res.error || res.message || 'Error'))
}
return res
},
error => {
console.error('Response error:', error)
// 处理超时
// Handle timeouts
if (error.code === 'ECONNABORTED' && error.message.includes('timeout')) {
console.error('Request timeout')
}
// 处理网络错误
// Handle network errors
if (error.message === 'Network Error') {
console.error('Network error - please check your connection')
}
return Promise.reject(error)
}
)
// 带重试的请求函数
// Request helper with retry
export const requestWithRetry = async (requestFn, maxRetries = 3, delay = 1000) => {
for (let i = 0; i < maxRetries; i++) {
try {
return await requestFn()
} catch (error) {
if (i === maxRetries - 1) throw error
console.warn(`Request failed, retrying (${i + 1}/${maxRetries})...`)
await new Promise(resolve => setTimeout(resolve, delay * Math.pow(2, i)))
}

View File

@ -1,7 +1,7 @@
import service, { requestWithRetry } from './index'
/**
* 开始报告生成
* Start report generation
* @param {Object} data - { simulation_id, force_regenerate? }
*/
export const generateReport = (data) => {
@ -9,7 +9,7 @@ export const generateReport = (data) => {
}
/**
* 获取报告生成状态
* Get report-generation status
* @param {string} reportId
*/
export const getReportStatus = (reportId) => {
@ -17,25 +17,25 @@ export const getReportStatus = (reportId) => {
}
/**
* 获取 Agent 日志增量
* Get the Agent log (incremental)
* @param {string} reportId
* @param {number} fromLine - 从第几行开始获取
* @param {number} fromLine - Start at this line index
*/
export const getAgentLog = (reportId, fromLine = 0) => {
return service.get(`/api/report/${reportId}/agent-log`, { params: { from_line: fromLine } })
}
/**
* 获取控制台日志增量
* Get the console log (incremental)
* @param {string} reportId
* @param {number} fromLine - 从第几行开始获取
* @param {number} fromLine - Start at this line index
*/
export const getConsoleLog = (reportId, fromLine = 0) => {
return service.get(`/api/report/${reportId}/console-log`, { params: { from_line: fromLine } })
}
/**
* 获取报告详情
* Get report details
* @param {string} reportId
*/
export const getReport = (reportId) => {
@ -43,7 +43,7 @@ export const getReport = (reportId) => {
}
/**
* Report Agent 对话
* Chat with the Report Agent
* @param {Object} data - { simulation_id, message, chat_history? }
*/
export const chatWithReport = (data) => {

View File

@ -1,7 +1,7 @@
import service, { requestWithRetry } from './index'
/**
* 创建模拟
* Create a simulation
* @param {Object} data - { project_id, graph_id?, enable_twitter?, enable_reddit? }
*/
export const createSimulation = (data) => {
@ -9,7 +9,7 @@ export const createSimulation = (data) => {
}
/**
* 准备模拟环境异步任务
* Prepare the simulation environment (async task)
* @param {Object} data - { simulation_id, entity_types?, use_llm_for_profiles?, parallel_profile_count?, force_regenerate? }
*/
export const prepareSimulation = (data) => {
@ -17,7 +17,7 @@ export const prepareSimulation = (data) => {
}
/**
* 查询准备任务进度
* Query prepare-task progress
* @param {Object} data - { task_id?, simulation_id? }
*/
export const getPrepareStatus = (data) => {
@ -25,7 +25,7 @@ export const getPrepareStatus = (data) => {
}
/**
* 获取模拟状态
* Get simulation status
* @param {string} simulationId
*/
export const getSimulation = (simulationId) => {
@ -33,7 +33,7 @@ export const getSimulation = (simulationId) => {
}
/**
* 获取模拟的 Agent Profiles
* Get Agent Profiles for a simulation
* @param {string} simulationId
* @param {string} platform - 'reddit' | 'twitter'
*/
@ -42,7 +42,7 @@ export const getSimulationProfiles = (simulationId, platform = 'reddit') => {
}
/**
* 实时获取生成中的 Agent Profiles
* Stream Agent Profiles while they are being generated
* @param {string} simulationId
* @param {string} platform - 'reddit' | 'twitter'
*/
@ -51,7 +51,7 @@ export const getSimulationProfilesRealtime = (simulationId, platform = 'reddit')
}
/**
* 获取模拟配置
* Get simulation config
* @param {string} simulationId
*/
export const getSimulationConfig = (simulationId) => {
@ -59,17 +59,17 @@ export const getSimulationConfig = (simulationId) => {
}
/**
* 实时获取生成中的模拟配置
* Stream simulation config while it is being generated
* @param {string} simulationId
* @returns {Promise} 返回配置信息包含元数据和配置内容
* @returns {Promise} Resolves to the config object, including metadata and the generated config body
*/
export const getSimulationConfigRealtime = (simulationId) => {
return service.get(`/api/simulation/${simulationId}/config/realtime`)
}
/**
* 列出所有模拟
* @param {string} projectId - 可选按项目ID过滤
* List all simulations
* @param {string} projectId - Optional; filter by project ID
*/
export const listSimulations = (projectId) => {
const params = projectId ? { project_id: projectId } : {}
@ -77,7 +77,7 @@ export const listSimulations = (projectId) => {
}
/**
* 启动模拟
* Start a simulation
* @param {Object} data - { simulation_id, platform?, max_rounds?, enable_graph_memory_update? }
*/
export const startSimulation = (data) => {
@ -85,7 +85,7 @@ export const startSimulation = (data) => {
}
/**
* 停止模拟
* Stop a simulation
* @param {Object} data - { simulation_id }
*/
export const stopSimulation = (data) => {
@ -93,7 +93,7 @@ export const stopSimulation = (data) => {
}
/**
* 获取模拟运行实时状态
* Get live run status for a simulation
* @param {string} simulationId
*/
export const getRunStatus = (simulationId) => {
@ -101,7 +101,7 @@ export const getRunStatus = (simulationId) => {
}
/**
* 获取模拟运行详细状态包含最近动作
* Get detailed run status (includes the most recent actions)
* @param {string} simulationId
*/
export const getRunStatusDetail = (simulationId) => {
@ -109,11 +109,11 @@ export const getRunStatusDetail = (simulationId) => {
}
/**
* 获取模拟中的帖子
* Get posts from the simulation
* @param {string} simulationId
* @param {string} platform - 'reddit' | 'twitter'
* @param {number} limit - 返回数量
* @param {number} offset - 偏移量
* @param {number} limit - Number of posts to return
* @param {number} offset - Offset
*/
export const getSimulationPosts = (simulationId, platform = 'reddit', limit = 50, offset = 0) => {
return service.get(`/api/simulation/${simulationId}/posts`, {
@ -122,10 +122,10 @@ export const getSimulationPosts = (simulationId, platform = 'reddit', limit = 50
}
/**
* 获取模拟时间线按轮次汇总
* Get the simulation timeline (grouped by round)
* @param {string} simulationId
* @param {number} startRound - 起始轮次
* @param {number} endRound - 结束轮次
* @param {number} startRound - Starting round (inclusive)
* @param {number} endRound - Ending round (inclusive)
*/
export const getSimulationTimeline = (simulationId, startRound = 0, endRound = null) => {
const params = { start_round: startRound }
@ -136,7 +136,7 @@ export const getSimulationTimeline = (simulationId, startRound = 0, endRound = n
}
/**
* 获取Agent统计信息
* Get Agent statistics
* @param {string} simulationId
*/
export const getAgentStats = (simulationId) => {
@ -144,7 +144,7 @@ export const getAgentStats = (simulationId) => {
}
/**
* 获取模拟动作历史
* Get the action history of a simulation
* @param {string} simulationId
* @param {Object} params - { limit, offset, platform, agent_id, round_num }
*/
@ -153,7 +153,7 @@ export const getSimulationActions = (simulationId, params = {}) => {
}
/**
* 关闭模拟环境优雅退出
* Shut down the simulation environment (graceful exit)
* @param {Object} data - { simulation_id, timeout? }
*/
export const closeSimulationEnv = (data) => {
@ -161,7 +161,7 @@ export const closeSimulationEnv = (data) => {
}
/**
* 获取模拟环境状态
* Get the simulation environment status
* @param {Object} data - { simulation_id }
*/
export const getEnvStatus = (data) => {
@ -169,7 +169,7 @@ export const getEnvStatus = (data) => {
}
/**
* 批量采访 Agent
* Batch-interview agents
* @param {Object} data - { simulation_id, interviews: [{ agent_id, prompt }] }
*/
export const interviewAgents = (data) => {
@ -177,11 +177,10 @@ export const interviewAgents = (data) => {
}
/**
* 获取历史模拟列表带项目详情
* 用于首页历史项目展示
* @param {number} limit - 返回数量限制
* Get the historical simulation list (with project details)
* Used by the home page history view.
* @param {number} limit - Max number of results
*/
export const getSimulationHistory = (limit = 20) => {
return service.get('/api/simulation/history', { params: { limit } })
}

View File

@ -2,7 +2,7 @@
<div class="graph-panel">
<div class="panel-header">
<span class="panel-title">{{ $t('graph.panelTitle') }}</span>
<!-- 顶部工具栏 (Internal Top Right) -->
<!-- Top toolbar (Internal Top Right) -->
<div class="header-tools">
<button class="tool-btn" @click="$emit('refresh')" :disabled="loading" :title="$t('graph.refreshGraph')">
<span class="icon-refresh" :class="{ 'spinning': loading }"></span>
@ -15,11 +15,11 @@
</div>
<div class="graph-container" ref="graphContainer">
<!-- 图谱可视化 -->
<!-- Graph visualization -->
<div v-if="graphData" class="graph-view">
<svg ref="graphSvg" class="graph-svg"></svg>
<!-- 构建中/模拟中提示 -->
<!-- Building / simulating hint -->
<div v-if="currentPhase === 1 || isSimulating" class="graph-building-hint">
<div class="memory-icon-wrapper">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="memory-icon">
@ -30,7 +30,7 @@
{{ isSimulating ? $t('graph.graphMemoryRealtime') : $t('graph.realtimeUpdating') }}
</div>
<!-- 模拟结束后的提示 -->
<!-- Post-simulation hint -->
<div v-if="showSimulationFinishedHint" class="graph-building-hint finished-hint">
<div class="hint-icon-wrapper">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="hint-icon">
@ -48,7 +48,7 @@
</button>
</div>
<!-- 节点/边详情面板 -->
<!-- Node / edge detail panel -->
<div v-if="selectedItem" class="detail-panel">
<div class="detail-panel-header">
<span class="detail-title">{{ selectedItem.type === 'node' ? $t('graph.nodeDetails') : $t('graph.relationship') }}</span>
@ -58,7 +58,7 @@
<button class="detail-close" @click="closeDetailPanel">×</button>
</div>
<!-- 节点详情 -->
<!-- Node details -->
<div v-if="selectedItem.type === 'node'" class="detail-content">
<div class="detail-row">
<span class="detail-label">Name:</span>
@ -101,9 +101,9 @@
</div>
</div>
<!-- 边详情 -->
<!-- Edge details -->
<div v-else class="detail-content">
<!-- 自环组详情 -->
<!-- Self-loop group details -->
<template v-if="selectedItem.data.isSelfLoopGroup">
<div class="edge-relation-header self-loop-header">
{{ selectedItem.data.source_name }} - Self Relations
@ -154,7 +154,7 @@
</div>
</template>
<!-- 普通边详情 -->
<!-- Regular edge details -->
<template v-else>
<div class="edge-relation-header">
{{ selectedItem.data.source_name }} {{ selectedItem.data.name || 'RELATED_TO' }} {{ selectedItem.data.target_name }}
@ -200,20 +200,20 @@
</div>
</div>
<!-- 加载状态 -->
<!-- Loading state -->
<div v-else-if="loading" class="graph-state">
<div class="loading-spinner"></div>
<p>{{ $t('graph.graphDataLoading') }}</p>
</div>
<!-- 等待/空状态 -->
<!-- Waiting / empty state -->
<div v-else class="graph-state">
<div class="empty-icon"></div>
<p class="empty-text">{{ $t('graph.waitingOntology') }}</p>
</div>
</div>
<!-- 底部图例 (Bottom Left) -->
<!-- Bottom legend (Bottom Left) -->
<div v-if="graphData && entityTypes.length" class="graph-legend">
<span class="legend-title">Entity Types</span>
<div class="legend-items">
@ -224,7 +224,7 @@
</div>
</div>
<!-- 显示边标签开关 -->
<!-- Show edge labels toggle -->
<div v-if="graphData" class="edge-labels-toggle">
<label class="toggle-switch">
<input type="checkbox" v-model="showEdgeLabels" />
@ -251,26 +251,26 @@ const emit = defineEmits(['refresh', 'toggle-maximize'])
const graphContainer = ref(null)
const graphSvg = ref(null)
const selectedItem = ref(null)
const showEdgeLabels = ref(true) //
const expandedSelfLoops = ref(new Set()) //
const showSimulationFinishedHint = ref(false) //
const wasSimulating = ref(false) //
const showEdgeLabels = ref(true) // Default: show edge labels
const expandedSelfLoops = ref(new Set()) // Expanded self-loop items
const showSimulationFinishedHint = ref(false) // Post-simulation hint
const wasSimulating = ref(false) // Track whether we were simulating before
//
// Dismiss the simulation-finished hint
const dismissFinishedHint = () => {
showSimulationFinishedHint.value = false
}
// isSimulating
// Watch isSimulating changes to detect simulation end
watch(() => props.isSimulating, (newValue, oldValue) => {
if (wasSimulating.value && !newValue) {
//
// Transition from simulating to not-simulating, show end hint
showSimulationFinishedHint.value = true
}
wasSimulating.value = newValue
}, { immediate: true })
// /
// Toggle self-loop item expand/collapse state
const toggleSelfLoop = (id) => {
const newSet = new Set(expandedSelfLoops.value)
if (newSet.has(id)) {
@ -281,11 +281,11 @@ const toggleSelfLoop = (id) => {
expandedSelfLoops.value = newSet
}
//
// Compute entity types for the legend
const entityTypes = computed(() => {
if (!props.graphData?.nodes) return []
const typeMap = {}
//
// Aesthetic color palette
const colors = ['#FF6B35', '#004E89', '#7B2D8E', '#1A936F', '#C5283D', '#E9724C', '#3498db', '#9b59b6', '#27ae60', '#f39c12']
props.graphData.nodes.forEach(node => {
@ -298,7 +298,7 @@ const entityTypes = computed(() => {
return Object.values(typeMap)
})
//
// Format date/time
const formatDateTime = (dateStr) => {
if (!dateStr) return ''
try {
@ -318,7 +318,7 @@ const formatDateTime = (dateStr) => {
const closeDetailPanel = () => {
selectedItem.value = null
expandedSelfLoops.value = new Set() //
expandedSelfLoops.value = new Set() // Reset expanded state
}
let currentSimulation = null
@ -328,7 +328,7 @@ let linkLabelBgRef = null
const renderGraph = () => {
if (!graphSvg.value || !props.graphData) return
// 仿
// Stop previous simulation
if (currentSimulation) {
currentSimulation.stop()
}
@ -362,16 +362,16 @@ const renderGraph = () => {
const nodeIds = new Set(nodes.map(n => n.id))
//
// Process edge data: compute edge count and index per node pair
const edgePairCount = {}
const selfLoopEdges = {} //
const selfLoopEdges = {} // Self-loop edges grouped by node
const tempEdges = edgesData
.filter(e => nodeIds.has(e.source_node_uuid) && nodeIds.has(e.target_node_uuid))
//
// Count edges between each node pair, collect self-loop edges
tempEdges.forEach(e => {
if (e.source_node_uuid === e.target_node_uuid) {
// -
// Self-loop - collect into array
if (!selfLoopEdges[e.source_node_uuid]) {
selfLoopEdges[e.source_node_uuid] = []
}
@ -386,9 +386,9 @@ const renderGraph = () => {
}
})
//
// Record which edge index we are currently processing for each node pair
const edgePairIndex = {}
const processedSelfLoopNodes = new Set() //
const processedSelfLoopNodes = new Set() // Already-processed self-loop nodes
const edges = []
@ -396,9 +396,9 @@ const renderGraph = () => {
const isSelfLoop = e.source_node_uuid === e.target_node_uuid
if (isSelfLoop) {
// -
// Self-loop edge - add only one merged self-loop per node
if (processedSelfLoopNodes.has(e.source_node_uuid)) {
return //
return // Already processed, skip
}
processedSelfLoopNodes.add(e.source_node_uuid)
@ -417,7 +417,7 @@ const renderGraph = () => {
source_name: nodeName,
target_name: nodeName,
selfLoopCount: allSelfLoops.length,
selfLoopEdges: allSelfLoops //
selfLoopEdges: allSelfLoops // Store details of all self-loop edges
}
})
return
@ -428,19 +428,19 @@ const renderGraph = () => {
const currentIndex = edgePairIndex[pairKey] || 0
edgePairIndex[pairKey] = currentIndex + 1
// UUID < UUID
// Determine if edge direction matches the normalized direction (source UUID < target UUID)
const isReversed = e.source_node_uuid > e.target_node_uuid
// 线
// Compute curvature: spread multiple edges apart, single edge stays straight
let curvature = 0
if (totalCount > 1) {
//
//
// Evenly distribute curvature for clear visual separation
// Curvature range scales with edge count: more edges -> wider range
const curvatureRange = Math.min(1.2, 0.6 + totalCount * 0.15)
curvature = ((currentIndex / (totalCount - 1)) - 0.5) * curvatureRange * 2
//
//
// If edge direction is opposite to normalized direction, flip curvature
// This keeps all edges in the same reference frame, preventing overlap from differing directions
if (isReversed) {
curvature = -curvature
}
@ -468,11 +468,11 @@ const renderGraph = () => {
entityTypes.value.forEach(t => colorMap[t.name] = t.color)
const getColor = (type) => colorMap[type] || '#999'
// Simulation -
// Simulation - dynamically tune node spacing by edge count
const simulation = d3.forceSimulation(nodes)
.force('link', d3.forceLink(edges).id(d => d.id).distance(d => {
//
// 150 40
// Dynamically adjust distance based on edge count between this node pair
// Base distance 150, add 40 per additional edge
const baseDistance = 150
const edgeCount = d.pairTotal || 1
return baseDistance + (edgeCount - 1) * 50
@ -480,7 +480,7 @@ const renderGraph = () => {
.force('charge', d3.forceManyBody().strength(-400))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collide', d3.forceCollide(50))
//
// Add a centering force so isolated node groups gather near the center
.force('x', d3.forceX(width / 2).strength(0.04))
.force('y', d3.forceY(height / 2).strength(0.04))
@ -493,39 +493,39 @@ const renderGraph = () => {
g.attr('transform', event.transform)
}))
// Links - 使 path 线
// Links - use path to support curves
const linkGroup = g.append('g').attr('class', 'links')
// 线
// Compute curved path
const getLinkPath = (d) => {
const sx = d.source.x, sy = d.source.y
const tx = d.target.x, ty = d.target.y
//
// Detect self-loop
if (d.isSelfLoop) {
//
// Self-loop: draw an arc leaving the node and returning to it
const loopRadius = 30
//
const x1 = sx + 8 //
// Leave from the right side of the node, loop around, and return
const x1 = sx + 8 // Start point offset
const y1 = sy - 4
const x2 = sx + 8 //
const x2 = sx + 8 // End point offset
const y2 = sy + 4
// 使sweep-flag=1
// Draw the self-loop as an arc (sweep-flag=1, clockwise)
return `M${x1},${y1} A${loopRadius},${loopRadius} 0 1,1 ${x2},${y2}`
}
if (d.curvature === 0) {
// 线
// Straight line
return `M${sx},${sy} L${tx},${ty}`
}
// 线 -
// Compute curve control points - dynamically adjusted by edge count and distance
const dx = tx - sx, dy = ty - sy
const dist = Math.sqrt(dx * dx + dy * dy)
// 线线
//
// Perpendicular offset from the connection line, scaled by distance so curves stay visible
// More edges -> larger offset-to-distance ratio
const pairTotal = d.pairTotal || 1
const offsetRatio = 0.25 + pairTotal * 0.05 // 25%5%
const offsetRatio = 0.25 + pairTotal * 0.05 // Base 25%, +5% per extra edge
const baseOffset = Math.max(35, dist * offsetRatio)
const offsetX = -dy / dist * d.curvature * baseOffset
const offsetY = dx / dist * d.curvature * baseOffset
@ -535,14 +535,14 @@ const renderGraph = () => {
return `M${sx},${sy} Q${cx},${cy} ${tx},${ty}`
}
// 线
// Compute curve midpoint (used for label placement)
const getLinkMidpoint = (d) => {
const sx = d.source.x, sy = d.source.y
const tx = d.target.x, ty = d.target.y
//
// Detect self-loop
if (d.isSelfLoop) {
//
// Self-loop label position: right of the node
return { x: sx + 70, y: sy }
}
@ -550,7 +550,7 @@ const renderGraph = () => {
return { x: (sx + tx) / 2, y: (sy + ty) / 2 }
}
// 线 t=0.5
// Midpoint of a quadratic Bezier curve at t=0.5
const dx = tx - sx, dy = ty - sy
const dist = Math.sqrt(dx * dx + dy * dy)
const pairTotal = d.pairTotal || 1
@ -561,7 +561,7 @@ const renderGraph = () => {
const cx = (sx + tx) / 2 + offsetX
const cy = (sy + ty) / 2 + offsetY
// 线 B(t) = (1-t)²P0 + 2(1-t)tP1 + t²P2, t=0.5
// Quadratic Bezier formula B(t) = (1-t)²P0 + 2(1-t)tP1 + t²P2, t=0.5
const midX = 0.25 * sx + 0.5 * cx + 0.25 * tx
const midY = 0.25 * sy + 0.5 * cy + 0.25 * ty
@ -577,11 +577,11 @@ const renderGraph = () => {
.style('cursor', 'pointer')
.on('click', (event, d) => {
event.stopPropagation()
//
// Reset the previously selected edge style
linkGroup.selectAll('path').attr('stroke', '#C0C0C0').attr('stroke-width', 1.5)
linkLabelBg.attr('fill', 'rgba(255,255,255,0.95)')
linkLabels.attr('fill', '#666')
//
// Highlight the currently selected edge
d3.select(event.target).attr('stroke', '#3498db').attr('stroke-width', 3)
selectedItem.value = {
@ -590,7 +590,7 @@ const renderGraph = () => {
}
})
// Link labels background (使)
// Link labels background (white background keeps text crisp)
const linkLabelBg = linkGroup.selectAll('rect')
.data(edges)
.enter().append('rect')
@ -605,7 +605,7 @@ const renderGraph = () => {
linkGroup.selectAll('path').attr('stroke', '#C0C0C0').attr('stroke-width', 1.5)
linkLabelBg.attr('fill', 'rgba(255,255,255,0.95)')
linkLabels.attr('fill', '#666')
//
// Highlight the matching edge
link.filter(l => l === d).attr('stroke', '#3498db').attr('stroke-width', 3)
d3.select(event.target).attr('fill', 'rgba(52, 152, 219, 0.1)')
@ -633,7 +633,7 @@ const renderGraph = () => {
linkGroup.selectAll('path').attr('stroke', '#C0C0C0').attr('stroke-width', 1.5)
linkLabelBg.attr('fill', 'rgba(255,255,255,0.95)')
linkLabels.attr('fill', '#666')
//
// Highlight the matching edge
link.filter(l => l === d).attr('stroke', '#3498db').attr('stroke-width', 3)
d3.select(event.target).attr('fill', '#3498db')
@ -643,7 +643,7 @@ const renderGraph = () => {
}
})
//
// Save references for external show/hide control
linkLabelsRef = linkLabels
linkLabelBgRef = linkLabelBg
@ -661,7 +661,7 @@ const renderGraph = () => {
.style('cursor', 'pointer')
.call(d3.drag()
.on('start', (event, d) => {
// 仿
// Only record position; do not restart simulation (distinguish click from drag)
d.fx = d.x
d.fy = d.y
d._dragStartX = event.x
@ -669,13 +669,13 @@ const renderGraph = () => {
d._isDragging = false
})
.on('drag', (event, d) => {
//
// Detect if a real drag has started (movement past threshold)
const dx = event.x - d._dragStartX
const dy = event.y - d._dragStartY
const distance = Math.sqrt(dx * dx + dy * dy)
if (!d._isDragging && distance > 3) {
// 仿
// First time we detect a real drag - restart the simulation
d._isDragging = true
simulation.alphaTarget(0.3).restart()
}
@ -686,7 +686,7 @@ const renderGraph = () => {
}
})
.on('end', (event, d) => {
// 仿
// Only let the simulation wind down if there was an actual drag
if (d._isDragging) {
simulation.alphaTarget(0)
}
@ -697,12 +697,12 @@ const renderGraph = () => {
)
.on('click', (event, d) => {
event.stopPropagation()
//
// Reset all node styles
node.attr('stroke', '#fff').attr('stroke-width', 2.5)
linkGroup.selectAll('path').attr('stroke', '#C0C0C0').attr('stroke-width', 1.5)
//
// Highlight the selected node
d3.select(event.target).attr('stroke', '#E91E63').attr('stroke-width', 4)
//
// Highlight edges connected to this node
link.filter(l => l.source.id === d.id || l.target.id === d.id)
.attr('stroke', '#E91E63')
.attr('stroke-width', 2.5)
@ -739,19 +739,19 @@ const renderGraph = () => {
.style('font-family', 'system-ui, sans-serif')
simulation.on('tick', () => {
// 线
// Update curved paths
link.attr('d', d => getLinkPath(d))
//
// Update edge label positions (no rotation; horizontal is clearer)
linkLabels.each(function(d) {
const mid = getLinkMidpoint(d)
d3.select(this)
.attr('x', mid.x)
.attr('y', mid.y)
.attr('transform', '') //
.attr('transform', '') // Remove rotation, keep horizontal
})
//
// Update edge label backgrounds
linkLabelBg.each(function(d, i) {
const mid = getLinkMidpoint(d)
const textEl = linkLabels.nodes()[i]
@ -761,7 +761,7 @@ const renderGraph = () => {
.attr('y', mid.y - bbox.height / 2 - 2)
.attr('width', bbox.width + 8)
.attr('height', bbox.height + 4)
.attr('transform', '') //
.attr('transform', '') // Remove rotation
})
node
@ -773,7 +773,7 @@ const renderGraph = () => {
.attr('y', d => d.y)
})
//
// Click on empty space to close the detail panel
svg.on('click', () => {
selectedItem.value = null
node.attr('stroke', '#fff').attr('stroke-width', 2.5)
@ -787,7 +787,7 @@ watch(() => props.graphData, () => {
nextTick(renderGraph)
}, { deep: true })
//
// Watch the edge-label visibility toggle
watch(showEdgeLabels, (newVal) => {
if (linkLabelsRef) {
linkLabelsRef.style('display', newVal ? 'block' : 'none')
@ -1250,7 +1250,7 @@ input:checked + .slider:before {
50% { opacity: 1; transform: scale(1.15); filter: drop-shadow(0 0 8px rgba(76, 175, 80, 0.6)); }
}
/* 模拟结束后的提示样式 */
/* Post-simulation hint styles */
.graph-building-hint.finished-hint {
background: rgba(0, 0, 0, 0.65);
border: 1px solid rgba(255, 255, 255, 0.1);

View File

@ -4,20 +4,20 @@
:class="{ 'no-projects': projects.length === 0 && !loading }"
ref="historyContainer"
>
<!-- 背景装饰技术网格线只在有项目时显示 -->
<!-- Background decoration: technical grid lines (only shown when there are projects) -->
<div v-if="projects.length > 0 || loading" class="tech-grid-bg">
<div class="grid-pattern"></div>
<div class="gradient-overlay"></div>
</div>
<!-- 标题区域 -->
<!-- Title section -->
<div class="section-header">
<div class="section-line"></div>
<span class="section-title">{{ $t('history.title') }}</span>
<div class="section-line"></div>
</div>
<!-- 卡片容器只在有项目时显示 -->
<!-- Card container (only shown when there are projects) -->
<div v-if="projects.length > 0" class="cards-container" :class="{ expanded: isExpanded }" :style="containerStyle">
<div
v-for="(project, index) in projects"
@ -29,7 +29,7 @@
@mouseleave="hoveringCard = null"
@click="navigateToProject(project)"
>
<!-- 卡片头部simulation_id 功能可用状态 -->
<!-- Card header: simulation_id and feature availability status -->
<div class="card-header">
<span class="card-id">{{ formatSimulationId(project.simulation_id) }}</span>
<div class="card-status-icons">
@ -50,12 +50,12 @@
</div>
</div>
<!-- 文件列表区域 -->
<!-- File list area -->
<div class="card-files-wrapper">
<!-- 角落装饰 - 取景框风格 -->
<!-- Corner decoration - viewfinder style -->
<div class="corner-mark top-left-only"></div>
<!-- 文件列表 -->
<!-- File list -->
<div class="files-list" v-if="project.files && project.files.length > 0">
<div
v-for="(file, fileIndex) in project.files.slice(0, 3)"
@ -65,25 +65,25 @@
<span class="file-tag" :class="getFileType(file.filename)">{{ getFileTypeLabel(file.filename) }}</span>
<span class="file-name">{{ truncateFilename(file.filename, 20) }}</span>
</div>
<!-- 如果有更多文件显示提示 -->
<!-- If there are more files, show a hint -->
<div v-if="project.files.length > 3" class="files-more">
{{ $t('history.moreFiles', { count: project.files.length - 3 }) }}
</div>
</div>
<!-- 无文件时的占位 -->
<!-- Placeholder when there are no files -->
<div class="files-empty" v-else>
<span class="empty-file-icon"></span>
<span class="empty-file-text">{{ $t('history.noFiles') }}</span>
</div>
</div>
<!-- 卡片标题使用模拟需求的前20字作为标题 -->
<!-- Card title (uses the first 20 characters of the simulation requirement as the title) -->
<h3 class="card-title">{{ getSimulationTitle(project.simulation_requirement) }}</h3>
<!-- 卡片描述模拟需求完整展示 -->
<!-- Card description (full display of simulation requirement) -->
<p class="card-desc">{{ truncateText(project.simulation_requirement, 55) }}</p>
<!-- 卡片底部 -->
<!-- Card footer -->
<div class="card-footer">
<div class="card-datetime">
<span class="card-date">{{ formatDate(project.created_at) }}</span>
@ -94,23 +94,23 @@
</span>
</div>
<!-- 底部装饰线 (hover时展开) -->
<!-- Bottom decoration line (expands on hover) -->
<div class="card-bottom-line"></div>
</div>
</div>
<!-- 加载状态 -->
<!-- Loading state -->
<div v-if="loading" class="loading-state">
<span class="loading-spinner"></span>
<span class="loading-text">{{ $t('history.loadingText') }}</span>
</div>
<!-- 历史回放详情弹窗 -->
<!-- History replay details modal -->
<Teleport to="body">
<Transition name="modal">
<div v-if="selectedProject" class="modal-overlay" @click.self="closeModal">
<div class="modal-content">
<!-- 弹窗头部 -->
<!-- Modal header -->
<div class="modal-header">
<div class="modal-title-section">
<span class="modal-id">{{ formatSimulationId(selectedProject.simulation_id) }}</span>
@ -122,15 +122,15 @@
<button class="modal-close" @click="closeModal">×</button>
</div>
<!-- 弹窗内容 -->
<!-- Modal content -->
<div class="modal-body">
<!-- 模拟需求 -->
<!-- Simulation requirement -->
<div class="modal-section">
<div class="modal-label">{{ $t('history.simRequirement') }}</div>
<div class="modal-requirement">{{ selectedProject.simulation_requirement || $t('common.none') }}</div>
</div>
<!-- 文件列表 -->
<!-- File list -->
<div class="modal-section">
<div class="modal-label">{{ $t('history.relatedFiles') }}</div>
<div class="modal-files" v-if="selectedProject.files && selectedProject.files.length > 0">
@ -143,14 +143,14 @@
</div>
</div>
<!-- 推演回放分割线 -->
<!-- Replay divider -->
<div class="modal-divider">
<span class="divider-line"></span>
<span class="divider-text">{{ $t('history.replayTitle') }}</span>
<span class="divider-line"></span>
</div>
<!-- 导航按钮 -->
<!-- Navigation buttons -->
<div class="modal-actions">
<button
class="modal-btn btn-project"
@ -179,7 +179,7 @@
<span class="btn-text">{{ $t('history.step4Button') }}</span>
</button>
</div>
<!-- 不可回放提示 -->
<!-- Non-replayable hint -->
<div class="modal-playback-hint">
<span class="hint-text">{{ $t('history.replayHint') }}</span>
</div>
@ -220,56 +220,56 @@ const router = useRouter()
const route = useRoute()
const { t } = useI18n()
//
// State
const projects = ref([])
const loading = ref(true)
const isExpanded = ref(false)
const hoveringCard = ref(null)
const historyContainer = ref(null)
const selectedProject = ref(null) //
const selectedProject = ref(null) // Currently selected project (used for the modal)
let observer = null
let isAnimating = false //
let expandDebounceTimer = null //
let pendingState = null //
let isAnimating = false // Animation lock, prevents flickering
let expandDebounceTimer = null // Debounce timer
let pendingState = null // Records the target state to be applied
// -
// Card layout configuration - adjusted to a wider ratio
const CARDS_PER_ROW = 4
const CARD_WIDTH = 280
const CARD_HEIGHT = 280
const CARD_GAP = 24
//
// Dynamically compute the container height style
const containerStyle = computed(() => {
if (!isExpanded.value) {
//
// Collapsed state: fixed height
return { minHeight: '420px' }
}
//
// Expanded state: dynamically compute height based on the number of cards
const total = projects.value.length
if (total === 0) {
return { minHeight: '280px' }
}
const rows = Math.ceil(total / CARDS_PER_ROW)
// * + (-1) * +
// Compute the actual required height: rows * card height + (rows-1) * gap + a small bottom padding
const expandedHeight = rows * CARD_HEIGHT + (rows - 1) * CARD_GAP + 10
return { minHeight: `${expandedHeight}px` }
})
//
// Get card style
const getCardStyle = (index) => {
const total = projects.value.length
if (isExpanded.value) {
//
// Expanded state: grid layout
const transition = 'transform 700ms cubic-bezier(0.23, 1, 0.32, 1), opacity 700ms cubic-bezier(0.23, 1, 0.32, 1), box-shadow 0.3s ease, border-color 0.3s ease'
const col = index % CARDS_PER_ROW
const row = Math.floor(index / CARDS_PER_ROW)
//
// Compute the number of cards in the current row to ensure each row is centered
const currentRowStart = row * CARDS_PER_ROW
const currentRowCards = Math.min(CARDS_PER_ROW, total - currentRowStart)
@ -279,7 +279,7 @@ const getCardStyle = (index) => {
const colInRow = index % CARDS_PER_ROW
const x = startX + colInRow * (CARD_WIDTH + CARD_GAP)
//
// Expand downward, increasing the spacing from the title
const y = 20 + row * (CARD_HEIGHT + CARD_GAP)
return {
@ -289,14 +289,14 @@ const getCardStyle = (index) => {
transition: transition
}
} else {
//
// Collapsed state: fan-shaped stack
const transition = 'transform 700ms cubic-bezier(0.23, 1, 0.32, 1), opacity 700ms cubic-bezier(0.23, 1, 0.32, 1), box-shadow 0.3s ease, border-color 0.3s ease'
const centerIndex = (total - 1) / 2
const offset = index - centerIndex
const x = offset * 35
//
// Adjust the start position, close to the title while maintaining proper spacing
const y = 25 + Math.abs(offset) * 8
const r = offset * 3
const s = 0.95 - Math.abs(offset) * 0.05
@ -310,24 +310,24 @@ const getCardStyle = (index) => {
}
}
//
// Get the style class based on the rounds progress
const getProgressClass = (simulation) => {
const current = simulation.current_round || 0
const total = simulation.total_rounds || 0
if (total === 0 || current === 0) {
//
// Not started
return 'not-started'
} else if (current >= total) {
//
// Completed
return 'completed'
} else {
//
// In progress
return 'in-progress'
}
}
//
// Format date (display only the date portion)
const formatDate = (dateStr) => {
if (!dateStr) return ''
try {
@ -338,7 +338,7 @@ const formatDate = (dateStr) => {
}
}
// :
// Format time (display hour:minute)
const formatTime = (dateStr) => {
if (!dateStr) return ''
try {
@ -351,27 +351,27 @@ const formatTime = (dateStr) => {
}
}
//
// Truncate text
const truncateText = (text, maxLength) => {
if (!text) return ''
return text.length > maxLength ? text.slice(0, maxLength) + '...' : text
}
// 20
// Generate a title from the simulation requirement (take the first 20 characters)
const getSimulationTitle = (requirement) => {
if (!requirement) return t('history.untitledSimulation')
const title = requirement.slice(0, 20)
return requirement.length > 20 ? title + '...' : title
}
// simulation_id 6
// Format simulation_id display (truncate to the first 6 characters)
const formatSimulationId = (simulationId) => {
if (!simulationId) return 'SIM_UNKNOWN'
const prefix = simulationId.replace('sim_', '').slice(0, 6)
return `SIM_${prefix.toUpperCase()}`
}
// /
// Format rounds display (current round / total rounds)
const formatRounds = (simulation) => {
const current = simulation.current_round || 0
const total = simulation.total_rounds || 0
@ -379,7 +379,7 @@ const formatRounds = (simulation) => {
return t('history.roundsProgress', { current, total })
}
//
// Get file type (used for styling)
const getFileType = (filename) => {
if (!filename) return 'other'
const ext = filename.split('.').pop()?.toLowerCase()
@ -395,14 +395,14 @@ const getFileType = (filename) => {
return typeMap[ext] || 'other'
}
//
// Get file type label text
const getFileTypeLabel = (filename) => {
if (!filename) return 'FILE'
const ext = filename.split('.').pop()?.toUpperCase()
return ext || 'FILE'
}
//
// Truncate filename (preserving extension)
const truncateFilename = (filename, maxLength) => {
if (!filename) return t('history.unknownFile')
if (filename.length <= maxLength) return filename
@ -413,17 +413,17 @@ const truncateFilename = (filename, maxLength) => {
return truncatedName + ext
}
//
// Open project details modal
const navigateToProject = (simulation) => {
selectedProject.value = simulation
}
//
// Close modal
const closeModal = () => {
selectedProject.value = null
}
// Project
// Navigate to graph build page (Project)
const goToProject = () => {
if (selectedProject.value?.project_id) {
router.push({
@ -434,7 +434,7 @@ const goToProject = () => {
}
}
// Simulation
// Navigate to environment setup page (Simulation)
const goToSimulation = () => {
if (selectedProject.value?.simulation_id) {
router.push({
@ -445,7 +445,7 @@ const goToSimulation = () => {
}
}
// Report
// Navigate to analysis report page (Report)
const goToReport = () => {
if (selectedProject.value?.report_id) {
router.push({
@ -456,7 +456,7 @@ const goToReport = () => {
}
}
//
// Load history projects
const loadHistory = async () => {
try {
loading.value = true
@ -487,14 +487,14 @@ const loadHistory = async () => {
projects.value = merged
} catch (error) {
console.error('加载历史项目失败:', error)
console.error('Failed to load history:', error)
projects.value = []
} finally {
loading.value = false
}
}
// IntersectionObserver
// Initialize IntersectionObserver
const initObserver = () => {
if (observer) {
observer.disconnect()
@ -505,47 +505,47 @@ const initObserver = () => {
entries.forEach((entry) => {
const shouldExpand = entry.isIntersecting
//
// Update the pending target state (always record the latest target state, even when animating)
pendingState = shouldExpand
//
// Clear the previous debounce timer (a new scroll intent overrides the old one)
if (expandDebounceTimer) {
clearTimeout(expandDebounceTimer)
expandDebounceTimer = null
}
//
// If animating, just record the state and handle it when the animation ends
if (isAnimating) return
//
// If the target state matches the current state, no action needed
if (shouldExpand === isExpanded.value) {
pendingState = null
return
}
// 使
// (50ms)(200ms)
// Use debounce to delay state switching and prevent rapid flickering
// Use a shorter delay when expanding (50ms) and a longer one when collapsing (200ms) for stability
const delay = shouldExpand ? 50 : 200
expandDebounceTimer = setTimeout(() => {
//
// Check if animating
if (isAnimating) return
//
// Check whether the pending state still needs to be applied (it may have been overridden by a later scroll)
if (pendingState === null || pendingState === isExpanded.value) return
//
// Set the animation lock
isAnimating = true
isExpanded.value = pendingState
pendingState = null
//
// After the animation completes, release the lock and check for pending state changes
setTimeout(() => {
isAnimating = false
//
// After the animation ends, check whether a new pending state exists
if (pendingState !== null && pendingState !== isExpanded.value) {
//
// Wait a short while before applying, to avoid switching too quickly
expandDebounceTimer = setTimeout(() => {
if (pendingState !== null && pendingState !== isExpanded.value) {
isAnimating = true
@ -562,20 +562,20 @@ const initObserver = () => {
})
},
{
// 使使
// Use multiple thresholds for smoother detection
threshold: [0.4, 0.6, 0.8],
// rootMargin
// Adjust rootMargin, shrink upward from the viewport bottom so the user must scroll further to trigger expansion
rootMargin: '0px 0px -150px 0px'
}
)
//
// Start observing
if (historyContainer.value) {
observer.observe(historyContainer.value)
}
}
//
// Watch for route changes and reload data when returning to the homepage
watch(() => route.path, (newPath) => {
if (newPath === '/') {
loadHistory()
@ -583,28 +583,28 @@ watch(() => route.path, (newPath) => {
})
onMounted(async () => {
// DOM
// Ensure the DOM is rendered before loading data
await nextTick()
await loadHistory()
// DOM
// Wait for the DOM to render before initializing the observer
setTimeout(() => {
initObserver()
}, 100)
})
// 使 keep-alive
// If keep-alive is used, reload data when the component is activated
onActivated(() => {
loadHistory()
})
onUnmounted(() => {
// Intersection Observer
// Clean up the Intersection Observer
if (observer) {
observer.disconnect()
observer = null
}
//
// Clean up the debounce timer
if (expandDebounceTimer) {
clearTimeout(expandDebounceTimer)
expandDebounceTimer = null
@ -613,7 +613,7 @@ onUnmounted(() => {
</script>
<style scoped>
/* 容器 */
/* Container */
.history-database {
position: relative;
width: 100%;
@ -623,13 +623,13 @@ onUnmounted(() => {
overflow: visible;
}
/* 无项目时简化显示 */
/* Simplified display when there are no projects */
.history-database.no-projects {
min-height: auto;
padding: 40px 0 20px;
}
/* 技术网格背景 */
/* Technical grid background */
.tech-grid-bg {
position: absolute;
top: 0;
@ -640,7 +640,7 @@ onUnmounted(() => {
pointer-events: none;
}
/* 使用CSS背景图案创建固定间距的正方形网格 */
/* Use a CSS background pattern to create a fixed-pitch square grid */
.grid-pattern {
position: absolute;
top: 0;
@ -651,7 +651,7 @@ onUnmounted(() => {
linear-gradient(to right, rgba(0, 0, 0, 0.05) 1px, transparent 1px),
linear-gradient(to bottom, rgba(0, 0, 0, 0.05) 1px, transparent 1px);
background-size: 50px 50px;
/* 从左上角开始定位,高度变化时只在底部扩展,不影响已有网格位置 */
/* Position from the top-left corner; when the height changes, only the bottom extends, leaving existing grid positions untouched */
background-position: top left;
}
@ -667,7 +667,7 @@ onUnmounted(() => {
pointer-events: none;
}
/* 标题区域 */
/* Title section */
.section-header {
position: relative;
z-index: 100;
@ -695,7 +695,7 @@ onUnmounted(() => {
text-transform: uppercase;
}
/* 卡片容器 */
/* Card container */
.cards-container {
position: relative;
display: flex;
@ -703,10 +703,10 @@ onUnmounted(() => {
align-items: flex-start;
padding: 0 40px;
transition: min-height 700ms cubic-bezier(0.23, 1, 0.32, 1);
/* min-height 由 JS 动态计算,根据卡片数量自适应 */
/* min-height is dynamically computed by JS, adapting to the number of cards */
}
/* 项目卡片 */
/* Project card */
.project-card {
position: absolute;
width: 280px;
@ -729,7 +729,7 @@ onUnmounted(() => {
z-index: 1000 !important;
}
/* 卡片头部 */
/* Card header */
.card-header {
display: flex;
justify-content: space-between;
@ -747,7 +747,7 @@ onUnmounted(() => {
font-weight: 500;
}
/* 功能状态图标组 */
/* Feature status icon group */
.card-status-icons {
display: flex;
align-items: center;
@ -764,17 +764,17 @@ onUnmounted(() => {
opacity: 1;
}
/* 不同功能的颜色 */
.status-icon:nth-child(1).available { color: #3B82F6; } /* 图谱构建 - 蓝色 */
.status-icon:nth-child(2).available { color: #F59E0B; } /* 环境搭建 - 橙色 */
.status-icon:nth-child(3).available { color: #10B981; } /* 分析报告 - 绿色 */
/* Colors for different features */
.status-icon:nth-child(1).available { color: #3B82F6; } /* Graph build - blue */
.status-icon:nth-child(2).available { color: #F59E0B; } /* Environment setup - orange */
.status-icon:nth-child(3).available { color: #10B981; } /* Analysis report - green */
.status-icon.unavailable {
color: #D1D5DB;
opacity: 0.5;
}
/* 轮数进度显示 */
/* Rounds progress display */
.card-progress {
display: flex;
align-items: center;
@ -788,13 +788,13 @@ onUnmounted(() => {
font-size: 0.5rem;
}
/* 进度状态颜色 */
.card-progress.completed { color: #10B981; } /* 已完成 - 绿色 */
.card-progress.in-progress { color: #F59E0B; } /* 进行中 - 橙色 */
.card-progress.not-started { color: #9CA3AF; } /* 未开始 - 灰色 */
/* Progress status colors */
.card-progress.completed { color: #10B981; } /* Completed - green */
.card-progress.in-progress { color: #F59E0B; } /* In progress - orange */
.card-progress.not-started { color: #9CA3AF; } /* Not started - gray */
.card-status.pending { color: #9CA3AF; }
/* 文件列表区域 */
/* File list area */
.card-files-wrapper {
position: relative;
width: 100%;
@ -814,7 +814,7 @@ onUnmounted(() => {
gap: 4px;
}
/* 更多文件提示 */
/* More files hint */
.files-more {
display: flex;
align-items: center;
@ -844,7 +844,7 @@ onUnmounted(() => {
border-color: #e5e7eb;
}
/* 简约文件标签样式 */
/* Minimal file tag style */
.file-tag {
display: inline-flex;
align-items: center;
@ -862,7 +862,7 @@ onUnmounted(() => {
min-width: 28px;
}
/* 低饱和度配色方案 - Morandi色系 */
/* Low-saturation color scheme - Morandi palette */
.file-tag.pdf { background: #f2e6e6; color: #a65a5a; }
.file-tag.doc { background: #e6eff5; color: #5a7ea6; }
.file-tag.xls { background: #e6f2e8; color: #5aa668; }
@ -883,7 +883,7 @@ onUnmounted(() => {
letter-spacing: 0.1px;
}
/* 无文件时的占位 */
/* Placeholder when there are no files */
.files-empty {
display: flex;
align-items: center;
@ -904,13 +904,13 @@ onUnmounted(() => {
letter-spacing: 0.5px;
}
/* 悬停时文件区域效果 */
/* File area effect on hover */
.project-card:hover .card-files-wrapper {
border-color: #d1d5db;
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
}
/* 角落装饰 */
/* Corner decoration */
.corner-mark.top-left-only {
position: absolute;
top: 6px;
@ -923,7 +923,7 @@ onUnmounted(() => {
z-index: 10;
}
/* 卡片标题 */
/* Card title */
.card-title {
font-family: 'Inter', -apple-system, sans-serif;
font-size: 0.9rem;
@ -941,7 +941,7 @@ onUnmounted(() => {
color: #2563EB;
}
/* 卡片描述 */
/* Card description */
.card-desc {
font-family: 'Inter', sans-serif;
font-size: 0.75rem;
@ -955,7 +955,7 @@ onUnmounted(() => {
-webkit-box-orient: vertical;
}
/* 卡片底部 */
/* Card footer */
.card-footer {
position: relative;
display: flex;
@ -969,14 +969,14 @@ onUnmounted(() => {
font-weight: 500;
}
/* 日期时间组合 */
/* Date and time combination */
.card-datetime {
display: flex;
align-items: center;
gap: 8px;
}
/* 底部轮数进度显示 */
/* Bottom rounds progress display */
.card-footer .card-progress {
display: flex;
align-items: center;
@ -990,12 +990,12 @@ onUnmounted(() => {
font-size: 0.5rem;
}
/* 进度状态颜色 - 底部 */
/* Progress status colors - bottom */
.card-footer .card-progress.completed { color: #10B981; }
.card-footer .card-progress.in-progress { color: #F59E0B; }
.card-footer .card-progress.not-started { color: #9CA3AF; }
/* 底部装饰线 */
/* Bottom decoration line */
.card-bottom-line {
position: absolute;
bottom: 0;
@ -1011,7 +1011,7 @@ onUnmounted(() => {
width: 100%;
}
/* 空状态 */
/* Empty state */
.empty-state, .loading-state {
display: flex;
flex-direction: column;
@ -1039,7 +1039,7 @@ onUnmounted(() => {
to { transform: rotate(360deg); }
}
/* 响应式 */
/* Responsive */
@media (max-width: 1200px) {
.project-card {
width: 240px;
@ -1055,7 +1055,7 @@ onUnmounted(() => {
}
}
/* ===== 历史回放详情弹窗样式 ===== */
/* ===== History replay details modal styles ===== */
.modal-overlay {
position: fixed;
top: 0;
@ -1081,7 +1081,7 @@ onUnmounted(() => {
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
/* 动画过渡 */
/* Animation transition */
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.3s ease;
@ -1110,7 +1110,7 @@ onUnmounted(() => {
opacity: 0;
}
/* 弹窗头部 */
/* Modal header */
.modal-header {
display: flex;
justify-content: space-between;
@ -1177,7 +1177,7 @@ onUnmounted(() => {
color: #111827;
}
/* 弹窗内容 */
/* Modal content */
.modal-body {
padding: 24px 32px;
}
@ -1219,7 +1219,7 @@ onUnmounted(() => {
padding-right: 4px;
}
/* 自定义滚动条样式 */
/* Custom scrollbar style */
.modal-files::-webkit-scrollbar {
width: 4px;
}
@ -1273,7 +1273,7 @@ onUnmounted(() => {
text-align: center;
}
/* 推演回放分割线 */
/* Replay divider */
.modal-divider {
display: flex;
align-items: center;
@ -1297,7 +1297,7 @@ onUnmounted(() => {
white-space: nowrap;
}
/* 导航按钮 */
/* Navigation buttons */
.modal-actions {
display: flex;
gap: 16px;
@ -1364,7 +1364,7 @@ onUnmounted(() => {
color: #111827;
}
/* 不可回放提示 */
/* Non-replayable hint */
.modal-playback-hint {
display: flex;
align-items: center;

View File

@ -210,10 +210,10 @@ const selectedOntologyItem = ref(null)
const logContent = ref(null)
const creatingSimulation = ref(false)
// - simulation
// Move on to environment setup create a simulation and navigate
const handleEnterEnvSetup = async () => {
if (!props.projectData?.project_id || !props.projectData?.graph_id) {
console.error('缺少项目或图谱信息')
console.error('Missing project or graph information')
return
}
@ -228,17 +228,17 @@ const handleEnterEnvSetup = async () => {
})
if (res.success && res.data?.simulation_id) {
// simulation
// Navigate to the simulation page
router.push({
name: 'Simulation',
params: { simulationId: res.data.simulation_id }
})
} else {
console.error('创建模拟失败:', res.error)
console.error('Failed to create simulation:', res.error)
alert(t('step1.createSimulationFailed', { error: res.error || t('common.unknownError') }))
}
} catch (err) {
console.error('创建模拟异常:', err)
console.error('Exception creating simulation:', err)
alert(t('step1.createSimulationException', { error: err.message }))
} finally {
creatingSimulation.value = false

View File

@ -1,7 +1,7 @@
<template>
<div class="env-setup-panel">
<div class="scroll-container">
<!-- Step 01: 模拟实例 -->
<!-- Step 01: Simulation Instance -->
<div class="step-card" :class="{ 'active': phase === 0, 'completed': phase > 0 }">
<div class="card-header">
<div class="step-info">
@ -41,7 +41,7 @@
</div>
</div>
<!-- Step 02: 生成 Agent 人设 -->
<!-- Step 02: Generate Agent Personas -->
<div class="step-card" :class="{ 'active': phase === 1, 'completed': phase > 1 }">
<div class="card-header">
<div class="step-info">
@ -113,7 +113,7 @@
</div>
</div>
<!-- Step 03: 生成双平台模拟配置 -->
<!-- Step 03: Generate Dual-Platform Simulation Config -->
<div class="step-card" :class="{ 'active': phase === 2, 'completed': phase > 2 }">
<div class="card-header">
<div class="step-info">
@ -135,7 +135,7 @@
<!-- Config Preview -->
<div v-if="simulationConfig" class="config-detail-panel">
<!-- 时间配置 -->
<!-- Time Config -->
<div class="config-block">
<div class="config-grid">
<div class="config-item">
@ -179,7 +179,7 @@
</div>
</div>
<!-- Agent 配置 -->
<!-- Agent Config -->
<div class="config-block">
<div class="config-block-header">
<span class="config-block-title">{{ $t('step2.agentConfig') }}</span>
@ -191,7 +191,7 @@
:key="agent.agent_id"
class="agent-card"
>
<!-- 卡片头部 -->
<!-- Card Header -->
<div class="agent-card-header">
<div class="agent-identity">
<span class="agent-id">Agent {{ agent.agent_id }}</span>
@ -203,7 +203,7 @@
</div>
</div>
<!-- 活跃时间轴 -->
<!-- Active Timeline -->
<div class="agent-timeline">
<span class="timeline-label">{{ $t('step2.activeTimePeriod') }}</span>
<div class="mini-timeline">
@ -224,7 +224,7 @@
</div>
</div>
<!-- 行为参数 -->
<!-- Behavior Parameters -->
<div class="agent-params">
<div class="param-group">
<div class="param-item">
@ -264,7 +264,7 @@
</div>
</div>
<!-- 平台配置 -->
<!-- Platform Config -->
<div class="config-block">
<div class="config-block-header">
<span class="config-block-title">{{ $t('step2.recommendAlgoConfig') }}</span>
@ -327,7 +327,7 @@
</div>
</div>
<!-- LLM 配置推理 -->
<!-- LLM Config Reasoning -->
<div v-if="simulationConfig.generation_reasoning" class="config-block">
<div class="config-block-header">
<span class="config-block-title">{{ $t('step2.llmConfigReasoning') }}</span>
@ -346,7 +346,7 @@
</div>
</div>
<!-- Step 04: 初始激活编排 -->
<!-- Step 04: Initial Activation Orchestration -->
<div class="step-card" :class="{ 'active': phase === 3, 'completed': phase > 3 }">
<div class="card-header">
<div class="step-info">
@ -367,7 +367,7 @@
</p>
<div v-if="simulationConfig?.event_config" class="orchestration-content">
<!-- 叙事方向 -->
<!-- Narrative Direction -->
<div class="narrative-box">
<span class="box-label narrative-label">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="special-icon">
@ -385,7 +385,7 @@
<p class="narrative-text">{{ simulationConfig.event_config.narrative_direction }}</p>
</div>
<!-- 热点话题 -->
<!-- Hot Topics -->
<div class="topics-section">
<span class="box-label">{{ $t('step2.initialHotTopics') }}</span>
<div class="hot-topics-grid">
@ -395,7 +395,7 @@
</div>
</div>
<!-- 初始帖子流 -->
<!-- Initial Post Stream -->
<div class="initial-posts-section">
<span class="box-label">{{ $t('step2.initialActivationSeq', { count: simulationConfig.event_config.initial_posts.length }) }}</span>
<div class="posts-timeline">
@ -418,7 +418,7 @@
</div>
</div>
<!-- Step 05: 准备完成 -->
<!-- Step 05: Setup Complete -->
<div class="step-card" :class="{ 'active': phase === 4 }">
<div class="card-header">
<div class="step-info">
@ -435,7 +435,7 @@
<p class="api-note">POST /api/simulation/start</p>
<p class="description">{{ $t('step2.setupCompleteDesc') }}</p>
<!-- 模拟轮数配置 - 只有在配置生成完成且轮数计算出来后才显示 -->
<!-- Simulation Rounds Config - Only shown after config generation completes and rounds are calculated -->
<div v-if="simulationConfig && autoGeneratedRounds" class="rounds-config-section">
<div class="rounds-header">
<div class="header-left">
@ -544,7 +544,7 @@
</div>
<div class="modal-body">
<!-- 基本信息 -->
<!-- Basic Info -->
<div class="modal-info-grid">
<div class="info-item">
<span class="info-label">{{ $t('step2.profileModalAge') }}</span>
@ -564,13 +564,13 @@
</div>
</div>
<!-- 简介 -->
<!-- Bio -->
<div class="modal-section">
<span class="section-label">{{ $t('step2.profileModalBio') }}</span>
<p class="section-bio">{{ selectedProfile.bio || $t('step2.noBio') }}</p>
</div>
<!-- 关注话题 -->
<!-- Interested Topics -->
<div class="modal-section" v-if="selectedProfile.interested_topics?.length">
<span class="section-label">{{ $t('step2.profileModalTopics') }}</span>
<div class="topics-grid">
@ -582,11 +582,11 @@
</div>
</div>
<!-- 详细人设 -->
<!-- Detailed Persona -->
<div class="modal-section" v-if="selectedProfile.persona">
<span class="section-label">{{ $t('step2.profileModalPersona') }}</span>
<!-- 人设维度概览 -->
<!-- Persona Dimensions Overview -->
<div class="persona-dimensions">
<div class="dimension-card">
<span class="dim-title">{{ $t('step2.personaDimExperience') }}</span>
@ -645,7 +645,7 @@ import {
const { t } = useI18n()
const props = defineProps({
simulationId: String, //
simulationId: String, // Passed in from parent component
projectData: Object,
graphData: Object,
systemLogs: Array
@ -654,7 +654,7 @@ const props = defineProps({
const emit = defineEmits(['go-back', 'next-step', 'add-log', 'update-status'])
// State
const phase = ref(0) // 0: , 1: , 2: , 3:
const phase = ref(0) // 0: init, 1: generate personas, 2: generate config, 3: complete
const taskId = ref(null)
const prepareProgress = ref(0)
const currentStage = ref('')
@ -666,14 +666,14 @@ const simulationConfig = ref(null)
const selectedProfile = ref(null)
const showProfilesDetail = ref(true)
//
// Log deduplication: track the last output key information
let lastLoggedMessage = ''
let lastLoggedProfileCount = 0
let lastLoggedConfigStage = ''
//
const useCustomRounds = ref(false) // 使
const customMaxRounds = ref(40) // 40
// Simulation rounds configuration
const useCustomRounds = ref(false) // Default to auto-configured rounds
const customMaxRounds = ref(40) // Default recommended 40 rounds
// Watch stage to update phase
watch(currentStage, (newStage) => {
@ -681,28 +681,28 @@ watch(currentStage, (newStage) => {
phase.value = 1
} else if (newStage === '生成模拟配置' || newStage === 'generating_config') {
phase.value = 2
//
// Enter config generation phase, start polling for config
if (!configTimer) {
addLog(t('log.startGeneratingConfig'))
startConfigPolling()
}
} else if (newStage === '准备模拟脚本' || newStage === 'copying_scripts') {
phase.value = 2 //
phase.value = 2 // Still part of the config phase
}
})
// 使
// Calculate auto-generated rounds from config (no hardcoded default)
const autoGeneratedRounds = computed(() => {
if (!simulationConfig.value?.time_config) {
return null // null
return null // Return null when config is not yet generated
}
const totalHours = simulationConfig.value.time_config.total_simulation_hours
const minutesPerRound = simulationConfig.value.time_config.minutes_per_round
if (!totalHours || !minutesPerRound) {
return null // null
return null // Return null when config data is incomplete
}
const calculatedRounds = Math.floor((totalHours * 60) / minutesPerRound)
// 40
// Ensure max rounds is no less than 40 (recommended), to avoid slider range issues
return Math.max(calculatedRounds, 40)
})
@ -719,7 +719,7 @@ const displayProfiles = computed(() => {
return profiles.value.slice(0, 6)
})
// agent_idusername
// Get the username for a given agent_id
const getAgentUsername = (agentId) => {
if (profiles.value && profiles.value.length > agentId && agentId >= 0) {
const profile = profiles.value[agentId]
@ -728,7 +728,7 @@ const getAgentUsername = (agentId) => {
return `agent_${agentId}`
}
//
// Compute the total count of related topics across all personas
const totalTopicsCount = computed(() => {
return profiles.value.reduce((sum, p) => {
return sum + (p.interested_topics?.length || 0)
@ -740,17 +740,17 @@ const addLog = (msg) => {
emit('add-log', msg)
}
//
// Handle the "Start Simulation" button click
const handleStartSimulation = () => {
//
// Build the params to pass to the parent component
const params = {}
if (useCustomRounds.value) {
// max_rounds
// User customized rounds, pass max_rounds param
params.maxRounds = customMaxRounds.value
addLog(t('log.startSimCustomRounds', { rounds: customMaxRounds.value }))
} else {
// max_rounds
// User chose to keep auto-generated rounds, do not pass max_rounds
addLog(t('log.startSimAutoRounds', { rounds: autoGeneratedRounds.value }))
}
@ -768,15 +768,15 @@ const selectProfile = (profile) => {
selectedProfile.value = profile
}
//
// Auto-start prepare simulation
const startPrepareSimulation = async () => {
if (!props.simulationId) {
addLog(t('log.errorMissingSimId'))
emit('update-status', 'error')
return
}
//
// Mark step 1 complete, move to step 2
phase.value = 1
addLog(t('log.simInstanceCreated', { id: props.simulationId }))
addLog(t('log.preparingSimEnv'))
@ -800,7 +800,7 @@ const startPrepareSimulation = async () => {
addLog(t('log.prepareTaskStarted'))
addLog(t('log.prepareTaskId', { taskId: res.data.task_id }))
// Agentprepare
// Immediately set the expected total agent count (from the prepare endpoint response)
if (res.data.expected_entities_count) {
expectedTotal.value = res.data.expected_entities_count
addLog(t('log.zepEntitiesFound', { count: res.data.expected_entities_count }))
@ -810,9 +810,9 @@ const startPrepareSimulation = async () => {
}
addLog(t('log.startPollingProgress'))
//
// Start polling for progress
startPolling()
// Profiles
// Start fetching Profiles in real time
startProfilesPolling()
} else {
addLog(t('log.prepareFailed', { error: res.error || t('common.unknownError') }))
@ -858,15 +858,15 @@ const pollPrepareStatus = async () => {
if (res.success && res.data) {
const data = res.data
//
// Update progress
prepareProgress.value = data.progress || 0
progressMessage.value = data.message || ''
//
// Parse stage info and emit detailed logs
if (data.progress_detail) {
currentStage.value = data.progress_detail.current_stage_name || ''
//
// Output detailed progress logs (avoid duplicates)
const detail = data.progress_detail
const logKey = `${detail.current_stage}-${detail.current_item}-${detail.total_items}`
if (logKey !== lastLoggedMessage && detail.item_description) {
@ -879,19 +879,19 @@ const pollPrepareStatus = async () => {
}
}
} else if (data.message) {
//
// Extract stage from message
const match = data.message.match(/\[(\d+)\/(\d+)\]\s*([^:]+)/)
if (match) {
currentStage.value = match[3].trim()
}
//
// Output message log (avoid duplicates)
if (data.message !== lastLoggedMessage) {
lastLoggedMessage = data.message
addLog(data.message)
}
}
//
// Check whether finished
if (data.status === 'completed' || data.status === 'ready' || data.already_prepared) {
addLog(t('log.prepareComplete'))
stopPolling()
@ -904,32 +904,32 @@ const pollPrepareStatus = async () => {
}
}
} catch (err) {
console.warn('轮询状态失败:', err)
console.warn('Polling status failed:', err)
}
}
const fetchProfilesRealtime = async () => {
if (!props.simulationId) return
try {
const res = await getSimulationProfilesRealtime(props.simulationId, 'reddit')
if (res.success && res.data) {
const prevCount = profiles.value.length
profiles.value = res.data.profiles || []
// API
// Only update when the API returns a valid value, to avoid overwriting an existing valid value
if (res.data.total_expected) {
expectedTotal.value = res.data.total_expected
}
//
// Extract entity types
const types = new Set()
profiles.value.forEach(p => {
if (p.entity_type) types.add(p.entity_type)
})
entityTypes.value = Array.from(types)
// Profile
// Output Profile generation progress log (only when count changes)
const currentCount = profiles.value.length
if (currentCount > 0 && currentCount !== lastLoggedProfileCount) {
lastLoggedProfileCount = currentCount
@ -941,18 +941,18 @@ const fetchProfilesRealtime = async () => {
}
addLog(t('log.agentProfile', { current: currentCount, total: total, name: profileName, profession: latestProfile?.profession || t('step2.unknownProfession') }))
//
// If all are generated
if (expectedTotal.value && currentCount >= expectedTotal.value) {
addLog(t('log.allProfilesComplete', { count: currentCount }))
}
}
}
} catch (err) {
console.warn('获取 Profiles 失败:', err)
console.warn('Failed to fetch Profiles:', err)
}
}
//
// Config polling
const startConfigPolling = () => {
configTimer = setInterval(fetchConfigRealtime, 2000)
}
@ -973,7 +973,7 @@ const fetchConfigRealtime = async () => {
if (res.success && res.data) {
const data = res.data
//
// Output config-generation-stage log (avoid duplicates)
if (data.generation_stage && data.generation_stage !== lastLoggedConfigStage) {
lastLoggedConfigStage = data.generation_stage
if (data.generation_stage === 'generating_profiles') {
@ -983,12 +983,12 @@ const fetchConfigRealtime = async () => {
}
}
//
// If config has been generated
if (data.config_generated && data.config) {
simulationConfig.value = data.config
addLog(t('log.configComplete'))
//
// Show detailed config summary
if (data.summary) {
addLog(t('log.configSummaryAgents', { count: data.summary.total_agents }))
addLog(t('log.configSummaryHours', { hours: data.summary.simulation_hours }))
@ -997,13 +997,13 @@ const fetchConfigRealtime = async () => {
addLog(t('log.configSummaryPlatforms', { twitter: data.summary.has_twitter_config ? '✓' : '✗', reddit: data.summary.has_reddit_config ? '✓' : '✗' }))
}
//
// Show time config details
if (data.config.time_config) {
const tc = data.config.time_config
addLog(t('log.timeConfigDetail', { minutes: tc.minutes_per_round, rounds: Math.floor((tc.total_simulation_hours * 60) / tc.minutes_per_round) }))
}
//
// Show event config
if (data.config.event_config?.narrative_direction) {
const narrative = data.config.event_config.narrative_direction
addLog(t('log.narrativeDirection', { direction: narrative.length > 50 ? narrative.substring(0, 50) + '...' : narrative }))
@ -1016,7 +1016,7 @@ const fetchConfigRealtime = async () => {
}
}
} catch (err) {
console.warn('获取 Config 失败:', err)
console.warn('Failed to fetch Config:', err)
}
}
@ -1024,11 +1024,11 @@ const loadPreparedData = async () => {
phase.value = 2
addLog(t('log.loadingExistingConfig'))
// Profiles
// Fetch Profiles one last time
await fetchProfilesRealtime()
addLog(t('log.loadedAgentProfiles', { count: profiles.value.length }))
// 使
// Fetch config (using the realtime endpoint)
try {
const res = await getSimulationConfigRealtime(props.simulationId)
if (res.success && res.data) {
@ -1036,7 +1036,7 @@ const loadPreparedData = async () => {
simulationConfig.value = res.data.config
addLog(t('log.configLoadSuccess'))
//
// Show detailed config summary
if (res.data.summary) {
addLog(t('log.configSummaryAgents', { count: res.data.summary.total_agents }))
addLog(t('log.configSummaryHours', { hours: res.data.summary.simulation_hours }))
@ -1047,7 +1047,7 @@ const loadPreparedData = async () => {
phase.value = 4
emit('update-status', 'completed')
} else {
//
// Config not yet generated, start polling
addLog(t('log.configGenerating'))
startConfigPolling()
}
@ -1069,7 +1069,7 @@ watch(() => props.systemLogs?.length, () => {
})
onMounted(() => {
//
// Auto-start the prepare flow
if (props.simulationId) {
addLog(t('log.step2Init'))
startPrepareSimulation()
@ -1905,7 +1905,7 @@ onUnmounted(() => {
flex: 1;
}
/* 基本信息网格 */
/* Basic Info Grid */
.modal-info-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
@ -1941,7 +1941,7 @@ onUnmounted(() => {
color: #FF5722;
}
/* 模块区域 */
/* Module Section */
.modal-section {
margin-bottom: 28px;
}
@ -1967,7 +1967,7 @@ onUnmounted(() => {
border-left: 3px solid #E0E0E0;
}
/* 话题标签 */
/* Topic Tags */
.topics-grid {
display: flex;
flex-wrap: wrap;
@ -1989,7 +1989,7 @@ onUnmounted(() => {
color: #0D47A1;
}
/* 详细人设 */
/* Detailed Persona */
.persona-dimensions {
display: grid;
grid-template-columns: repeat(2, 1fr);
@ -2275,7 +2275,7 @@ onUnmounted(() => {
margin: 0;
}
/* 模拟轮数配置样式 */
/* Simulation Rounds Config Styles */
.rounds-config-section {
margin: 24px 0;
padding-top: 24px;

View File

@ -3,7 +3,7 @@
<!-- Top Control Bar -->
<div class="control-bar">
<div class="status-group">
<!-- Twitter 平台进度 -->
<!-- Twitter platform progress -->
<div class="platform-status twitter" :class="{ active: runStatus.twitter_running, completed: runStatus.twitter_completed }">
<div class="platform-header">
<svg class="platform-icon" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
@ -30,7 +30,7 @@
<span class="stat-value mono">{{ runStatus.twitter_actions_count || 0 }}</span>
</span>
</div>
<!-- 可用动作提示 -->
<!-- Available actions hint -->
<div class="actions-tooltip">
<div class="tooltip-title">Available Actions</div>
<div class="tooltip-actions">
@ -44,7 +44,7 @@
</div>
</div>
<!-- Reddit 平台进度 -->
<!-- Reddit platform progress -->
<div class="platform-status reddit" :class="{ active: runStatus.reddit_running, completed: runStatus.reddit_completed }">
<div class="platform-header">
<svg class="platform-icon" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
@ -71,7 +71,7 @@
<span class="stat-value mono">{{ runStatus.reddit_actions_count || 0 }}</span>
</span>
</div>
<!-- 可用动作提示 -->
<!-- Available actions hint -->
<div class="actions-tooltip">
<div class="tooltip-title">Available Actions</div>
<div class="tooltip-actions">
@ -157,12 +157,12 @@
</div>
<div class="card-body">
<!-- CREATE_POST: 发布帖子 -->
<!-- CREATE_POST: publish a post -->
<div v-if="action.action_type === 'CREATE_POST' && action.action_args?.content" class="content-text main-text">
{{ action.action_args.content }}
</div>
<!-- QUOTE_POST: 引用帖子 -->
<!-- QUOTE_POST: quote a post -->
<template v-if="action.action_type === 'QUOTE_POST'">
<div v-if="action.action_args?.quote_content" class="content-text">
{{ action.action_args.quote_content }}
@ -178,7 +178,7 @@
</div>
</template>
<!-- REPOST: 转发帖子 -->
<!-- REPOST: repost -->
<template v-if="action.action_type === 'REPOST'">
<div class="repost-info">
<svg class="icon-small" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><polyline points="17 1 21 5 17 9"></polyline><path d="M3 11V9a4 4 0 0 1 4-4h14"></path><polyline points="7 23 3 19 7 15"></polyline><path d="M21 13v2a4 4 0 0 1-4 4H3"></path></svg>
@ -189,7 +189,7 @@
</div>
</template>
<!-- LIKE_POST: 点赞帖子 -->
<!-- LIKE_POST: like a post -->
<template v-if="action.action_type === 'LIKE_POST'">
<div class="like-info">
<svg class="icon-small filled" viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path></svg>
@ -200,7 +200,7 @@
</div>
</template>
<!-- CREATE_COMMENT: 发表评论 -->
<!-- CREATE_COMMENT: post a comment -->
<template v-if="action.action_type === 'CREATE_COMMENT'">
<div v-if="action.action_args?.content" class="content-text">
{{ action.action_args.content }}
@ -211,7 +211,7 @@
</div>
</template>
<!-- SEARCH_POSTS: 搜索帖子 -->
<!-- SEARCH_POSTS: search posts -->
<template v-if="action.action_type === 'SEARCH_POSTS'">
<div class="search-info">
<svg class="icon-small" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
@ -220,7 +220,7 @@
</div>
</template>
<!-- FOLLOW: 关注用户 -->
<!-- FOLLOW: follow a user -->
<template v-if="action.action_type === 'FOLLOW'">
<div class="follow-info">
<svg class="icon-small" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="8.5" cy="7" r="4"></circle><line x1="20" y1="8" x2="20" y2="14"></line><line x1="23" y1="11" x2="17" y2="11"></line></svg>
@ -240,7 +240,7 @@
</div>
</template>
<!-- DO_NOTHING: 无操作静默 -->
<!-- DO_NOTHING: no-op (silent) -->
<template v-if="action.action_type === 'DO_NOTHING'">
<div class="idle-info">
<svg class="icon-small" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
@ -248,7 +248,7 @@
</div>
</template>
<!-- 通用回退未知类型或有 content 但未被上述处理 -->
<!-- Generic fallback: unknown type or content not handled above -->
<div v-if="!['CREATE_POST', 'QUOTE_POST', 'REPOST', 'LIKE_POST', 'CREATE_COMMENT', 'SEARCH_POSTS', 'FOLLOW', 'UPVOTE_POST', 'DOWNVOTE_POST', 'DO_NOTHING'].includes(action.action_type) && action.action_args?.content" class="content-text">
{{ action.action_args.content }}
</div>
@ -301,10 +301,10 @@ const { t } = useI18n()
const props = defineProps({
simulationId: String,
maxRounds: Number, // Step2
maxRounds: Number, // Max rounds from Step 2
minutesPerRound: {
type: Number,
default: 30 // 30
default: 30 // 30 minutes per round by default
},
projectData: Object,
graphData: Object,
@ -317,22 +317,22 @@ const router = useRouter()
// State
const isGeneratingReport = ref(false)
const phase = ref(0) // 0: , 1: , 2:
const phase = ref(0) // 0: not started, 1: running, 2: complete
const isStarting = ref(false)
const isStopping = ref(false)
const startError = ref(null)
const runStatus = ref({})
const allActions = ref([]) //
const actionIds = ref(new Set()) // ID
const allActions = ref([]) // All actions (accumulated incrementally)
const actionIds = ref(new Set()) // Action ID set used for deduplication
const scrollContainer = ref(null)
// Computed
//
// Display actions chronologically (newest at the bottom)
const chronologicalActions = computed(() => {
return allActions.value
})
//
// Per-platform action counts
const twitterActionsCount = computed(() => {
return allActions.value.filter(a => a.platform === 'twitter').length
})
@ -341,7 +341,7 @@ const redditActionsCount = computed(() => {
return allActions.value.filter(a => a.platform === 'reddit').length
})
//
// Format simulated elapsed time (based on round count and minutes per round)
const formatElapsedTime = (currentRound) => {
if (!currentRound || currentRound <= 0) return '0h 0m'
const totalMinutes = currentRound * props.minutesPerRound
@ -350,12 +350,12 @@ const formatElapsedTime = (currentRound) => {
return `${hours}h ${minutes}m`
}
// Twitter
// Elapsed time for the Twitter platform
const twitterElapsedTime = computed(() => {
return formatElapsedTime(runStatus.value.twitter_current_round || 0)
})
// Reddit
// Elapsed time for the Reddit platform
const redditElapsedTime = computed(() => {
return formatElapsedTime(runStatus.value.reddit_current_round || 0)
})
@ -365,7 +365,7 @@ const addLog = (msg) => {
emit('add-log', msg)
}
//
// Reset all state (used when restarting the simulation)
const resetAllState = () => {
phase.value = 0
runStatus.value = {}
@ -376,17 +376,17 @@ const resetAllState = () => {
startError.value = null
isStarting.value = false
isStopping.value = false
stopPolling() //
stopPolling() // Stop any prior polling
}
//
// Start the simulation
const doStartSimulation = async () => {
if (!props.simulationId) {
addLog(t('log.errorMissingSimId'))
return
}
//
// Reset all state first so this run is not influenced by the previous one
resetAllState()
isStarting.value = true
@ -398,8 +398,8 @@ const doStartSimulation = async () => {
const params = {
simulation_id: props.simulationId,
platform: 'parallel',
force: true, //
enable_graph_memory_update: true //
force: true, // Force a fresh start
enable_graph_memory_update: true // Enable dynamic graph memory updates
}
if (props.maxRounds) {
@ -424,7 +424,7 @@ const doStartSimulation = async () => {
startStatusPolling()
startDetailPolling()
} else {
startError.value = res.error || '启动失败'
startError.value = res.error || 'Start failed'
addLog(t('log.startFailed', { error: res.error || t('common.unknownError') }))
emit('update-status', 'error')
}
@ -437,7 +437,7 @@ const doStartSimulation = async () => {
}
}
//
// Stop the simulation
const handleStopSimulation = async () => {
if (!props.simulationId) return
@ -462,7 +462,7 @@ const handleStopSimulation = async () => {
}
}
//
// Poll status
let statusTimer = null
let detailTimer = null
@ -485,7 +485,7 @@ const stopPolling = () => {
}
}
//
// Track the last seen round for each platform so we can detect changes and log them
const prevTwitterRound = ref(0)
const prevRedditRound = ref(0)
@ -500,7 +500,7 @@ const fetchRunStatus = async () => {
runStatus.value = data
//
// Detect round changes per platform and log them
if (data.twitter_current_round > prevTwitterRound.value) {
addLog(`[Plaza] R${data.twitter_current_round}/${data.total_rounds} | T:${data.twitter_simulated_hours || 0}h | A:${data.twitter_actions_count}`)
prevTwitterRound.value = data.twitter_current_round
@ -511,11 +511,11 @@ const fetchRunStatus = async () => {
prevRedditRound.value = data.reddit_current_round
}
// runner_status
// Detect simulation completion (via runner_status or per-platform completion flag)
const isCompleted = data.runner_status === 'completed' || data.runner_status === 'stopped'
// runner_status
// twitter_completed reddit_completed
// Extra check: backend may not have updated runner_status yet, but platforms may already report complete
// Inspect twitter_completed and reddit_completed flags
const platformsCompleted = checkPlatformsCompleted(data)
if (isCompleted || platformsCompleted) {
@ -529,28 +529,28 @@ const fetchRunStatus = async () => {
}
}
} catch (err) {
console.warn('获取运行状态失败:', err)
console.warn('Failed to fetch run status:', err)
}
}
//
// Check whether all enabled platforms have completed
const checkPlatformsCompleted = (data) => {
// false
// Return false when there is no platform data at all
if (!data) return false
//
// Check the completion state of each platform
const twitterCompleted = data.twitter_completed === true
const redditCompleted = data.reddit_completed === true
//
// actions_count count > 0 running true
// If at least one platform has completed, check whether all enabled ones are done
// Use actions_count to detect whether a platform is enabled (count > 0 or running was true)
const twitterEnabled = (data.twitter_actions_count > 0) || data.twitter_running || twitterCompleted
const redditEnabled = (data.reddit_actions_count > 0) || data.reddit_running || redditCompleted
// false
// If no platform is enabled, return false
if (!twitterEnabled && !redditEnabled) return false
//
// Check whether every enabled platform has completed
if (twitterEnabled && !twitterCompleted) return false
if (redditEnabled && !redditCompleted) return false
@ -564,13 +564,13 @@ const fetchRunStatusDetail = async () => {
const res = await getRunStatusDetail(props.simulationId)
if (res.success && res.data) {
// 使 all_actions
// Use all_actions to fetch the complete action list
const serverActions = res.data.all_actions || []
//
// Append new actions incrementally (deduplicated)
let newActionsAdded = 0
serverActions.forEach(action => {
// ID
// Generate a unique ID
const actionId = action.id || `${action.timestamp}-${action.platform}-${action.agent_id}-${action.action_type}`
if (!actionIds.value.has(actionId)) {
@ -583,11 +583,11 @@ const fetchRunStatusDetail = async () => {
}
})
//
//
// Do not auto-scroll; let the user browse the timeline freely
// New actions are appended at the bottom
}
} catch (err) {
console.warn('获取详细状态失败:', err)
console.warn('Failed to fetch detailed status:', err)
}
}
@ -665,7 +665,7 @@ const handleNextStep = async () => {
const reportId = res.data.report_id
addLog(t('log.reportGenTaskStarted', { reportId }))
//
// Navigate to the report page
router.push({ name: 'Report', params: { reportId } })
} else {
addLog(t('log.reportGenFailed', { error: res.error || t('common.unknownError') }))

View File

@ -127,7 +127,7 @@
</div>
</div>
<!-- Next Step Button - 在完成后显示 -->
<!-- Next Step Button - Shown after completion -->
<button v-if="isComplete" class="next-step-btn" @click="goToInteraction">
<span>{{ $t('step4.goToInteraction') }}</span>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
@ -194,7 +194,7 @@
</div>
</template>
<!-- Section Content Generated (内容生成完成但整个章节可能还没完成) -->
<!-- Section Content Generated (content generation complete, but the entire section may not be done) -->
<template v-if="log.action === 'section_content'">
<div class="section-tag content-ready">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
@ -205,7 +205,7 @@
</div>
</template>
<!-- Section Complete (章节生成完成) -->
<!-- Section Complete (section generation done) -->
<template v-if="log.action === 'section_complete'">
<div class="section-tag completed">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
@ -315,7 +315,7 @@
Final: {{ log.details?.has_final_answer ? 'Yes' : 'No' }}
</span>
</div>
<!-- 当是最终答案时显示特殊提示 -->
<!-- When it is the final answer, show a special hint -->
<div v-if="log.details?.has_final_answer" class="final-answer-hint">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20 6 9 17 4 12"></polyline>
@ -433,22 +433,22 @@ const showRawResult = reactive({})
// Toggle functions
const toggleRawResult = (timestamp, event) => {
//
// Save the button position relative to the viewport
const button = event?.target
const buttonRect = button?.getBoundingClientRect()
const buttonTopBeforeToggle = buttonRect?.top
//
// Toggle state
showRawResult[timestamp] = !showRawResult[timestamp]
// DOM
// After DOM updates, adjust scroll position to keep button in the same place
if (button && buttonTopBeforeToggle !== undefined && rightPanel.value) {
nextTick(() => {
const newButtonRect = button.getBoundingClientRect()
const buttonTopAfterToggle = newButtonRect.top
const scrollDelta = buttonTopAfterToggle - buttonTopBeforeToggle
//
// Adjust scroll position
rightPanel.value.scrollTop += scrollDelta
})
}
@ -466,7 +466,7 @@ const toggleSectionContent = (idx) => {
}
const toggleSectionCollapse = (idx) => {
//
// Only completed sections can be collapsed
if (!generatedSections.value[idx + 1]) return
const newSet = new Set(collapsedSections.value)
if (newSet.has(idx)) {
@ -499,32 +499,32 @@ const toolConfig = {
'insight_forge': {
name: 'Deep Insight',
color: 'purple',
icon: 'lightbulb' // -
icon: 'lightbulb' // Lightbulb icon - represents insight
},
'panorama_search': {
name: 'Panorama Search',
color: 'blue',
icon: 'globe' // -
icon: 'globe' // Globe icon - represents panorama search
},
'interview_agents': {
name: 'Agent Interview',
color: 'green',
icon: 'users' // -
icon: 'users' // Users icon - represents conversation
},
'quick_search': {
name: 'Quick Search',
color: 'orange',
icon: 'zap' // -
icon: 'zap' // Lightning icon - represents quick
},
'get_graph_statistics': {
name: 'Graph Stats',
color: 'cyan',
icon: 'chart' // -
icon: 'chart' // Chart icon - represents statistics
},
'get_entities_by_type': {
name: 'Entity Query',
color: 'pink',
icon: 'database' // -
icon: 'database' // Database icon - represents entities
}
}
@ -553,30 +553,30 @@ const parseInsightForge = (text) => {
}
try {
//
// Extract analysis question
const queryMatch = text.match(/分析问题:\s*(.+?)(?:\n|$)/)
if (queryMatch) result.query = queryMatch[1].trim()
//
// Extract prediction scenario
const reqMatch = text.match(/预测场景:\s*(.+?)(?:\n|$)/)
if (reqMatch) result.simulationRequirement = reqMatch[1].trim()
// - ": X"
// Extract statistics - match ": X" format
const factMatch = text.match(/相关预测事实:\s*(\d+)/)
const entityMatch = text.match(/涉及实体:\s*(\d+)/)
const relMatch = text.match(/关系链:\s*(\d+)/)
if (factMatch) result.stats.facts = parseInt(factMatch[1])
if (entityMatch) result.stats.entities = parseInt(entityMatch[1])
if (relMatch) result.stats.relationships = parseInt(relMatch[1])
// -
// Extract sub-questions - full extraction, no count limit
const subQSection = text.match(/### 分析的子问题\n([\s\S]*?)(?=\n###|$)/)
if (subQSection) {
const lines = subQSection[1].split('\n').filter(l => l.match(/^\d+\./))
result.subQueries = lines.map(l => l.replace(/^\d+\.\s*/, '').trim()).filter(Boolean)
}
// -
// Extract key facts - full extraction, no count limit
const factsSection = text.match(/### 【关键事实】[\s\S]*?\n([\s\S]*?)(?=\n###|$)/)
if (factsSection) {
const lines = factsSection[1].split('\n').filter(l => l.match(/^\d+\./))
@ -585,12 +585,12 @@ const parseInsightForge = (text) => {
return match ? match[1].replace(/^"|"$/g, '').trim() : l.replace(/^\d+\.\s*/, '').trim()
}).filter(Boolean)
}
// -
// Extract core entities - full extraction, including summary and related facts count
const entitySection = text.match(/### 【核心实体】\n([\s\S]*?)(?=\n###|$)/)
if (entitySection) {
const entityText = entitySection[1]
// "- **"
// Split entity blocks by "- **"
const entityBlocks = entityText.split(/\n(?=- \*\*)/).filter(b => b.trim().startsWith('- **'))
result.entities = entityBlocks.map(block => {
const nameMatch = block.match(/^-\s*\*\*(.+?)\*\*\s*\((.+?)\)/)
@ -604,8 +604,8 @@ const parseInsightForge = (text) => {
}
}).filter(e => e.name)
}
// -
// Extract relation chains - full extraction, no count limit
const relSection = text.match(/### 【关系链】\n([\s\S]*?)(?=\n###|$)/)
if (relSection) {
const lines = relSection[1].split('\n').filter(l => l.trim().startsWith('-'))
@ -634,11 +634,11 @@ const parsePanorama = (text) => {
}
try {
//
// Extract query
const queryMatch = text.match(/查询:\s*(.+?)(?:\n|$)/)
if (queryMatch) result.query = queryMatch[1].trim()
//
// Extract statistics
const nodesMatch = text.match(/总节点数:\s*(\d+)/)
const edgesMatch = text.match(/总边数:\s*(\d+)/)
const activeMatch = text.match(/当前有效事实:\s*(\d+)/)
@ -647,19 +647,19 @@ const parsePanorama = (text) => {
if (edgesMatch) result.stats.edges = parseInt(edgesMatch[1])
if (activeMatch) result.stats.activeFacts = parseInt(activeMatch[1])
if (histMatch) result.stats.historicalFacts = parseInt(histMatch[1])
// -
// Extract current active facts - full extraction, no count limit
const activeSection = text.match(/### 【当前有效事实】[\s\S]*?\n([\s\S]*?)(?=\n###|$)/)
if (activeSection) {
const lines = activeSection[1].split('\n').filter(l => l.match(/^\d+\./))
result.activeFacts = lines.map(l => {
//
// Remove numbering and quotes
const factText = l.replace(/^\d+\.\s*/, '').replace(/^"|"$/g, '').trim()
return factText
}).filter(Boolean)
}
// / -
// Extract historical/expired facts - full extraction, no count limit
const histSection = text.match(/### 【历史\/过期事实】[\s\S]*?\n([\s\S]*?)(?=\n###|$)/)
if (histSection) {
const lines = histSection[1].split('\n').filter(l => l.match(/^\d+\./))
@ -668,8 +668,8 @@ const parsePanorama = (text) => {
return factText
}).filter(Boolean)
}
// -
// Extract related entities - full extraction, no count limit
const entitySection = text.match(/### 【涉及实体】\n([\s\S]*?)(?=\n###|$)/)
if (entitySection) {
const lines = entitySection[1].split('\n').filter(l => l.trim().startsWith('-'))
@ -698,48 +698,48 @@ const parseInterview = (text) => {
}
try {
// 访
// Extract interview topic
const topicMatch = text.match(/\*\*采访主题:\*\*\s*(.+?)(?:\n|$)/)
if (topicMatch) result.topic = topicMatch[1].trim()
// 访 "5 / 9 Agent"
// Extract interview count (e.g. "5 / 9 Agent")
const countMatch = text.match(/\*\*采访人数:\*\*\s*(\d+)\s*\/\s*(\d+)/)
if (countMatch) {
result.successCount = parseInt(countMatch[1])
result.totalCount = parseInt(countMatch[2])
result.agentCount = `${countMatch[1]} / ${countMatch[2]}`
}
// 访
// Extract selection reasons for interview targets
const reasonMatch = text.match(/### 采访对象选择理由\n([\s\S]*?)(?=\n---\n|\n### 采访实录)/)
if (reasonMatch) {
result.selectionReason = reasonMatch[1].trim()
}
//
// Parse each person's selection reason
const parseIndividualReasons = (reasonText) => {
const reasons = {}
if (!reasonText) return reasons
const lines = reasonText.split(/\n+/)
let currentName = null
let currentReason = []
for (const line of lines) {
let headerMatch = null
let name = null
let reasonStart = null
// 1: . **index=X**
// : 1. **_345index=1**...
// Format 1: . **index=X**
// Example: 1. **_345index=1**...
headerMatch = line.match(/^\d+\.\s*\*\*([^*(]+)(?:[(]index\s*=?\s*\d+[)])?\*\*[:]\s*(.*)/)
if (headerMatch) {
name = headerMatch[1].trim()
reasonStart = headerMatch[2]
}
// 2: - index X
// : - _601index 0...
// Format 2: - index X
// Example: - _601index 0...
if (!headerMatch) {
headerMatch = line.match(/^-\s*选择([^(]+)(?:[(]index\s*=?\s*\d+[)])?[:]\s*(.*)/)
if (headerMatch) {
@ -747,9 +747,9 @@ const parseInterview = (text) => {
reasonStart = headerMatch[2]
}
}
// 3: - **index X**
// : - **_601index 0**...
// Format 3: - **index X**
// Example: - **_601index 0**...
if (!headerMatch) {
headerMatch = line.match(/^-\s*\*\*([^*(]+)(?:[(]index\s*=?\s*\d+[)])?\*\*[:]\s*(.*)/)
if (headerMatch) {
@ -757,34 +757,34 @@ const parseInterview = (text) => {
reasonStart = headerMatch[2]
}
}
if (name) {
//
// Save the previous person's reason
if (currentName && currentReason.length > 0) {
reasons[currentName] = currentReason.join(' ').trim()
}
//
// Start a new person
currentName = name
currentReason = reasonStart ? [reasonStart.trim()] : []
} else if (currentName && line.trim() && !line.match(/^未选|^综上|^最终选择/)) {
//
// Continuation of the reason (exclude ending summary paragraphs)
currentReason.push(line.trim())
}
}
//
// Save the last person's reason
if (currentName && currentReason.length > 0) {
reasons[currentName] = currentReason.join(' ').trim()
}
return reasons
}
const individualReasons = parseIndividualReasons(result.selectionReason)
// 访
// Extract each interview record
const interviewBlocks = text.split(/#### 采访 #\d+:/).slice(1)
interviewBlocks.forEach((block, index) => {
const interview = {
num: index + 1,
@ -798,34 +798,34 @@ const parseInterview = (text) => {
redditAnswer: '',
quotes: []
}
// """"
// Extract title (e.g. "", "" etc.)
const titleMatch = block.match(/^(.+?)\n/)
if (titleMatch) interview.title = titleMatch[1].trim()
//
// Extract name and role
const nameRoleMatch = block.match(/\*\*(.+?)\*\*\s*\((.+?)\)/)
if (nameRoleMatch) {
interview.name = nameRoleMatch[1].trim()
interview.role = nameRoleMatch[2].trim()
//
// Set the selection reason for this person
interview.selectionReason = individualReasons[interview.name] || ''
}
//
// Extract bio
const bioMatch = block.match(/_简介:\s*([\s\S]*?)_\n/)
if (bioMatch) {
interview.bio = bioMatch[1].trim().replace(/\.\.\.$/, '...')
}
//
// Extract question list
const qMatch = block.match(/\*\*Q:\*\*\s*([\s\S]*?)(?=\n\n\*\*A:\*\*|\*\*A:\*\*)/)
if (qMatch) {
const qText = qMatch[1].trim()
//
// Split questions by number
const questions = qText.split(/\n\d+\.\s+/).filter(q => q.trim())
if (questions.length > 0) {
// "1."
// If the first question is preceded by "1.", special handling is needed
const firstQ = qText.match(/^1\.\s+(.+)/)
if (firstQ) {
interview.questions = [firstQ[1].trim(), ...questions.slice(1).map(q => q.trim())]
@ -834,26 +834,26 @@ const parseInterview = (text) => {
}
}
}
// - TwitterReddit
// Extract answer - Twitter and Reddit
const answerMatch = block.match(/\*\*A:\*\*\s*([\s\S]*?)(?=\*\*关键引言|$)/)
if (answerMatch) {
const answerText = answerMatch[1].trim()
// TwitterReddit
// Separate Twitter and Reddit answers
const twitterMatch = answerText.match(/【Twitter平台回答】\n?([\s\S]*?)(?=【Reddit平台回答】|$)/)
const redditMatch = answerText.match(/【Reddit平台回答】\n?([\s\S]*?)$/)
if (twitterMatch) {
interview.twitterAnswer = twitterMatch[1].trim()
}
if (redditMatch) {
interview.redditAnswer = redditMatch[1].trim()
}
// 退
// Platform fallback logic (compatible with old format: only one platform marker)
if (!twitterMatch && redditMatch) {
// Reddit
// Only Reddit answer, copy to default display only when not placeholder text
if (interview.redditAnswer && interview.redditAnswer !== '(该平台未获得回复)') {
interview.twitterAnswer = interview.redditAnswer
}
@ -862,18 +862,18 @@ const parseInterview = (text) => {
interview.redditAnswer = interview.twitterAnswer
}
} else if (!twitterMatch && !redditMatch) {
//
// No platform markers (very old format), use the whole text as the answer
interview.twitterAnswer = answerText
}
}
//
// Extract key quotes (compatible with multiple quote formats)
const quotesMatch = block.match(/\*\*关键引言:\*\*\n([\s\S]*?)(?=\n---|\n####|$)/)
if (quotesMatch) {
const quotesText = quotesMatch[1]
// > "text"
// Prefer > "text" format
let quoteMatches = quotesText.match(/> "([^"]+)"/g)
// 退 > "text" > \u201Ctext\u201D
// Fallback: match > "text" or > \u201Ctext\u201D (Chinese quotes)
if (!quoteMatches) {
quoteMatches = quotesText.match(/> [\u201C""]([^\u201D""]+)[\u201D""]/g)
}
@ -883,13 +883,13 @@ const parseInterview = (text) => {
.filter(q => q)
}
}
if (interview.name || interview.title) {
result.interviews.push(interview)
}
})
// 访
// Extract interview summary
const summaryMatch = text.match(/### 采访摘要与核心观点\n([\s\S]*?)$/)
if (summaryMatch) {
result.summary = summaryMatch[1].trim()
@ -911,22 +911,22 @@ const parseQuickSearch = (text) => {
}
try {
//
// Extract search query
const queryMatch = text.match(/搜索查询:\s*(.+?)(?:\n|$)/)
if (queryMatch) result.query = queryMatch[1].trim()
//
// Extract result count
const countMatch = text.match(/找到\s*(\d+)\s*条/)
if (countMatch) result.count = parseInt(countMatch[1])
// -
// Extract related facts - full extraction, no count limit
const factsSection = text.match(/### 相关事实:\n([\s\S]*)$/)
if (factsSection) {
const lines = factsSection[1].split('\n').filter(l => l.match(/^\d+\./))
result.facts = lines.map(l => l.replace(/^\d+\.\s*/, '').trim()).filter(Boolean)
}
//
// Try to extract edges info (if any)
const edgesSection = text.match(/### 相关边:\n([\s\S]*?)(?=\n###|$)/)
if (edgesSection) {
const lines = edgesSection[1].split('\n').filter(l => l.trim().startsWith('-'))
@ -938,8 +938,8 @@ const parseQuickSearch = (text) => {
return null
}).filter(Boolean)
}
//
// Try to extract nodes info (if any)
const nodesSection = text.match(/### 相关节点:\n([\s\S]*?)(?=\n###|$)/)
if (nodesSection) {
const lines = nodesSection[1].split('\n').filter(l => l.trim().startsWith('-'))
@ -1229,7 +1229,7 @@ const PanoramaDisplay = {
h('div', { class: 'fact-item historical', key: i }, [
h('span', { class: 'fact-number' }, i + 1),
h('div', { class: 'fact-content' }, [
// [time - time]
// Try to extract time info [time - time]
(() => {
const timeMatch = fact.match(/^\[(.+?)\]\s*(.*)$/)
if (timeMatch) {
@ -1296,16 +1296,16 @@ const InterviewDisplay = {
const activeIndex = ref(0)
const expandedAnswers = ref(new Set())
// -
// Maintain independent platform selection state for each Q&A pair
const platformTabs = reactive({}) // { 'agentIdx-qIdx': 'twitter' | 'reddit' }
//
// Get the current platform selection for a question
const getPlatformTab = (agentIdx, qIdx) => {
const key = `${agentIdx}-${qIdx}`
return platformTabs[key] || 'twitter'
}
//
// Set the platform selection for a question
const setPlatformTab = (agentIdx, qIdx, platform) => {
const key = `${agentIdx}-${qIdx}`
platformTabs[key] = platform
@ -1327,25 +1327,25 @@ const InterviewDisplay = {
return text.substring(0, 400) + '...'
}
//
// Check if text is a platform placeholder
const isPlaceholderText = (text) => {
if (!text) return true
const t = text.trim()
return t === '(该平台未获得回复)' || t === '(该平台未获得回复)' || t === '[无回复]'
}
//
// Try to split answer by question number
const splitAnswerByQuestions = (answerText, questionCount) => {
if (!answerText || questionCount <= 0) return [answerText]
if (isPlaceholderText(answerText)) return ['']
//
// 1. "X" "X:"
// 2. "1. " "\n1. " +
// Support two numbering formats:
// 1. "X" or "X:" (Chinese format, new backend format)
// 2. "1. " or "\n1. " (number + dot, old format compatibility)
let matches = []
let match
// "X"
// Prefer "X" format
const cnPattern = /(?:^|[\r\n]+)问题(\d+)[:]\s*/g
while ((match = cnPattern.exec(answerText)) !== null) {
matches.push({
@ -1355,7 +1355,7 @@ const InterviewDisplay = {
})
}
// 退 "."
// If no match, fall back to "." format
if (matches.length === 0) {
const numPattern = /(?:^|[\r\n]+)(\d+)\.\s+/g
while ((match = numPattern.exec(answerText)) !== null) {
@ -1367,7 +1367,7 @@ const InterviewDisplay = {
}
}
//
// If no numbering or only one found, return as a whole
if (matches.length <= 1) {
const cleaned = answerText
.replace(/^问题\d+[:]\s*/, '')
@ -1376,7 +1376,7 @@ const InterviewDisplay = {
return [cleaned || answerText]
}
//
// Extract each part by number
const parts = []
for (let i = 0; i < matches.length; i++) {
const current = matches[i]
@ -1396,8 +1396,8 @@ const InterviewDisplay = {
return [answerText]
}
//
// Get the answer for a specific question
const getAnswerForQuestion = (interview, qIdx, platform) => {
const answer = platform === 'twitter' ? interview.twitterAnswer : (interview.redditAnswer || interview.twitterAnswer)
if (!answer || isPlaceholderText(answer)) return answer || ''
@ -1405,21 +1405,21 @@ const InterviewDisplay = {
const questionCount = interview.questions?.length || 1
const answers = splitAnswerByQuestions(answer, questionCount)
//
// Split succeeded and index is valid
if (answers.length > 1 && qIdx < answers.length) {
return answers[qIdx] || ''
}
//
// Split failed: first question returns full answer, others return empty
return qIdx === 0 ? answer : ''
}
//
// Check if a question has dual-platform answers (filter out placeholder text)
const hasMultiplePlatforms = (interview, qIdx) => {
if (!interview.twitterAnswer || !interview.redditAnswer) return false
const twitterAnswer = getAnswerForQuestion(interview, qIdx, 'twitter')
const redditAnswer = getAnswerForQuestion(interview, qIdx, 'reddit')
//
// Both platforms have real answers (non-placeholder) and content differs
return !isPlaceholderText(twitterAnswer) && !isPlaceholderText(redditAnswer) && twitterAnswer !== redditAnswer
}
@ -1469,13 +1469,13 @@ const InterviewDisplay = {
])
]),
// Selection Reason -
// Selection Reason
props.result.interviews[activeIndex.value]?.selectionReason && h('div', { class: 'selection-reason' }, [
h('div', { class: 'reason-label' }, '选择理由'),
h('div', { class: 'reason-label' }, 'Selection Reason'),
h('div', { class: 'reason-content' }, props.result.interviews[activeIndex.value].selectionReason)
]),
// Q&A Conversation Thread -
// Q&A Conversation Thread
h('div', { class: 'qa-thread' },
(props.result.interviews[activeIndex.value]?.questions?.length > 0
? props.result.interviews[activeIndex.value].questions
@ -1505,7 +1505,7 @@ const InterviewDisplay = {
h('div', { class: 'qa-content' }, [
h('div', { class: 'qa-answer-header' }, [
h('div', { class: 'qa-sender' }, interview?.name || 'Agent'),
//
// Dual-platform switch button (only shown when there are real dual-platform answers)
hasDualPlatform && h('div', { class: 'platform-switch' }, [
h('button', {
class: ['platform-btn', { active: currentPlatform === 'twitter' }],
@ -1537,7 +1537,7 @@ const InterviewDisplay = {
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\n/g, '<br>')
}),
// Expand/Collapse Button
// Expand/Collapse Button (placeholder text not shown)
!isPlaceholder && answerText.length > 400 && h('button', {
class: 'expand-answer-btn',
onClick: () => toggleAnswer(expandKey)
@ -1769,18 +1769,18 @@ const isFinalizing = computed(() => {
return !isComplete.value && isPlanningDone.value && totalSections.value > 0 && completedSections.value >= totalSections.value
})
//
// Currently active step (for top display)
const activeStep = computed(() => {
const steps = workflowSteps.value
// active
// Find the currently active step
const active = steps.find(s => s.status === 'active')
if (active) return active
// active done
// If no active step, return the last done step
const doneSteps = steps.filter(s => s.status === 'done')
if (doneSteps.length > 0) return doneSteps[doneSteps.length - 1]
//
// Otherwise return the first step
return steps[0] || { noLabel: '--', title: '等待开始', status: 'todo', meta: '' }
})
@ -1874,25 +1874,25 @@ const truncateText = (text, maxLen) => {
const renderMarkdown = (content) => {
if (!content) return ''
// ## xxx
// Remove the leading H2 (## xxx) because the section title is shown in the outer layer
let processedContent = content.replace(/^##\s+.+\n+/, '')
//
// Handle code blocks
let html = processedContent.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre class="code-block"><code>$2</code></pre>')
//
// Handle inline code
html = html.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
//
// Handle headings
html = html.replace(/^#### (.+)$/gm, '<h5 class="md-h5">$1</h5>')
html = html.replace(/^### (.+)$/gm, '<h4 class="md-h4">$1</h4>')
html = html.replace(/^## (.+)$/gm, '<h3 class="md-h3">$1</h3>')
html = html.replace(/^# (.+)$/gm, '<h2 class="md-h2">$1</h2>')
//
// Handle blockquotes
html = html.replace(/^> (.+)$/gm, '<blockquote class="md-quote">$1</blockquote>')
// -
// Handle lists - support nested lists
html = html.replace(/^(\s*)- (.+)$/gm, (match, indent, text) => {
const level = Math.floor(indent.length / 2)
return `<li class="md-li" data-level="${level}">${text}</li>`
@ -1902,52 +1902,52 @@ const renderMarkdown = (content) => {
return `<li class="md-oli" data-level="${level}">${text}</li>`
})
//
// Wrap unordered lists
html = html.replace(/(<li class="md-li"[^>]*>.*?<\/li>\s*)+/g, '<ul class="md-ul">$&</ul>')
//
// Wrap ordered lists
html = html.replace(/(<li class="md-oli"[^>]*>.*?<\/li>\s*)+/g, '<ol class="md-ol">$&</ol>')
//
// Clean up whitespace between list items
html = html.replace(/<\/li>\s+<li/g, '</li><li')
//
// Clean up whitespace after list opening tags
html = html.replace(/<ul class="md-ul">\s+/g, '<ul class="md-ul">')
html = html.replace(/<ol class="md-ol">\s+/g, '<ol class="md-ol">')
//
// Clean up whitespace before list closing tags
html = html.replace(/\s+<\/ul>/g, '</ul>')
html = html.replace(/\s+<\/ol>/g, '</ol>')
//
// Handle bold and italic
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>')
html = html.replace(/_(.+?)_/g, '<em>$1</em>')
// 线
// Handle horizontal rules
html = html.replace(/^---$/gm, '<hr class="md-hr">')
// - <br>
// Handle line breaks - blank lines become paragraph separators, single line breaks become <br>
html = html.replace(/\n\n/g, '</p><p class="md-p">')
html = html.replace(/\n/g, '<br>')
//
// Wrap in paragraphs
html = '<p class="md-p">' + html + '</p>'
//
// Clean up empty paragraphs
html = html.replace(/<p class="md-p"><\/p>/g, '')
html = html.replace(/<p class="md-p">(<h[2-5])/g, '$1')
html = html.replace(/(<\/h[2-5]>)<\/p>/g, '$1')
html = html.replace(/<p class="md-p">(<ul|<ol|<blockquote|<pre|<hr)/g, '$1')
html = html.replace(/(<\/ul>|<\/ol>|<\/blockquote>|<\/pre>)<\/p>/g, '$1')
// <br>
// Clean up <br> tags before/after block elements
html = html.replace(/<br>\s*(<ul|<ol|<blockquote)/g, '$1')
html = html.replace(/(<\/ul>|<\/ol>|<\/blockquote>)\s*<br>/g, '$1')
// <p><br>
// Clean up <p><br> right before block elements (caused by extra blank lines)
html = html.replace(/<p class="md-p">(<br>\s*)+(<ul|<ol|<blockquote|<pre|<hr)/g, '$2')
// <br>
// Clean up consecutive <br> tags
html = html.replace(/(<br>\s*){2,}/g, '<br>')
// <br>
// Clean up <br> between block elements and the start of the next paragraph
html = html.replace(/(<\/ol>|<\/ul>|<\/blockquote>)<br>(<p|<div)/g, '$1$2')
// <ol>
// Fix non-contiguous ordered list numbering: when a single-item <ol> is interrupted by paragraph content, keep numbering increasing
const tokens = html.split(/(<ol class="md-ol">(?:<li class="md-oli"[^>]*>[\s\S]*?<\/li>)+<\/ol>)/g)
let olCounter = 0
let inSequence = false
@ -2013,7 +2013,7 @@ const getActionLabel = (action) => {
const getLogLevelClass = (log) => {
if (log.includes('ERROR') || log.includes('错误')) return 'error'
if (log.includes('WARNING') || log.includes('警告')) return 'warning'
// INFO 使 success
// INFO uses the default color, not marked as success
return ''
}
@ -2042,22 +2042,22 @@ const fetchAgentLog = async () => {
currentSectionIndex.value = log.section_index
}
// section_complete -
// section_complete - section generation complete
if (log.action === 'section_complete') {
if (log.details?.content) {
generatedSections.value[log.section_index] = log.details.content
//
// Auto-expand the newly generated section
expandedContent.value.add(log.section_index - 1)
currentSectionIndex.value = null
}
}
if (log.action === 'report_complete') {
isComplete.value = true
currentSectionIndex.value = null // loading
currentSectionIndex.value = null // Make sure to clear the loading state
emit('update-status', 'completed')
stopPolling()
// nextTick
// Scroll logic is unified in nextTick after the loop ends
}
if (log.action === 'report_start') {
@ -2069,7 +2069,7 @@ const fetchAgentLog = async () => {
nextTick(() => {
if (rightPanel.value) {
//
// If the task is complete, scroll to top; otherwise scroll to bottom to follow the latest logs
if (isComplete.value) {
rightPanel.value.scrollTop = 0
} else {
@ -2084,39 +2084,39 @@ const fetchAgentLog = async () => {
}
}
// - LLM response
// Extract final answer content - extract section content from LLM response
const extractFinalContent = (response) => {
if (!response) return null
// <final_answer>
// Try to extract content within <final_answer> tags
const finalAnswerTagMatch = response.match(/<final_answer>([\s\S]*?)<\/final_answer>/)
if (finalAnswerTagMatch) {
return finalAnswerTagMatch[1].trim()
}
// Final Answer:
// 1: Final Answer:\n\n
// 2: Final Answer:
// Try to find content after "Final Answer:" (supports multiple formats)
// Format 1: Final Answer:\n\ncontent
// Format 2: Final Answer: content
const finalAnswerMatch = response.match(/Final\s*Answer:\s*\n*([\s\S]*)$/i)
if (finalAnswerMatch) {
return finalAnswerMatch[1].trim()
}
// :
// Try to find content after ":"
const chineseFinalMatch = response.match(/最终答案[:]\s*\n*([\s\S]*)$/i)
if (chineseFinalMatch) {
return chineseFinalMatch[1].trim()
}
// ## # > markdown
// If starts with ## or # or >, it may be direct markdown content
const trimmedResponse = response.trim()
if (trimmedResponse.match(/^[#>]/)) {
return trimmedResponse
}
// markdown
// If the content is long and contains markdown, try to strip the thinking process before returning
if (response.length > 300 && (response.includes('**') || response.includes('>'))) {
// Thought:
// Remove thinking process that starts with "Thought:"
const thoughtMatch = response.match(/^Thought:[\s\S]*?(?=\n\n[^T]|\n\n$)/i)
if (thoughtMatch) {
const afterThought = response.substring(thoughtMatch[0].length).trim()
@ -2125,7 +2125,7 @@ const extractFinalContent = (response) => {
}
}
}
return null
}
@ -2461,7 +2461,7 @@ watch(() => props.reportId, (newId) => {
.section-number {
font-family: 'JetBrains Mono', monospace;
font-size: 16px;
color: #9CA3AF; /* 深灰色,不随状态变化 */
color: #9CA3AF; /* Dark gray, not affected by state changes */
font-weight: 500;
}
@ -3903,7 +3903,7 @@ watch(() => props.reportId, (newId) => {
overflow: hidden;
}
/* Selection Reason - 选择理由 */
/* Selection Reason */
:deep(.interview-display .selection-reason) {
background: #F8FAFC;
border: 1px solid #E2E8F0;
@ -5102,7 +5102,7 @@ watch(() => props.reportId, (newId) => {
border-radius: 4px;
}
/* Console Logs - 与 Step3Simulation.vue 保持一致 */
/* Console Logs - kept consistent with Step3Simulation.vue */
.console-logs {
background: #000;
color: #DDD;

View File

@ -437,7 +437,7 @@ const showToolsDetail = ref(true)
// Chat State
const chatInput = ref('')
const chatHistory = ref([])
const chatHistoryCache = ref({}) // : { 'report_agent': [], 'agent_0': [], 'agent_1': [], ... }
const chatHistoryCache = ref({}) // Cache of all chat histories: { 'report_agent': [], 'agent_0': [], 'agent_1': [], ... }
const isSending = ref(false)
const chatMessages = ref(null)
const chatInputRef = ref(null)
@ -487,7 +487,7 @@ const selectChatTarget = (target) => {
}
}
//
// Save the current chat history into the cache
const saveChatHistory = () => {
if (chatHistory.value.length === 0) return
@ -499,7 +499,7 @@ const saveChatHistory = () => {
}
const selectReportAgentChat = () => {
//
// Save the current chat history
saveChatHistory()
activeTab.value = 'chat'
@ -508,7 +508,7 @@ const selectReportAgentChat = () => {
selectedAgentIndex.value = null
showAgentDropdown.value = false
// Report Agent
// Restore the Report Agent chat history
chatHistory.value = chatHistoryCache.value['report_agent'] || []
}
@ -528,7 +528,7 @@ const toggleAgentDropdown = () => {
}
const selectAgent = (agent, idx) => {
//
// Save the current chat history
saveChatHistory()
selectedAgent.value = agent
@ -536,7 +536,7 @@ const selectAgent = (agent, idx) => {
chatTarget.value = 'agent'
showAgentDropdown.value = false
// Agent
// Restore that Agent chat history
chatHistory.value = chatHistoryCache.value[`agent_${idx}`] || []
addLog(t('log.selectChatTarget', { name: agent.username }))
}
@ -566,7 +566,7 @@ const renderMarkdown = (content) => {
html = html.replace(/^# (.+)$/gm, '<h2 class="md-h2">$1</h2>')
html = html.replace(/^> (.+)$/gm, '<blockquote class="md-quote">$1</blockquote>')
// -
// Process lists support nested lists
html = html.replace(/^(\s*)- (.+)$/gm, (match, indent, text) => {
const level = Math.floor(indent.length / 2)
return `<li class="md-li" data-level="${level}">${text}</li>`
@ -576,17 +576,17 @@ const renderMarkdown = (content) => {
return `<li class="md-oli" data-level="${level}">${text}</li>`
})
//
// Wrap unordered lists
html = html.replace(/(<li class="md-li"[^>]*>.*?<\/li>\s*)+/g, '<ul class="md-ul">$&</ul>')
//
// Wrap ordered lists
html = html.replace(/(<li class="md-oli"[^>]*>.*?<\/li>\s*)+/g, '<ol class="md-ol">$&</ol>')
//
// Strip whitespace between list items
html = html.replace(/<\/li>\s+<li/g, '</li><li')
//
// Strip whitespace right after a list opening tag
html = html.replace(/<ul class="md-ul">\s+/g, '<ul class="md-ul">')
html = html.replace(/<ol class="md-ol">\s+/g, '<ol class="md-ol">')
//
// Strip whitespace right before a list closing tag
html = html.replace(/\s+<\/ul>/g, '</ul>')
html = html.replace(/\s+<\/ol>/g, '</ol>')
@ -602,17 +602,17 @@ const renderMarkdown = (content) => {
html = html.replace(/(<\/h[2-5]>)<\/p>/g, '$1')
html = html.replace(/<p class="md-p">(<ul|<ol|<blockquote|<pre|<hr)/g, '$1')
html = html.replace(/(<\/ul>|<\/ol>|<\/blockquote>|<\/pre>)<\/p>/g, '$1')
// <br>
// Strip <br> tags around block elements
html = html.replace(/<br>\s*(<ul|<ol|<blockquote)/g, '$1')
html = html.replace(/(<\/ul>|<\/ol>|<\/blockquote>)\s*<br>/g, '$1')
// <p><br>
// Strip <p><br> right before a block element (excess blank lines)
html = html.replace(/<p class="md-p">(<br>\s*)+(<ul|<ol|<blockquote|<pre|<hr)/g, '$2')
// <br>
// Collapse consecutive <br> tags
html = html.replace(/(<br>\s*){2,}/g, '<br>')
// <br>
// Strip <br> between a block element and the next paragraph opener
html = html.replace(/(<\/ol>|<\/ul>|<\/blockquote>)<br>(<p|<div)/g, '$1$2')
// <ol>
// Renumber non-contiguous <ol> blocks separated by paragraphs
const tokens = html.split(/(<ol class="md-ol">(?:<li class="md-oli"[^>]*>[\s\S]*?<\/li>)+<\/ol>)/g)
let olCounter = 0
let inSequence = false
@ -674,7 +674,7 @@ const sendMessage = async () => {
} finally {
isSending.value = false
scrollToBottom()
//
// Auto-save the chat history into the cache
saveChatHistory()
}
}
@ -722,9 +722,9 @@ const sendToAgent = async (message) => {
const historyContext = chatHistory.value
.filter(msg => msg.content !== message)
.slice(-6)
.map(msg => `${msg.role === 'user' ? '提问者' : '你'}${msg.content}`)
.map(msg => `${msg.role === 'user' ? 'You' : 'Assistant'}: ${msg.content}`)
.join('\n')
prompt = `以下是我们之前的对话:\n${historyContext}\n\n现在我的新问题是${message}`
prompt = `Here is our previous conversation:\n${historyContext}\n\nMy new question is: ${message}`
}
const res = await interviewAgents({
@ -736,17 +736,17 @@ const sendToAgent = async (message) => {
})
if (res.success && res.data) {
// : res.data.result.results
// : {"twitter_0": {...}, "reddit_0": {...}} {"reddit_0": {...}}
// Correct data path: res.data.result.results is an object dict
// Shape: {"twitter_0": {...}, "reddit_0": {...}} or single-platform {"reddit_0": {...}}
const resultData = res.data.result || res.data
const resultsDict = resultData.results || resultData
// reddit
// Convert the object dict into an array, preferring Reddit replies
let responseContent = null
const agentId = selectedAgentIndex.value
if (typeof resultsDict === 'object' && !Array.isArray(resultsDict)) {
// 使 reddit twitter
// Prefer Reddit replies, then Twitter
const redditKey = `reddit_${agentId}`
const twitterKey = `twitter_${agentId}`
const agentResult = resultsDict[redditKey] || resultsDict[twitterKey] || Object.values(resultsDict)[0]
@ -754,7 +754,7 @@ const sendToAgent = async (message) => {
responseContent = agentResult.response || agentResult.answer
}
} else if (Array.isArray(resultsDict) && resultsDict.length > 0) {
//
// Tolerate array shape for backward compatibility
responseContent = resultsDict[0].response || resultsDict[0].answer
}
@ -820,19 +820,19 @@ const submitSurvey = async () => {
})
if (res.success && res.data) {
// : res.data.result.results
// : {"twitter_0": {...}, "reddit_0": {...}, "twitter_1": {...}, ...}
// Correct data path: res.data.result.results is an object dict
// Shape: {"twitter_0": {...}, "reddit_0": {...}, "twitter_1": {...}, ...}
const resultData = res.data.result || res.data
const resultsDict = resultData.results || resultData
//
// Convert the object dict into an array
const surveyResultsList = []
for (const interview of interviews) {
const agentIdx = interview.agent_id
const agent = profiles.value[agentIdx]
// 使 reddit twitter
// Prefer Reddit replies, then Twitter
let responseContent = t('step5.noResponse')
if (typeof resultsDict === 'object' && !Array.isArray(resultsDict)) {
@ -843,7 +843,7 @@ const submitSurvey = async () => {
responseContent = agentResult.response || agentResult.answer || t('step5.noResponse')
}
} else if (Array.isArray(resultsDict)) {
//
// Tolerate array shape for backward compatibility
const matchedResult = resultsDict.find(r => r.agent_id === agentIdx)
if (matchedResult) {
responseContent = matchedResult.response || matchedResult.answer || t('step5.noResponse')
@ -983,7 +983,7 @@ watch(() => props.simulationId, (newId) => {
overflow: hidden;
}
/* Left Panel - Report Style (与 Step4Report.vue 完全一致) */
/* Left Panel — Report Style (mirrors Step4Report.vue) */
.left-panel.report-style {
width: 45%;
min-width: 450px;
@ -2031,7 +2031,7 @@ watch(() => props.simulationId, (newId) => {
margin-bottom: 0;
}
/* 修复有序列表编号 - 使用 CSS 计数器让多个 ol 连续编号 */
/* Renumber ordered lists — use CSS counters to make multiple <ol> blocks number consecutively */
.message-text {
counter-reset: list-counter;
}
@ -2057,7 +2057,7 @@ watch(() => props.simulationId, (newId) => {
flex-shrink: 0;
}
/* 无序列表样式 */
/* Unordered list styling */
.message-text :deep(.md-ul) {
padding-left: 20px;
margin: 8px 0;
@ -2536,7 +2536,7 @@ watch(() => props.simulationId, (newId) => {
margin: 6px 0;
}
/* 聊天/问卷区域的引用样式 */
/* Blockquote styling for the chat / interview area */
.chat-messages :deep(.md-quote),
.result-answer :deep(.md-quote) {
margin: 12px 0;

View File

@ -14,12 +14,12 @@ for (const path in localeFiles) {
}
}
const savedLocale = localStorage.getItem('locale') || 'zh'
const savedLocale = localStorage.getItem('locale') || 'nl'
const i18n = createI18n({
legacy: false,
locale: savedLocale,
fallbackLocale: 'zh',
fallbackLocale: 'en',
messages
})

View File

@ -1,6 +1,7 @@
/**
* 临时存储待上传的文件和需求
* 用于首页点击启动引擎后立即跳转在Process页面再进行API调用
* Temporary store for files + simulation requirement waiting to be uploaded.
* Used so clicking "start engine" on the home page can navigate immediately;
* the Process page performs the actual API upload.
*/
import { reactive } from 'vue'

View File

@ -1,6 +1,6 @@
<template>
<div class="home-container">
<!-- 顶部导航栏 -->
<!-- Top navigation bar -->
<nav class="navbar">
<div class="nav-brand">MIROFISH</div>
<div class="nav-links">
@ -12,7 +12,7 @@
</nav>
<div class="main-content">
<!-- 上半部分Hero 区域 -->
<!-- Top half: Hero section -->
<section class="hero-section">
<div class="hero-left">
<div class="tag-row">
@ -42,7 +42,7 @@
</div>
<div class="hero-right">
<!-- Logo 区域 -->
<!-- Logo area -->
<div class="logo-container">
<img src="../assets/logo/MiroFish_logo_left.jpeg" alt="MiroFish Logo" class="hero-logo" />
</div>
@ -53,9 +53,9 @@
</div>
</section>
<!-- 下半部分双栏布局 -->
<!-- Bottom half: two-column layout -->
<section class="dashboard-section">
<!-- 左栏状态与步骤 -->
<!-- Left column: status & steps -->
<div class="left-panel">
<div class="panel-header">
<span class="status-dot"></span> {{ $t('home.systemStatus') }}
@ -66,7 +66,7 @@
{{ $t('home.systemReadyDesc') }}
</p>
<!-- 数据指标卡片 -->
<!-- Metrics cards -->
<div class="metrics-row">
<div class="metric-card">
<div class="metric-value">{{ $t('home.metricLowCost') }}</div>
@ -78,7 +78,7 @@
</div>
</div>
<!-- 项目模拟步骤介绍 (新增区域) -->
<!-- Project simulation step overview (new section) -->
<div class="steps-container">
<div class="steps-header">
<span class="diamond-icon"></span> {{ $t('home.workflowSequence') }}
@ -123,10 +123,10 @@
</div>
</div>
<!-- 右栏交互控制台 -->
<!-- Right column: interaction console -->
<div class="right-panel">
<div class="console-box">
<!-- 上传区域 -->
<!-- Upload area -->
<div class="console-section">
<div class="console-header">
<span class="console-label">{{ $t('home.realitySeed') }}</span>
@ -167,12 +167,12 @@
</div>
</div>
<!-- 分割线 -->
<!-- Divider -->
<div class="console-divider">
<span>{{ $t('home.inputParams') }}</span>
</div>
<!-- 输入区域 -->
<!-- Input area -->
<div class="console-section">
<div class="console-header">
<span class="console-label">{{ $t('home.simulationPrompt') }}</span>
@ -189,7 +189,7 @@
</div>
</div>
<!-- 启动按钮 -->
<!-- Start button -->
<div class="console-section btn-section">
<button
class="start-engine-btn"
@ -205,7 +205,7 @@
</div>
</section>
<!-- 历史项目数据库 -->
<!-- Historical project database -->
<HistoryDatabase />
</div>
</div>
@ -219,41 +219,41 @@ import LanguageSwitcher from '../components/LanguageSwitcher.vue'
const router = useRouter()
//
// Form data
const formData = ref({
simulationRequirement: ''
})
//
// File list
const files = ref([])
//
// State
const loading = ref(false)
const error = ref('')
const isDragOver = ref(false)
//
// File input ref
const fileInput = ref(null)
// :
// Computed: can the form be submitted
const canSubmit = computed(() => {
return formData.value.simulationRequirement.trim() !== '' && files.value.length > 0
})
//
// Trigger file selection
const triggerFileInput = () => {
if (!loading.value) {
fileInput.value?.click()
}
}
//
// Handle file selection
const handleFileSelect = (event) => {
const selectedFiles = Array.from(event.target.files)
addFiles(selectedFiles)
}
//
// Handle drag-and-drop
const handleDragOver = (e) => {
if (!loading.value) {
isDragOver.value = true
@ -272,7 +272,7 @@ const handleDrop = (e) => {
addFiles(droppedFiles)
}
//
// Add files
const addFiles = (newFiles) => {
const validFiles = newFiles.filter(file => {
const ext = file.name.split('.').pop().toLowerCase()
@ -281,12 +281,12 @@ const addFiles = (newFiles) => {
files.value.push(...validFiles)
}
//
// Remove file
const removeFile = (index) => {
files.value.splice(index, 1)
}
//
// Scroll to bottom
const scrollToBottom = () => {
window.scrollTo({
top: document.body.scrollHeight,
@ -294,15 +294,15 @@ const scrollToBottom = () => {
})
}
// - APIProcess
// Start simulation navigate immediately; API call happens on Process page
const startSimulation = () => {
if (!canSubmit.value || loading.value) return
//
// Store the pending upload
import('../store/pendingUpload.js').then(({ setPendingUpload }) => {
setPendingUpload(files.value, formData.value.simulationRequirement)
// Process使
// Immediately navigate to Process page (with a sentinel projectId for new projects)
router.push({
name: 'Process',
params: { projectId: 'new' }
@ -312,7 +312,7 @@ const startSimulation = () => {
</script>
<style scoped>
/* 全局变量与重置 */
/* Global variables and resets */
:root {
--black: #000000;
--white: #FFFFFF;
@ -321,8 +321,8 @@ const startSimulation = () => {
--gray-text: #666666;
--border: #E5E5E5;
/*
使用 Space Grotesk 作为主要标题字体JetBrains Mono 作为代码/标签字体
确保已在 index.html 引入这些 Google Fonts
Use Space Grotesk as the primary heading font, JetBrains Mono for code/labels
Make sure these Google Fonts are loaded in index.html
*/
--font-mono: 'JetBrains Mono', monospace;
--font-sans: 'Space Grotesk', 'Noto Sans SC', system-ui, sans-serif;
@ -336,7 +336,7 @@ const startSimulation = () => {
color: var(--black);
}
/* 顶部导航 */
/* Top navigation */
.navbar {
height: 60px;
background: var(--black);
@ -380,14 +380,14 @@ const startSimulation = () => {
font-family: sans-serif;
}
/* 主要内容区 */
/* Main content area */
.main-content {
max-width: 1400px;
margin: 0 auto;
padding: 60px 40px;
}
/* Hero 区域 */
/* Hero section */
.hero-section {
display: flex;
justify-content: space-between;
@ -518,7 +518,7 @@ const startSimulation = () => {
}
.hero-logo {
max-width: 500px; /* 调整logo大小 */
max-width: 500px; /* Adjust logo size */
width: 100%;
}
@ -540,7 +540,7 @@ const startSimulation = () => {
border-color: var(--orange);
}
/* Dashboard 双栏布局 */
/* Dashboard two-column layout */
.dashboard-section {
display: flex;
gap: 60px;
@ -555,7 +555,7 @@ const startSimulation = () => {
flex-direction: column;
}
/* 左侧面板 */
/* Left panel */
.left-panel {
flex: 0.8;
}
@ -611,7 +611,7 @@ const startSimulation = () => {
color: #999;
}
/* 项目模拟步骤介绍 */
/* Project simulation step overview */
.steps-container {
border: 1px solid var(--border);
padding: 30px;
@ -667,14 +667,14 @@ const startSimulation = () => {
color: var(--gray-text);
}
/* 右侧交互控制台 */
/* Right interaction console */
.right-panel {
flex: 1.2;
}
.console-box {
border: 1px solid #CCC; /* 外部实线 */
padding: 8px; /* 内边距形成双重边框感 */
border: 1px solid #CCC; /* Outer solid border */
padding: 8px; /* Inner padding for a double-border effect */
}
.console-section {
@ -842,7 +842,7 @@ const startSimulation = () => {
overflow: hidden;
}
/* 可点击状态(非禁用) */
/* Clickable (non-disabled) state */
.start-engine-btn:not(:disabled) {
background: var(--black);
border: 1px solid var(--black);
@ -867,14 +867,14 @@ const startSimulation = () => {
border: 1px solid #E5E5E5;
}
/* 引导动画:微妙的边框脉冲 */
/* Onboarding animation: subtle border pulse */
@keyframes pulse-border {
0% { box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.2); }
70% { box-shadow: 0 0 0 6px rgba(0, 0, 0, 0); }
100% { box-shadow: 0 0 0 0 rgba(0, 0, 0, 0); }
}
/* 响应式适配 */
/* Responsive */
@media (max-width: 1024px) {
.dashboard-section {
flex-direction: column;

View File

@ -49,7 +49,7 @@
/>
</div>
<!-- Right Panel: Step5 深度互动 -->
<!-- Right Panel: Step5 Deep Interaction -->
<div class="panel-wrapper right" :style="rightPanelStyle">
<Step5Interaction
:reportId="currentReportId"
@ -83,7 +83,7 @@ const props = defineProps({
reportId: String
})
// Layout State -
// Layout state default to the workbench view
const viewMode = ref('workbench')
// Data State
@ -147,26 +147,26 @@ const loadReportData = async () => {
try {
addLog(t('log.loadReportData', { id: currentReportId.value }))
// report simulation_id
// Fetch report info to get the simulation_id
const reportRes = await getReport(currentReportId.value)
if (reportRes.success && reportRes.data) {
const reportData = reportRes.data
simulationId.value = reportData.simulation_id
if (simulationId.value) {
// simulation
// Fetch simulation info
const simRes = await getSimulation(simulationId.value)
if (simRes.success && simRes.data) {
const simData = simRes.data
// project
// Fetch project info
if (simData.project_id) {
const projRes = await getProject(simData.project_id)
if (projRes.success && projRes.data) {
projectData.value = projRes.data
addLog(t('log.projectLoadSuccess', { id: projRes.data.project_id }))
// graph
// Fetch graph data
if (projRes.data.graph_id) {
await loadGraph(projRes.data.graph_id)
}

View File

@ -50,7 +50,7 @@
<!-- Right Panel: Step Components -->
<div class="panel-wrapper right" :style="rightPanelStyle">
<!-- Step 1: 图谱构建 -->
<!-- Step 1: Graph Build -->
<Step1GraphBuild
v-if="currentStep === 1"
:currentPhase="currentPhase"
@ -61,7 +61,7 @@
:systemLogs="systemLogs"
@next-step="handleNextStep"
/>
<!-- Step 2: 环境搭建 -->
<!-- Step 2: Environment Setup -->
<Step2EnvSetup
v-else-if="currentStep === 2"
:projectData="projectData"
@ -95,7 +95,7 @@ const { t, tm } = useI18n()
const viewMode = ref('split') // graph | split | workbench
// Step State
const currentStep = ref(1) // 1: , 2: , 3: , 4: , 5:
const currentStep = ref(1) // 1: Graph Build, 2: Environment Setup, 3: Start Simulation, 4: Report Generation, 5: Deep Interaction
const stepNames = computed(() => tm('main.stepNames'))
// Data State
@ -166,7 +166,7 @@ const handleNextStep = (params = {}) => {
currentStep.value++
addLog(t('log.enterStep', { step: currentStep.value, name: stepNames.value[currentStep.value - 1] }))
// Step 2 Step 3
// If transitioning from Step 2 to Step 3, record the configured simulation round count
if (currentStep.value === 3 && params.maxRounds) {
addLog(t('log.customSimRounds', { rounds: params.maxRounds }))
}

File diff suppressed because it is too large Load Diff

View File

@ -49,7 +49,7 @@
/>
</div>
<!-- Right Panel: Step4 报告生成 -->
<!-- Right Panel: Step4 Report Generation -->
<div class="panel-wrapper right" :style="rightPanelStyle">
<Step4Report
:reportId="currentReportId"
@ -83,7 +83,7 @@ const props = defineProps({
reportId: String
})
// Layout State -
// Layout state default to the workbench view
const viewMode = ref('workbench')
// Data State
@ -146,26 +146,26 @@ const loadReportData = async () => {
try {
addLog(t('log.loadReportData', { id: currentReportId.value }))
// report simulation_id
// Fetch report info to get the simulation_id
const reportRes = await getReport(currentReportId.value)
if (reportRes.success && reportRes.data) {
const reportData = reportRes.data
simulationId.value = reportData.simulation_id
if (simulationId.value) {
// simulation
// Fetch simulation info
const simRes = await getSimulation(simulationId.value)
if (simRes.success && simRes.data) {
const simData = simRes.data
// project
// Fetch project info
if (simData.project_id) {
const projRes = await getProject(simData.project_id)
if (projRes.success && projRes.data) {
projectData.value = projRes.data
addLog(t('log.projectLoadSuccess', { id: projRes.data.project_id }))
// graph
// Fetch graph data
if (projRes.data.graph_id) {
await loadGraph(projRes.data.graph_id)
}

View File

@ -49,7 +49,7 @@
/>
</div>
<!-- Right Panel: Step3 开始模拟 -->
<!-- Right Panel: Step3 Start Simulation -->
<div class="panel-wrapper right" :style="rightPanelStyle">
<Step3Simulation
:simulationId="currentSimulationId"
@ -92,9 +92,9 @@ const viewMode = ref('split')
// Data State
const currentSimulationId = ref(route.params.simulationId)
// query maxRounds
// Read maxRounds from query params at init so child components get it immediately
const maxRounds = ref(route.query.maxRounds ? parseInt(route.query.maxRounds) : null)
const minutesPerRound = ref(30) // 30
const minutesPerRound = ref(30) // 30 minutes per round by default
const projectData = ref(null)
const graphData = ref(null)
const graphLoading = ref(false)
@ -150,14 +150,14 @@ const toggleMaximize = (target) => {
}
const handleGoBack = async () => {
// Step 2
// Before returning to Step 2, shut down any running simulation
addLog(t('log.preparingGoBack'))
//
// Stop polling
stopGraphRefresh()
try {
//
// Try a graceful shutdown of the simulation environment first
const envStatusRes = await getEnvStatus({ simulation_id: currentSimulationId.value })
if (envStatusRes.success && envStatusRes.data?.env_alive) {
@ -178,7 +178,7 @@ const handleGoBack = async () => {
}
}
} else {
//
// Environment not running check if a process still needs to be killed
if (isSimulating.value) {
addLog(t('log.stoppingSimProcess'))
try {
@ -193,13 +193,13 @@ const handleGoBack = async () => {
addLog(t('log.checkStatusFailed', { error: err.message }))
}
// Step 2 ()
// Return to Step 2 (Environment Setup)
router.push({ name: 'Simulation', params: { simulationId: currentSimulationId.value } })
}
const handleNextStep = () => {
// Step3Simulation
//
// Step3Simulation handles report generation and route navigation directly
// This method is just a fallback
addLog(t('log.enterStep4'))
}
@ -208,12 +208,12 @@ const loadSimulationData = async () => {
try {
addLog(t('log.loadingSimData', { id: currentSimulationId.value }))
// simulation
// Fetch simulation info
const simRes = await getSimulation(currentSimulationId.value)
if (simRes.success && simRes.data) {
const simData = simRes.data
// simulation config minutes_per_round
// Fetch simulation config to obtain minutes_per_round
try {
const configRes = await getSimulationConfig(currentSimulationId.value)
if (configRes.success && configRes.data?.time_config?.minutes_per_round) {
@ -224,14 +224,14 @@ const loadSimulationData = async () => {
addLog(t('log.timeConfigFetchFailed', { minutes: minutesPerRound.value }))
}
// project
// Fetch project info
if (simData.project_id) {
const projRes = await getProject(simData.project_id)
if (projRes.success && projRes.data) {
projectData.value = projRes.data
addLog(t('log.projectLoadSuccess', { id: projRes.data.project_id }))
// graph
// Fetch graph data
if (projRes.data.graph_id) {
await loadGraph(projRes.data.graph_id)
}
@ -246,8 +246,8 @@ const loadSimulationData = async () => {
}
const loadGraph = async (graphId) => {
// loading
// loading
// While simulating, automatic refresh hides the full-screen loader to avoid flicker
// Show the loader on manual refresh or initial load
if (!isSimulating.value) {
graphLoading.value = true
}
@ -279,7 +279,7 @@ let graphRefreshTimer = null
const startGraphRefresh = () => {
if (graphRefreshTimer) return
addLog(t('log.graphRealtimeRefreshStart'))
// 30
// Refresh once immediately, then every 30 seconds
graphRefreshTimer = setInterval(refreshGraph, 30000)
}
@ -302,7 +302,7 @@ watch(isSimulating, (newValue) => {
onMounted(() => {
addLog(t('log.simRunViewInit'))
// maxRounds query
// Record the maxRounds config (already read from query params at init)
if (maxRounds.value) {
addLog(t('log.customRounds', { rounds: maxRounds.value }))
}

View File

@ -48,7 +48,7 @@
/>
</div>
<!-- Right Panel: Step2 环境搭建 -->
<!-- Right Panel: Step2 Environment Setup -->
<div class="panel-wrapper right" :style="rightPanelStyle">
<Step2EnvSetup
:simulationId="currentSimulationId"
@ -142,7 +142,7 @@ const toggleMaximize = (target) => {
}
const handleGoBack = () => {
// process
// Navigate back to the Process page
if (projectData.value?.project_id) {
router.push({ name: 'Process', params: { projectId: projectData.value.project_id } })
} else {
@ -153,65 +153,65 @@ const handleGoBack = () => {
const handleNextStep = (params = {}) => {
addLog(t('log.enterStep3'))
//
// Record the simulation round configuration
if (params.maxRounds) {
addLog(t('log.customRoundsConfig', { rounds: params.maxRounds }))
} else {
addLog(t('log.useAutoRounds'))
}
//
// Build route params
const routeParams = {
name: 'SimulationRun',
params: { simulationId: currentSimulationId.value }
}
// query
// Pass custom round count via query param when present
if (params.maxRounds) {
routeParams.query = { maxRounds: params.maxRounds }
}
// Step 3
// Navigate to Step 3
router.push(routeParams)
}
// --- Data Logic ---
/**
* 检查并关闭正在运行的模拟
* 当用户从 Step 3 返回到 Step 2 默认用户要退出模拟
* Check for and shut down any running simulation
* When the user returns from Step 3 to Step 2, we assume they want to exit the simulation
*/
const checkAndStopRunningSimulation = async () => {
if (!currentSimulationId.value) return
try {
//
// First check whether the simulation environment is alive
const envStatusRes = await getEnvStatus({ simulation_id: currentSimulationId.value })
if (envStatusRes.success && envStatusRes.data?.env_alive) {
addLog(t('log.detectedSimEnvRunning'))
//
// Try to gracefully shut down the simulation environment
try {
const closeRes = await closeSimulationEnv({
simulation_id: currentSimulationId.value,
timeout: 10 // 10
timeout: 10 // 10-second timeout
})
if (closeRes.success) {
addLog(t('log.simEnvClosed'))
} else {
addLog(t('log.closeSimEnvFailedWithError', { error: closeRes.error || t('common.unknownError') }))
//
// If graceful shutdown failed, try a forced stop
await forceStopSimulation()
}
} catch (closeErr) {
addLog(t('log.closeSimEnvException', { error: closeErr.message }))
//
// If graceful shutdown raised, try a forced stop
await forceStopSimulation()
}
} else {
//
// Environment is not running, but a process may still be alive check status
const simRes = await getSimulation(currentSimulationId.value)
if (simRes.success && simRes.data?.status === 'running') {
addLog(t('log.detectedSimRunning'))
@ -219,13 +219,13 @@ const checkAndStopRunningSimulation = async () => {
}
}
} catch (err) {
//
console.warn('检查模拟状态失败:', err)
// Failing to check env status shouldn't block the rest of the flow
console.warn('Failed to check simulation status:', err)
}
}
/**
* 强制停止模拟
* Force-stop the simulation
*/
const forceStopSimulation = async () => {
try {
@ -244,19 +244,19 @@ const loadSimulationData = async () => {
try {
addLog(t('log.loadingSimData', { id: currentSimulationId.value }))
// simulation
// Fetch simulation info
const simRes = await getSimulation(currentSimulationId.value)
if (simRes.success && simRes.data) {
const simData = simRes.data
// project
// Fetch project info
if (simData.project_id) {
const projRes = await getProject(simData.project_id)
if (projRes.success && projRes.data) {
projectData.value = projRes.data
addLog(t('log.projectLoadSuccess', { id: projRes.data.project_id }))
// graph
// Fetch graph data
if (projRes.data.graph_id) {
await loadGraph(projRes.data.graph_id)
}
@ -294,10 +294,10 @@ const refreshGraph = () => {
onMounted(async () => {
addLog(t('log.simViewInit'))
// Step 3
// Check for and shut down any running simulation (when the user returns from Step 3)
await checkAndStopRunningSimulation()
//
// Load simulation data
loadSimulationData()
})
</script>

View File

@ -26,5 +26,9 @@
"de": {
"label": "Deutsch",
"llmInstruction": "Bitte antworten Sie auf Deutsch."
},
"nl": {
"label": "Nederlands",
"llmInstruction": "Antwoord in het Nederlands."
}
}

665
locales/nl.json Normal file
View File

@ -0,0 +1,665 @@
{
"common": {
"confirm": "Bevestigen",
"cancel": "Annuleren",
"loading": "Laden...",
"error": "Fout",
"success": "Gelukt",
"completed": "Voltooid",
"processing": "Genereren",
"pending": "In afwachting",
"ready": "Gereed",
"running": "Bezig",
"failed": "Mislukt",
"unknown": "Onbekend",
"unknownError": "Onbekende fout",
"none": "Geen",
"close": "Sluiten",
"back": "Terug",
"next": "Volgende",
"retry": "Opnieuw proberen",
"noData": "Geen gegevens beschikbaar",
"hours": "uur",
"minutes": "minuten",
"rounds": "rondes",
"items": "items",
"files": "bestanden"
},
"meta": {
"title": "MiroFish - Voorspel Alles",
"description": "MiroFish - Sociaalmedia-meningensimulatiesysteem"
},
"nav": {
"visitGithub": "Bezoek onze Github-pagina"
},
"home": {
"tagline": "Beknopte & Universele Zwermintelligentie-Engine",
"version": "/ v0.1-Preview",
"heroTitle1": "Upload rapporten,",
"heroTitle2": "Voorspel de toekomst",
"heroDesc": "Uit één enkel document haalt {brand} realiteitszaden om automatisch een parallelle wereld te genereren met tot wel {agentScale}. Injecteer variabelen vanuit een goddelijk perspectief om de {optimalSolution} te vinden in complexe groepsdynamiek.",
"heroDescBrand": "MiroFish",
"heroDescAgentScale": "miljoenen Agents",
"heroDescOptimalSolution": "\"lokaal optimum\"",
"slogan": "Laat Agents de toekomst repeteren, laat beslissingen zegevieren",
"systemStatus": "Systeemstatus",
"systemReady": "Gereed",
"systemReadyDesc": "Voorspellingsengine stand-by. Upload ongestructureerde data om een simulatiereeks te starten.",
"metricLowCost": "Lage kosten",
"metricLowCostDesc": "Gem. $5/sim",
"metricHighAvail": "Schaalbaar",
"metricHighAvailDesc": "Miljoenen Agents",
"workflowSequence": "Workflow",
"step01Title": "Graafopbouw",
"step01Desc": "Zaadextractie & geheugeninjectie & GraphRAG-constructie",
"step02Title": "Omgeving instellen",
"step02Desc": "Entiteitsextractie & persoonlijkheidsgeneratie & Agent-configuratie-injectie",
"step03Title": "Simulatie",
"step03Desc": "Parallelle sim op twee platforms & automatische vereiste-parsing & temporeel geheugen",
"step04Title": "Rapport",
"step04Desc": "ReportAgent interageert met de post-simulatie-omgeving via uitgebreide tools",
"step05Title": "Interactie",
"step05Desc": "Chat met elke gesimuleerde persoon & voer gesprekken met ReportAgent",
"realitySeed": "01 / Realiteitszaad",
"supportedFormats": "Formaten: PDF, MD, TXT",
"dragToUpload": "Sleep bestanden hierheen om te uploaden",
"orBrowse": "of klik om bestanden te bladeren",
"inputParams": "Invoerparameters",
"simulationPrompt": ">_ 02 / Simulatievraag",
"promptPlaceholder": "// Beschrijf uw simulatie- of voorspellingsbehoefte in natuurlijke taal",
"engineBadge": "Engine: MiroFish-V1.0",
"startEngine": "Engine starten",
"initializing": "Initialiseren..."
},
"main": {
"layoutGraph": "Graaf",
"layoutSplit": "Gesplitst",
"layoutWorkbench": "Werkbank",
"stepNames": ["Graafopbouw", "Omgeving instellen", "Simulatie uitvoeren", "Rapportgeneratie", "Diepe interactie"]
},
"step1": {
"ontologyGeneration": "Ontologiegeneratie",
"ontologyCompleted": "Voltooid",
"ontologyGenerating": "Genereren",
"ontologyPending": "In afwachting",
"ontologyDesc": "LLM analyseert documentinhoud en simulatievereisten, haalt realiteitszaden eruit en genereert automatisch een geschikte ontologiestructuur",
"analyzingDocs": "Documenten analyseren...",
"graphRagBuild": "GraphRAG-opbouw",
"graphRagDesc": "Op basis van de gegenereerde ontologie worden documenten automatisch opgedeeld en naar Zep gestuurd om een kennisgraaf te bouwen, entiteiten en relaties te extraheren, en temporeel geheugen en samenvattingen per gemeenschap te vormen",
"entityNodes": "Entiteitsknooppunten",
"relationEdges": "Relatieranden",
"schemaTypes": "Schematypes",
"buildComplete": "Opbouw voltooid",
"buildCompleteDesc": "Graafopbouw is voltooid. Ga verder naar de volgende stap voor het instellen van de simulatieomgeving.",
"inProgress": "Bezig",
"creating": "Aanmaken...",
"enterEnvSetup": "Naar omgevingsinstelling",
"createSimulationFailed": "Simulatie aanmaken mislukt: {error}",
"createSimulationException": "Fout bij aanmaken simulatie: {error}"
},
"step2": {
"simInstanceInit": "Initialisatie simulatie-instantie",
"simInstanceDesc": "Maak een nieuwe simulatie-instantie aan en haal wereldparametersjablonen op",
"asyncTaskDone": "Asynchrone taak voltooid",
"generateAgentPersona": "Agent-persona's genereren",
"generateAgentPersonaDesc": "Combineer context om automatisch entiteiten en relaties uit de kennisgraaf te extraheren, simuleer personen te initialiseren en uniek gedrag en geheugens toe te wijzen op basis van realiteitszaden",
"currentAgentCount": "Huidige Agents",
"expectedAgentTotal": "Verwacht aantal Agents",
"relatedTopicsCount": "Onderwerpen gerelateerd aan realiteitszaad",
"generatedAgentPersonas": "Gegenereerde Agent-persona's",
"unknownProfession": "Onbekend beroep",
"noBio": "Geen bio beschikbaar",
"dualPlatformConfig": "Configuratie voor twee platforms genereren",
"dualPlatformConfigDesc": "LLM stelt intelligent de wereldtijdsverloop, aanbevelingsalgoritmes, actieve uren per persoon, postfrequentie, gebeurtenis-triggers en meer in op basis van vereisten en realiteitszaden",
"simulationDuration": "Simulatieduur",
"roundDuration": "Ronde-duur",
"totalRounds": "Totaal aantal rondes",
"activePerHour": "Actief per uur",
"peakHours": "Piekuren",
"workHours": "Werkuren",
"morningHours": "Ochtenduren",
"offPeakHours": "Daluren",
"agentConfig": "Agent-configuratie",
"activeTimePeriod": "Actieve uren",
"postsPerHour": "Berichten/uur",
"commentsPerHour": "Reacties/uur",
"responseDelay": "Reactievertraging",
"activityLevel": "Activiteitsniveau",
"sentimentBias": "Sentimentvoorkeur",
"influenceWeight": "Invloed",
"recommendAlgoConfig": "Aanbevelingsalgoritme-configuratie",
"platform1Name": "Platform 1: Plein / Feed",
"platform2Name": "Platform 2: Onderwerp / Community",
"recencyWeight": "Recency-gewicht",
"popularityWeight": "Populariteitsgewicht",
"relevanceWeight": "Relevantiegewicht",
"viralThreshold": "Viraaldrempel",
"echoChamberStrength": "Echo-kamer-sterkte",
"llmConfigReasoning": "LLM-configuratie-redenering",
"initialActivation": "Initiële activatie-orkestratie",
"initialActivationDesc": "Genereer automatisch initiële activatiegebeurtenissen en hot topics op basis van de narratieve richting om de begintoestand van de simulatiewereld te sturen",
"orchestrating": "Orkestreren",
"narrativeDirection": "Narratieve richting",
"initialHotTopics": "Initiële hot topics",
"initialActivationSeq": "Initiële activatievolgorde ({count})",
"setupComplete": "Instelling voltooid",
"setupCompleteDesc": "Simulatieomgeving is gereed. U kunt de simulatie nu starten.",
"roundsConfig": "Configuratie van simulatierondes",
"roundsConfigDesc": "MiroFish plant automatisch {hours} echte uren te simuleren, waarbij elke ronde {minutesPerRound} minuten voorstelt",
"customToggle": "Aangepast",
"roundsUnit": "rondes",
"estimatedDuration": "Voor 100 Agents: geschat ~{minutes} minuten",
"estimatedDurationFull": "Voor 100 Agents: geschat {minutes} minuten",
"recommendedRounds": "{rounds} (aanbevolen)",
"customTip": "Voor de eerste keer raden we sterk aan om over te schakelen naar 'Aangepaste modus' om het aantal rondes te verlagen voor een snelle voorvertoning en lager foutrisico",
"backToGraphBuild": "Terug naar graafopbouw",
"startDualWorldSim": "Parallelle simulatie van twee werelden starten",
"profileModalAge": "Schijnbare leeftijd",
"profileModalGender": "Schijnbaar geslacht",
"profileModalCountry": "Land/regio",
"profileModalMbti": "Schijnbare MBTI",
"profileModalBio": "Persona-bio",
"profileModalTopics": "Onderwerpen gerelateerd aan realiteitszaad",
"profileModalPersona": "Gedetailleerde persona-achtergrond",
"personaDimExperience": "Volledige gebeurteniservaring",
"personaDimExperienceDesc": "Compleet gedragsverloop in deze gebeurtenis",
"personaDimBehavior": "Gedragsprofiel",
"personaDimBehaviorDesc": "Ervaringssamenvatting en gedragsvoorkeuren",
"personaDimMemory": "Unieke geheugenafdruk",
"personaDimMemoryDesc": "Herinneringen gevormd door realiteitszaden",
"personaDimSocial": "Sociaal netwerk",
"personaDimSocialDesc": "Individuele verbindingen en interactiegraaf",
"genderMale": "Man",
"genderFemale": "Vrouw",
"genderOther": "Anders",
"yearsOld": "jaar oud",
"initializing": "Initialiseren",
"generating": "Genereren"
},
"step3": {
"startGenerateReport": "Rapport genereren",
"generatingReport": "Starten...",
"waitingForActions": "Wachten op acties van agents...",
"errorMissingSimId": "Fout: ontbrekende simulationId",
"startingDualSim": "Parallelle simulatie op twee platforms starten...",
"graphMemoryUpdateEnabled": "Dynamische graafgeheugen-update ingeschakeld",
"setMaxRounds": "Maximaal aantal simulatierondes ingesteld op: {rounds}",
"oldSimCleared": "Oude simulatielogs gewist, simulatie wordt herstart",
"engineStarted": "Simulatie-engine succesvol gestart",
"startFailed": "Starten mislukt: {error}",
"startException": "Startfout: {error}",
"stoppingSim": "Simulatie stoppen...",
"simStopped": "Simulatie gestopt",
"stopFailed": "Stoppen mislukt: {error}",
"stopException": "Stopfout: {error}",
"allPlatformsCompleted": "Alle platformsimulaties zijn beëindigd",
"simCompleted": "Simulatie voltooid",
"graphRealtimeRefresh": "Realtime graafverversing ingeschakeld (30s)",
"graphRefreshStopped": "Realtime graafverversing gestopt",
"preparingGoBack": "Voorbereiden om terug te keren naar stap 2, simulatie sluiten...",
"closingSimEnv": "Simulatieomgeving sluiten...",
"simEnvClosed": "Simulatieomgeving gesloten",
"closeFailed": "Sluiten van simulatieomgeving mislukt, geforceerde stop wordt geprobeerd...",
"stoppingProcess": "Simulatieproces stoppen...",
"checkStatusFailed": "Simulatiestatus controleren mislukt: {error}",
"forceStopSuccess": "Simulatie geforceerd gestopt",
"forceStopFailed": "Geforceerde stop mislukt: {error}",
"startGenerateReportBtn": "Rapport genereren",
"generatingReportBtn": "Starten..."
},
"step4": {
"generatingSection": "{title} genereren...",
"goToInteraction": "Naar diepe interactie",
"waitingForReportAgent": "Wachten op Report Agent...",
"collapse": "Inklappen ▲",
"expandAll": "Toon alle {count} ▼",
"expandAllEntities": "Toon alle {count} ▼",
"scenarioLabel": "Scenario: ",
"tabKeyFacts": "Kernfeiten ({count})",
"tabCoreEntities": "Kernentiteiten ({count})",
"tabRelationChains": "Relatieketens ({count})",
"tabSubQueries": "Subvragen ({count})",
"panelKeyFacts": "Laatste kernfeiten uit temporeel geheugen",
"totalCount": "{count} totaal",
"totalEntityCount": "{count} totaal",
"panelCoreEntities": "Kernentiteiten",
"factCount": "{count} feiten",
"panelRelationChains": "Relatieketens",
"panelSubQueries": "Drift-query-analyse subvragen",
"emptyKeyFacts": "Geen kernfeiten beschikbaar",
"emptyCoreEntities": "Geen kernentiteiten beschikbaar",
"emptyRelationChains": "Geen relatieketens beschikbaar",
"tabActiveFacts": "Actieve feiten ({count})",
"tabHistoricalFacts": "Historische feiten ({count})",
"tabEntities": "Entiteiten ({count})",
"panelActiveFacts": "Actieve feiten",
"emptyActiveFacts": "Geen actieve feiten beschikbaar",
"panelHistoricalFacts": "Historische feiten",
"emptyHistoricalFacts": "Geen historische feiten beschikbaar",
"panelEntities": "Entiteiten",
"emptyEntities": "Geen entiteiten beschikbaar",
"searchLabel": "Zoeken: ",
"tabFacts": "Feiten ({count})",
"tabEdges": "Randen ({count})",
"tabNodes": "Knooppunten ({count})",
"panelSearchResults": "Zoekresultaten",
"emptySearchResults": "Geen resultaten gevonden",
"panelRelatedEdges": "Gerelateerde randen",
"panelRelatedNodes": "Gerelateerde knooppunten",
"world1": "Wereld 1",
"world2": "Wereld 2"
},
"step5": {
"interactiveTools": "Interactieve tools",
"agentsAvailable": "{count} agents beschikbaar",
"chatWithReportAgent": "Chat met Report Agent",
"chatWithAgent": "Chat met elke persoon in de wereld",
"selectChatTarget": "Kies chatdoel",
"sendSurvey": "Stuur enquête naar de wereld",
"reportAgentChat": "Report Agent - Chat",
"reportAgentDesc": "Een conversationele versie van de rapportgeneratie-agent met toegang tot 4 professionele tools en het volledige geheugen van MiroFish",
"toolInsightForge": "InsightForge Diepe Attributie",
"toolInsightForgeDesc": "Brengt realiteitszaadgegevens in lijn met simulatiestatus en combineert Globaal/Lokaal geheugen voor diepgaande attribueanalyse over de tijd",
"toolPanoramaSearch": "PanoramaSearch Volledige Tracking",
"toolPanoramaSearchDesc": "Op grafen gebaseerd BFS-algoritme dat verspreidingspaden van gebeurtenissen reconstrueert en de volledige topologie van informatiedoorstroming vastlegt",
"toolQuickSearch": "QuickSearch Snelle Ophalen",
"toolQuickSearchDesc": "GraphRAG-gebaseerde instant-query-interface met geoptimaliseerde indexering voor snelle extractie van knooppuntattributen en discrete feiten",
"toolInterviewSubAgent": "InterviewSubAgent Virtueel Interview",
"toolInterviewSubAgentDesc": "Autonome interviews die parallelle meer-ronde-dialogen voeren met gesimuleerde personen en ongestructureerde meningen en psychologische toestanden verzamelen",
"profileBio": "Bio",
"chatEmptyReportAgent": "Chat met Report Agent om de rapportinhoud diepgaand te verkennen",
"chatEmptyAgent": "Chat met gesimuleerde personen om hun perspectieven te begrijpen",
"chatInputPlaceholder": "Typ uw vraag...",
"selectSurveyTarget": "Kies enquêtedoelen",
"selectedCount": "{selected} / {total} geselecteerd",
"surveyQuestions": "Enquêtevragen",
"surveyInputPlaceholder": "Voer de vraag in die u aan alle geselecteerde doelen wilt stellen...",
"submitSurvey": "Enquête versturen",
"surveyResults": "Enquêteresultaten",
"surveyResultsCount": "{count} reacties",
"selectAll": "Alles selecteren",
"clearSelection": "Wissen",
"errorOccurred": "Sorry, er is een fout opgetreden: {error}",
"noResponse": "Geen reactie",
"requestFailed": "Verzoek mislukt",
"selectAgentFirst": "Selecteer eerst een gesimuleerde persoon"
},
"graph": {
"panelTitle": "Visualisatie graafrelaties",
"refreshGraph": "Graaf verversen",
"graphMemoryRealtime": "GraphRAG kort/langetermijngeheugen wordt in realtime bijgewerkt",
"realtimeUpdating": "Realtime bijwerken...",
"pendingContentHint": "Sommige inhoud wordt nog verwerkt. Overweeg om de graaf later handmatig te verversen.",
"nodeDetails": "Knooppuntdetails",
"relationship": "Relatie",
"graphDataLoading": "Graafdata laden...",
"waitingOntology": "Wachten op ontologiegeneratie...",
"toggleMaximize": "Maximaliseren/herstellen",
"closeHint": "Hint sluiten"
},
"history": {
"title": "Simulatiegeschiedenis",
"graphBuild": "Graafopbouw",
"envSetup": "Omgeving instellen",
"analysisReport": "Analyserapport",
"moreFiles": "+{count} bestanden",
"noFiles": "Geen bestanden",
"loadingText": "Laden...",
"simRequirement": "Simulatievereiste",
"relatedFiles": "Gerelateerde bestanden",
"noRelatedFiles": "Geen gerelateerde bestanden",
"replayTitle": "Simulatieherhaling",
"step1Button": "Graafopbouw",
"step2Button": "Omgeving instellen",
"step4Button": "Analyserapport",
"replayHint": "Stap 3 'Simulatie uitvoeren' en Stap 5 'Diepe interactie' moeten tijdens runtime worden gestart en ondersteunen geen geschiedenis-herhaling",
"notStarted": "Niet gestart",
"roundsProgress": "{current}/{total} rondes",
"untitledSimulation": "Naamloze simulatie",
"unknownFile": "Onbekend bestand"
},
"api": {
"projectNotFound": "Project niet gevonden: {id}",
"projectDeleteFailed": "Project niet gevonden of verwijderen mislukt: {id}",
"projectDeleted": "Project verwijderd: {id}",
"projectReset": "Project gereset: {id}",
"requireSimulationRequirement": "Geef een simulatievereiste op (simulation_requirement)",
"requireFileUpload": "Upload minstens één documentbestand",
"noDocProcessed": "Geen documenten succesvol verwerkt. Controleer de bestandsformaten.",
"requireProjectId": "Geef project_id op",
"configError": "Configuratiefout: {details}",
"zepApiKeyMissing": "ZEP_API_KEY niet geconfigureerd",
"ontologyNotGenerated": "Ontologie nog niet gegenereerd. Roep eerst /ontology/generate aan.",
"graphBuilding": "Graafopbouw bezig. Niet opnieuw indienen. Voeg force: true toe om geforceerd opnieuw op te bouwen.",
"textNotFound": "Geëxtraheerde tekstinhoud niet gevonden",
"ontologyNotFound": "Ontologiedefinitie niet gevonden",
"graphBuildStarted": "Graafopbouwtaak gestart. Vraag voortgang op via /task/{taskId}.",
"graphBuildComplete": "Graafopbouw voltooid",
"buildFailed": "Opbouw mislukt: {error}",
"taskNotFound": "Taak niet gevonden: {id}",
"graphDeleted": "Graaf verwijderd: {id}",
"entityNotFound": "Entiteit niet gevonden: {id}",
"graphNotBuilt": "Graaf nog niet opgebouwd. Roep eerst /api/graph/build aan.",
"requireSimulationId": "Geef simulation_id op",
"simulationNotFound": "Simulatie niet gevonden: {id}",
"projectMissingRequirement": "Project mist simulatievereiste (simulation_requirement)",
"prepareStarted": "Voorbereidingstaak gestart. Vraag voortgang op via /api/simulation/prepare/status.",
"alreadyPrepared": "Voorbereiding al voltooid. Niet opnieuw genereren.",
"notStartedPrepare": "Voorbereiding niet gestart. Roep /api/simulation/prepare aan.",
"taskCompletedPrepared": "Taak voltooid (voorbereiding bestaat al)",
"requireTaskOrSimId": "Geef task_id of simulation_id op",
"configNotFound": "Simulatieconfiguratie niet gevonden. Roep eerst /prepare aan.",
"configFileNotFound": "Configuratiebestand niet gevonden. Roep eerst /prepare aan.",
"unknownScript": "Onbekend script: {name}. Beschikbaar: {allowed}",
"scriptFileNotFound": "Scriptbestand niet gevonden: {name}",
"requireGraphId": "Geef graph_id op",
"noMatchingEntities": "Geen overeenkomende entiteiten gevonden",
"maxRoundsPositive": "max_rounds moet een positief geheel getal zijn",
"maxRoundsInvalid": "max_rounds moet een geldig geheel getal zijn",
"invalidPlatform": "Ongeldig platformtype: {platform}. Opties: twitter/reddit/parallel",
"simRunningForceHint": "Simulatie is bezig. Stop deze eerst via /stop, of gebruik force=true om opnieuw te starten.",
"simNotReady": "Simulatie niet gereed. Huidige status: {status}. Roep eerst /prepare aan.",
"graphIdRequiredForMemory": "Graafgeheugen-update vereist een geldige graph_id. Zorg dat de graaf is opgebouwd.",
"dbNotExist": "Database bestaat niet. De simulatie is mogelijk nog niet uitgevoerd.",
"requireMessage": "Geef een bericht op",
"missingGraphId": "Ontbrekende graaf-ID",
"missingGraphIdEnsure": "Ontbrekende graaf-ID. Zorg dat de graaf is opgebouwd.",
"missingSimRequirement": "Ontbrekende beschrijving van simulatievereiste",
"reportAlreadyExists": "Rapport bestaat al",
"reportGenerateStarted": "Rapportgeneratietaak gestart. Vraag voortgang op via /api/report/generate/status.",
"reportGenerated": "Rapport gegenereerd",
"reportNotFound": "Rapport niet gevonden: {id}",
"noReportForSim": "Geen rapport gevonden voor deze simulatie: {id}",
"reportDeleted": "Rapport verwijderd: {id}",
"reportGenerateFailed": "Rapportgeneratie mislukt",
"sectionNotFound": "Sectie niet gevonden: section_{index}.md",
"reportProgressNotAvail": "Rapport niet gevonden of voortgang niet beschikbaar: {id}",
"requireAgentId": "Geef agent_id op",
"requirePrompt": "Geef een prompt op (interviewvraag)",
"invalidInterviewPlatform": "Platform moet 'twitter' of 'reddit' zijn",
"envNotRunning": "Simulatieomgeving draait niet of is gesloten. Zorg dat de simulatie is voltooid en in command-wait-modus staat.",
"interviewTimeout": "Time-out interviewreactie: {error}",
"requireInterviews": "Geef interviews op (interviewlijst)",
"interviewListMissingAgentId": "Interviewlijst-item {index} mist agent_id",
"interviewListMissingPrompt": "Interviewlijst-item {index} mist prompt",
"interviewListInvalidPlatform": "Interviewlijst-item {index} platform moet 'twitter' of 'reddit' zijn",
"batchInterviewTimeout": "Time-out batch-interviewreactie: {error}",
"globalInterviewTimeout": "Time-out globale interviewreactie: {error}",
"envRunning": "Omgeving draait en is gereed voor Interview-commando's",
"envNotRunningShort": "Omgeving draait niet of is gesloten",
"requireGraphIdAndQuery": "Geef graph_id en query op",
"initReportAgent": "Report Agent initialiseren..."
},
"progress": {
"initGraphService": "Graafopbouwservice initialiseren...",
"textChunking": "Tekst opdelen...",
"creatingZepGraph": "Zep-graaf aanmaken...",
"settingOntology": "Ontologiedefinitie instellen...",
"addingChunks": "{count} tekstblokken toevoegen...",
"waitingZepProcess": "Wachten tot Zep data verwerkt...",
"fetchingGraphData": "Graafdata ophalen...",
"graphBuildComplete": "Graafopbouw voltooid",
"buildFailed": "Opbouw mislukt: {error}",
"startBuildingGraph": "Graafopbouw starten...",
"graphCreated": "Graaf aangemaakt: {graphId}",
"ontologySet": "Ontologie ingesteld",
"textSplit": "Tekst opgedeeld in {count} blokken",
"fetchingGraphInfo": "Graafinformatie ophalen...",
"sendingBatch": "Batch {current}/{total} verzenden ({chunks} blokken)...",
"batchFailed": "Batch {batch} mislukt: {error}",
"noEpisodesWait": "Geen episodes om op te wachten",
"waitingEpisodes": "Wachten tot {count} tekstblokken zijn verwerkt...",
"episodesTimeout": "Sommige blokken zijn verlopen, {completed}/{total} voltooid",
"zepProcessing": "Zep verwerkt... {completed}/{total} klaar, {pending} in afwachting ({elapsed}s)",
"processingComplete": "Verwerking voltooid: {completed}/{total}",
"taskComplete": "Taak voltooid",
"taskFailed": "Taak mislukt",
"startPreparingEnv": "Simulatieomgeving voorbereiden...",
"connectingZepGraph": "Verbinden met Zep-graaf...",
"readingNodeData": "Knooppuntdata lezen...",
"readingComplete": "Klaar, {count} entiteiten gevonden",
"startGenerating": "Genereren starten...",
"analyzingRequirements": "Simulatievereisten analyseren...",
"generatingOutline": "Rapportoverzicht genereren...",
"parsingOutline": "Overzichtsstructuur parsen...",
"outlinePlanComplete": "Overzichtsplanning voltooid",
"deepSearchAndWrite": "Diep zoeken & schrijven ({current}/{max})",
"initReport": "Rapport initialiseren...",
"startPlanningOutline": "Rapportoverzicht plannen...",
"outlineDone": "Overzicht voltooid, {count} secties",
"generatingSection": "Sectie genereren: {title} ({current}/{total})",
"sectionDone": "Sectie {title} voltooid",
"assemblingReport": "Volledig rapport samenstellen...",
"reportComplete": "Rapportgeneratie voltooid",
"reportFailed": "Rapportgeneratie mislukt: {error}",
"savingProfiles": "Profielbestanden opslaan...",
"profilesComplete": "Klaar, {count} profielen gegenereerd",
"callingLLMConfig": "LLM aanroepen om configuratie te genereren...",
"savingConfigFiles": "Configuratiebestanden opslaan...",
"configComplete": "Configuratiegeneratie voltooid",
"generatingTimeConfig": "Tijdconfiguratie genereren...",
"generatingEventConfig": "Gebeurtenisconfiguratie en hot topics genereren...",
"generatingAgentConfig": "Agent-configuratie genereren ({start}-{end}/{total})...",
"generatingPlatformConfig": "Platformconfiguratie genereren...",
"zepSearchQuery": "Alle informatie, activiteiten, gebeurtenissen, relaties en achtergrond over {name}",
"timeConfigLabel": "Tijdconfiguratie",
"eventConfigLabel": "Gebeurtenisconfiguratie",
"agentConfigResult": "Agent-configuratie: {count} gegenereerd",
"postAssignResult": "Berichttoewijzing: {count} berichten toegewezen",
"profileGenerated": "[Gegenereerd] {name} ({type})",
"readingGraphEntities": "Graafentiteiten lezen",
"generatingProfiles": "Agent-profielen genereren",
"generatingSimConfig": "Simulatieconfiguratie genereren",
"preparingScripts": "Scripts voorbereiden"
},
"log": {
"preparingGoBack": "Voorbereiden om terug te keren naar Stap 2, simulatie sluiten...",
"closingSimEnv": "Simulatieomgeving sluiten...",
"simEnvClosed": "✓ Simulatieomgeving gesloten",
"closeSimEnvFailed": "Sluiten van simulatieomgeving mislukt, geforceerde stop wordt geprobeerd...",
"simForceStopSuccess": "✓ Simulatie geforceerd gestopt",
"forceStopFailed": "Geforceerde stop mislukt: {error}",
"stoppingSimProcess": "Simulatieproces stoppen...",
"simStopped": "✓ Simulatie gestopt",
"stopSimFailed": "Simulatie stoppen mislukt: {error}",
"checkStatusFailed": "Simulatiestatus controleren mislukt: {error}",
"enterStep4": "Stap 4 binnengegaan: Rapportgeneratie",
"loadingSimData": "Simulatiedata laden: {id}",
"timeConfig": "Tijdconfiguratie: {minutes} minuten per ronde",
"timeConfigFetchFailed": "Tijdconfiguratie ophalen mislukt, standaard wordt gebruikt: {minutes} min/round",
"projectLoadSuccess": "Project geladen: {id}",
"loadSimDataFailed": "Simulatiedata laden mislukt: {error}",
"loadException": "Laadfout: {error}",
"graphDataLoadSuccess": "Graafdata succesvol geladen",
"graphLoadFailed": "Graaf laden mislukt: {error}",
"graphRealtimeRefreshStart": "Realtime graafverversing ingeschakeld (30s)",
"graphRealtimeRefreshStop": "Realtime graafverversing gestopt",
"simRunViewInit": "SimulationRunView geïnitialiseerd",
"customRounds": "Aangepaste simulatierondes: {rounds}",
"enterStep3": "Stap 3 binnengegaan: Simulatie uitvoeren",
"customRoundsConfig": "Aangepaste simulatierondes: {rounds} rondes",
"useAutoRounds": "Auto-geconfigureerde simulatierondes worden gebruikt",
"detectedSimEnvRunning": "Draaiende simulatieomgeving gedetecteerd, sluiten...",
"closeSimEnvFailedWithError": "Sluiten van simulatieomgeving mislukt: {error}",
"closeSimEnvException": "Fout bij sluiten simulatieomgeving: {error}",
"detectedSimRunning": "Gedetecteerd dat simulatie draait, stoppen...",
"forceStopSimFailed": "Geforceerde stop simulatie mislukt: {error}",
"forceStopSimException": "Fout bij geforceerde stop simulatie: {error}",
"simViewInit": "SimulationView geïnitialiseerd",
"errorMissingSimId": "Fout: ontbrekende simulationId",
"simInstanceCreated": "Simulatie-instantie aangemaakt: {id}",
"preparingSimEnv": "Simulatieomgeving voorbereiden...",
"detectedExistingPrep": "Bestaande voorbereiding gedetecteerd, wordt direct gebruikt",
"prepareTaskStarted": "Voorbereidingstaak gestart",
"prepareTaskId": " └─ Taak-ID: {taskId}",
"zepEntitiesFound": "{count} entiteiten gevonden uit Zep-graaf",
"entityTypes": " └─ Entiteitstypen: {types}",
"startPollingProgress": "Voorbereidingsvoortgang pollen...",
"prepareFailed": "Voorbereiding mislukt: {error}",
"prepareException": "Voorbereidingsfout: {error}",
"prepareComplete": "✓ Voorbereiding voltooid",
"prepareFailedWithError": "✗ Voorbereiding mislukt: {error}",
"startGeneratingConfig": "Configuratie voor twee-platform-simulatie genereren...",
"generatingAgentProfileConfig": "Agent-persona-configuratie genereren...",
"generatingLLMConfig": "LLM aanroepen om simulatieconfiguratieparameters te genereren...",
"configComplete": "✓ Simulatieconfiguratie gegenereerd",
"configSummaryAgents": " ├─ Agents: {count}",
"configSummaryHours": " ├─ Duur: {hours} uur",
"configSummaryPosts": " ├─ Initiële berichten: {count}",
"configSummaryTopics": " ├─ Hot topics: {count}",
"configSummaryPlatforms": " └─ Platforms: Twitter {twitter}, Reddit {reddit}",
"timeConfigDetail": "Tijdconfiguratie: {minutes} min/round, {rounds} rondes totaal",
"narrativeDirection": "Narratieve richting: {direction}",
"envSetupComplete": "✓ Omgevingsinstelling voltooid, gereed voor simulatie",
"startSimCustomRounds": "Simulatie starten, aangepaste rondes: {rounds}",
"startSimAutoRounds": "Simulatie starten, auto-geconfigureerde rondes: {rounds}",
"startGeneratingAgentProfiles": "Agent-persona's genereren...",
"agentProfile": "→ Agent-persona {current}/{total}: {name} ({profession})",
"allProfilesComplete": "✓ Alle {count} agent-persona's gegenereerd",
"loadingExistingConfig": "Bestaande configuratiedata laden...",
"loadedAgentProfiles": "{count} agent-persona's geladen",
"configLoadSuccess": "✓ Simulatieconfiguratie geladen",
"configSummaryPostsAlt": " └─ Initiële berichten: {count}",
"configGenerating": "Configuratie wordt gegenereerd, pollen...",
"loadConfigFailed": "Configuratie laden mislukt: {error}",
"step2Init": "Stap 2 omgevingsinstelling geïnitialiseerd",
"step3Init": "Stap 3 simulatie-uitvoering geïnitialiseerd",
"startingDualSim": "Parallelle simulatie op twee platforms starten...",
"setMaxRounds": "Maximaal aantal simulatierondes ingesteld op: {rounds}",
"graphMemoryUpdateEnabled": "Dynamische graafgeheugen-update ingeschakeld",
"oldSimCleared": "✓ Oude simulatielogs gewist, simulatie wordt herstart",
"engineStarted": "✓ Simulatie-engine succesvol gestart",
"startFailed": "✗ Starten mislukt: {error}",
"startException": "✗ Startfout: {error}",
"stoppingSim": "Simulatie stoppen...",
"simStoppedSuccess": "✓ Simulatie gestopt",
"stopFailed": "Stoppen mislukt: {error}",
"stopException": "Stopfout: {error}",
"allPlatformsCompleted": "✓ Alle platformsimulaties zijn beëindigd",
"simCompleted": "✓ Simulatie voltooid",
"reportRequestSent": "Verzoek voor rapportgeneratie verzonden, even geduld...",
"startingReportGen": "Rapportgeneratie starten...",
"reportGenTaskStarted": "✓ Rapportgeneratietaak gestart: {reportId}",
"reportGenFailed": "✗ Rapportgeneratie starten mislukt: {error}",
"reportGenException": "✗ Rapportgeneratiefout: {error}",
"step5Init": "Stap 5 diepe interactie geïnitialiseerd",
"selectChatTarget": "Geselecteerd chatdoel: {name}",
"sendFailed": "Verzenden mislukt: {error}",
"sendToReportAgent": "Verzonden naar Report Agent: {message}...",
"reportAgentReplied": "Report Agent heeft gereageerd",
"sendToAgent": "Verzonden naar {name}: {message}...",
"agentReplied": "{name} heeft gereageerd",
"sendSurvey": "Enquête versturen naar {count} doelen...",
"receivedReplies": "{count} reacties ontvangen",
"surveySendFailed": "Enquête versturen mislukt: {error}",
"loadReportData": "Rapportdata laden: {id}",
"loadReportFailed": "Rapport laden mislukt: {error}",
"reportDataLoaded": "Rapportdata geladen",
"loadReportLogFailed": "Rapportlogs laden mislukt: {error}",
"loadedProfiles": "{count} gesimuleerde personen geladen",
"loadProfilesFailed": "Gesimuleerde personen laden mislukt: {error}",
"interactionViewInit": "InteractionView geïnitialiseerd",
"reportViewInit": "ReportView geïnitialiseerd",
"getReportInfoFailed": "Rapportinfo ophalen mislukt: {error}",
"enterStep": "Stap {step} binnengegaan: {name}",
"returnToStep": "Terugkeren naar Stap {step}: {name}",
"customSimRounds": "Aangepaste simulatierondes: {rounds} rondes"
},
"report": {
"taskStarted": "Rapportgeneratietaak gestart",
"planningStart": "Rapportoverzicht plannen starten",
"fetchSimContext": "Simulatiecontext ophalen",
"planningComplete": "Overzichtsplanning voltooid",
"sectionStart": "Sectiegeneratie starten: {title}",
"reactThought": "ReACT-ronde {iteration} denken",
"toolCall": "Tool aanroepen: {toolName}",
"toolResult": "Tool {toolName} heeft resultaat geretourneerd",
"llmResponse": "LLM-reactie (tool-aanroepen: {hasToolCalls}, definitief antwoord: {hasFinalAnswer})",
"sectionContentDone": "Inhoud van sectie {title} gegenereerd",
"sectionComplete": "Sectie {title} gegenereerd",
"reportComplete": "Rapportgeneratie voltooid",
"errorOccurred": "Fout opgetreden: {error}",
"agentInitDone": "ReportAgent geïnitialiseerd: graph_id={graphId}, simulation_id={simulationId}",
"executingTool": "Tool uitvoeren: {toolName}, params: {params}",
"toolExecFailed": "Tool-uitvoering mislukt: {toolName}, fout: {error}",
"startPlanningOutline": "Rapportoverzicht plannen starten...",
"outlinePlanDone": "Overzichtsplanning voltooid: {count} secties",
"outlinePlanFailed": "Overzichtsplanning mislukt: {error}",
"reactGenerateSection": "ReACT genereert sectie: {title}",
"sectionIterNone": "Sectie {title} iteratie {iteration}: LLM gaf None terug",
"sectionConflict": "Sectie {title} ronde {iteration}: LLM produceerde zowel tool-aanroep als definitief antwoord (conflict #{conflictCount})",
"sectionConflictDowngrade": "Sectie {title}: {conflictCount} opeenvolgende conflicten, downgrade naar afkappen en eerste tool-aanroep uitvoeren",
"sectionGenDone": "Sectie {title} gegenereerd (tool-aanroepen: {count})",
"multiToolOnlyFirst": "LLM probeerde {total} tool-aanroepen, alleen de eerste wordt uitgevoerd: {toolName}",
"sectionNoPrefix": "Sectie {title} mist prefix 'Final Answer:', LLM-uitvoer wordt als definitieve inhoud overgenomen (tool-aanroepen: {count})",
"sectionMaxIter": "Sectie {title} heeft max iteraties bereikt, geforceerde generatie",
"sectionForceFailed": "Sectie {title} force-finish LLM gaf None terug, standaard foutmelding wordt gebruikt",
"sectionGenFailedContent": "(Deze sectie is niet gegenereerd: LLM gaf lege reactie terug, probeer later opnieuw)",
"outlineSavedToFile": "Overzicht opgeslagen naar bestand: {reportId}/outline.json",
"sectionSaved": "Sectie opgeslagen: {reportId}/section_{sectionNum}.md",
"reportGenDone": "Rapportgeneratie voltooid: {reportId}",
"reportGenFailed": "Rapportgeneratie mislukt: {error}",
"agentChat": "Report Agent-chat: {message}...",
"fetchReportFailed": "Rapportinhoud ophalen mislukt: {error}",
"outlineSaved": "Overzicht opgeslagen: {reportId}",
"sectionFileSaved": "Sectie opgeslagen: {reportId}/{fileSuffix}",
"fullReportAssembled": "Volledig rapport samengesteld: {reportId}",
"reportSaved": "Rapport opgeslagen: {reportId}",
"reportFolderDeleted": "Rapportmap verwijderd: {reportId}",
"redirectToQuickSearch": "search_graph omgeleid naar quick_search",
"redirectToInsightForge": "get_simulation_context omgeleid naar insight_forge"
},
"console": {
"zepToolsInitialized": "ZepToolsService geïnitialiseerd",
"zepRetryAttempt": "Zep {operation} poging {attempt} mislukt: {error}, opnieuw proberen over {delay}s...",
"zepAllRetriesFailed": "Zep {operation} mislukt na {retries} pogingen: {error}",
"graphSearch": "Graafzoekopdracht: graph_id={graphId}, query={query}...",
"graphSearchOp": "Graafzoekopdracht (graph={graphId})",
"searchComplete": "Zoeken voltooid: {count} relevante feiten gevonden",
"zepSearchApiFallback": "Zep Search API mislukt, terugvallen op lokaal zoeken: {error}",
"usingLocalSearch": "Lokaal zoeken gebruiken: query={query}...",
"localSearchComplete": "Lokaal zoeken voltooid: {count} relevante feiten gevonden",
"localSearchFailed": "Lokaal zoeken mislukt: {error}",
"fetchingAllNodes": "Alle knooppunten voor graaf {graphId} ophalen...",
"fetchedNodes": "{count} knooppunten opgehaald",
"fetchingAllEdges": "Alle randen voor graaf {graphId} ophalen...",
"fetchedEdges": "{count} randen opgehaald",
"fetchingNodeDetail": "Knooppuntdetail ophalen: {uuid}...",
"fetchNodeDetailOp": "Knooppuntdetail ophalen (uuid={uuid}...)",
"fetchNodeDetailFailed": "Knooppuntdetail ophalen mislukt: {error}",
"fetchingNodeEdges": "Randen voor knooppunt {uuid} ophalen...",
"foundNodeEdges": "{count} gerelateerde randen gevonden",
"fetchNodeEdgesFailed": "Knooppuntranden ophalen mislukt: {error}",
"fetchingEntitiesByType": "Entiteiten van type {type} ophalen...",
"foundEntitiesByType": "{count} entiteiten van type {type} gevonden",
"fetchingEntitySummary": "Relatiesamenvatting voor entiteit {name} ophalen...",
"fetchingGraphStats": "Statistieken voor graaf {graphId} ophalen...",
"fetchingSimContext": "Simulatiecontext ophalen: {requirement}...",
"insightForgeStart": "InsightForge diepe inzichtophaling: {query}...",
"generatedSubQueries": "{count} subvragen gegenereerd",
"insightForgeComplete": "InsightForge voltooid: {facts} feiten, {entities} entiteiten, {relationships} relaties",
"generateSubQueriesFailed": "Subvragen genereren mislukt: {error}, standaardwaarden worden gebruikt",
"panoramaSearchStart": "PanoramaSearch breed zoeken: {query}...",
"panoramaSearchComplete": "PanoramaSearch voltooid: {active} actief, {historical} historisch",
"quickSearchStart": "QuickSearch eenvoudig zoeken: {query}...",
"quickSearchComplete": "QuickSearch voltooid: {count} resultaten",
"interviewAgentsStart": "InterviewAgents diep interview (echte API): {requirement}...",
"profilesNotFound": "Profielen niet gevonden voor simulatie {simId}",
"loadedProfiles": "{count} agent-profielen geladen",
"selectedAgentsForInterview": "{count} agents geselecteerd voor interview: {indices}",
"generatedInterviewQuestions": "{count} interviewvragen gegenereerd",
"callingBatchInterviewApi": "Batch-interview-API aanroepen (twee platforms): {count} agents",
"interviewApiReturned": "Interview-API retourneerde: {count} resultaten, success={success}",
"interviewApiReturnedFailure": "Interview-API gaf foutmelding: {error}",
"interviewApiCallFailed": "Interview-API-aanroep mislukt (omgeving niet actief?): {error}",
"interviewApiCallException": "Interview-API-aanroep uitzondering: {error}",
"interviewAgentsComplete": "InterviewAgents voltooid: {count} agents geïnterviewd (twee platforms)",
"loadedRedditProfiles": "{count} profielen geladen uit reddit_profiles.json",
"readRedditProfilesFailed": "reddit_profiles.json lezen mislukt: {error}",
"loadedTwitterProfiles": "{count} profielen geladen uit twitter_profiles.csv",
"readTwitterProfilesFailed": "twitter_profiles.csv lezen mislukt: {error}",
"llmSelectAgentFailed": "LLM-agent-selectie mislukt, standaardselectie wordt gebruikt: {error}",
"generateInterviewQuestionsFailed": "Interviewvragen genereren mislukt: {error}",
"generateInterviewSummaryFailed": "Interviewsamenvatting genereren mislukt: {error}"
}
}

View File

@ -1,7 +1,7 @@
{
"name": "mirofish",
"version": "0.1.0",
"description": "MiroFish - 简洁通用的群体智能引擎,预测万物",
"description": "MiroFish - a clean, general-purpose swarm-intelligence engine that predicts anything",
"scripts": {
"setup": "npm install && cd frontend && npm install",
"setup:backend": "cd backend && uv sync",