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:
parent
62489ad4f9
commit
12669495bb
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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-transformers(pre-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
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
"""
|
||||
数据模型模块
|
||||
Data models module.
|
||||
"""
|
||||
|
||||
from .task import TaskManager, TaskStatus
|
||||
from .project import Project, ProjectStatus, ProjectManager
|
||||
|
||||
__all__ = ['TaskManager', 'TaskStatus', 'Project', 'ProjectStatus', 'ProjectManager']
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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. 属性名不能使用 name、uuid、group_id 等保留字,用 full_name、org_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 转为 PascalCase(Zep 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_CASE(Zep 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
|
|
@ -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:
|
||||
IPCResponse,result字段包含采访结果
|
||||
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:
|
||||
IPCResponse,result字段包含所有采访结果
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 lines(Keep 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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
EntityNode或None
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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。
|
||||
|
||||
所有有意义的行为都会被更新到Zep,action_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
|
|
@ -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']
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""
|
||||
文件解析工具
|
||||
支持PDF、Markdown、TXT文件的文本提取
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 format(e.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]}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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.')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
"""
|
||||
MiroFish Backend 启动入口
|
||||
MiroFish backend entry point
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 解决 Windows 控制台中文乱码问题:在所有导入之前设置 UTF-8 编码
|
||||
# Work around Windows console garbled Chinese output:Set 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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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可用动作(不包含INTERVIEW,INTERVIEW只能通过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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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可用动作(不包含INTERVIEW,INTERVIEW只能通过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")
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 } })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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_id获取对应的username
|
||||
// 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 }))
|
||||
|
||||
// 立即设置预期Agent总数(从prepare接口返回值获取)
|
||||
// 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;
|
||||
|
|
|
|||
|
|
@ -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') }))
|
||||
|
|
|
|||
|
|
@ -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. **校友_345(index=1)**:作为武大校友...
|
||||
|
||||
// Format 1: 数字. **名字(index=X)**:理由
|
||||
// Example: 1. **校友_345(index=1)**:作为武大校友...
|
||||
headerMatch = line.match(/^\d+\.\s*\*\*([^*((]+)(?:[((]index\s*=?\s*\d+[))])?\*\*[::]\s*(.*)/)
|
||||
if (headerMatch) {
|
||||
name = headerMatch[1].trim()
|
||||
reasonStart = headerMatch[2]
|
||||
}
|
||||
|
||||
// 格式2: - 选择名字(index X):理由
|
||||
// 例如: - 选择家长_601(index 0):作为家长群体代表...
|
||||
|
||||
// Format 2: - 选择名字(index X):理由
|
||||
// Example: - 选择家长_601(index 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)**:理由
|
||||
// 例如: - **家长_601(index 0)**:作为家长群体代表...
|
||||
|
||||
// Format 3: - **名字(index X)**:理由
|
||||
// Example: - **家长_601(index 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) => {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 提取回答 - 分Twitter和Reddit
|
||||
|
||||
// Extract answer - Twitter and Reddit
|
||||
const answerMatch = block.match(/\*\*A:\*\*\s*([\s\S]*?)(?=\*\*关键引言|$)/)
|
||||
if (answerMatch) {
|
||||
const answerText = answerMatch[1].trim()
|
||||
|
||||
// 分离Twitter和Reddit回答
|
||||
|
||||
// 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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
|||
})
|
||||
}
|
||||
|
||||
// 开始模拟 - 立即跳转,API调用在Process页面进行
|
||||
// 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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -26,5 +26,9 @@
|
|||
"de": {
|
||||
"label": "Deutsch",
|
||||
"llmInstruction": "Bitte antworten Sie auf Deutsch."
|
||||
},
|
||||
"nl": {
|
||||
"label": "Nederlands",
|
||||
"llmInstruction": "Antwoord in het Nederlands."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue