feat:历史记录分享功能实现
This commit is contained in:
@@ -5,8 +5,8 @@ AgentCoord 数据库模块
|
||||
"""
|
||||
|
||||
from .database import get_db, get_db_context, test_connection, engine, text
|
||||
from .models import MultiAgentTask, UserAgent, ExportRecord, TaskStatus
|
||||
from .crud import MultiAgentTaskCRUD, UserAgentCRUD, ExportRecordCRUD
|
||||
from .models import MultiAgentTask, UserAgent, ExportRecord, PlanShare, TaskStatus
|
||||
from .crud import MultiAgentTaskCRUD, UserAgentCRUD, ExportRecordCRUD, PlanShareCRUD
|
||||
|
||||
__all__ = [
|
||||
# 连接管理
|
||||
@@ -19,9 +19,11 @@ __all__ = [
|
||||
"MultiAgentTask",
|
||||
"UserAgent",
|
||||
"ExportRecord",
|
||||
"PlanShare",
|
||||
"TaskStatus",
|
||||
# CRUD
|
||||
"MultiAgentTaskCRUD",
|
||||
"UserAgentCRUD",
|
||||
"ExportRecordCRUD",
|
||||
"PlanShareCRUD",
|
||||
]
|
||||
|
||||
@@ -9,7 +9,7 @@ from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .models import MultiAgentTask, UserAgent, ExportRecord
|
||||
from .models import MultiAgentTask, UserAgent, ExportRecord, PlanShare
|
||||
|
||||
|
||||
class MultiAgentTaskCRUD:
|
||||
@@ -500,3 +500,78 @@ class ExportRecordCRUD:
|
||||
db.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class PlanShareCRUD:
|
||||
"""任务分享 CRUD 操作"""
|
||||
|
||||
@staticmethod
|
||||
def create(
|
||||
db: Session,
|
||||
share_token: str,
|
||||
task_id: str,
|
||||
task_data: dict,
|
||||
expires_at: Optional[datetime] = None,
|
||||
extraction_code: Optional[str] = None,
|
||||
) -> PlanShare:
|
||||
"""创建分享记录"""
|
||||
share = PlanShare(
|
||||
share_token=share_token,
|
||||
extraction_code=extraction_code,
|
||||
task_id=task_id,
|
||||
task_data=task_data,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
db.add(share)
|
||||
db.commit()
|
||||
db.refresh(share)
|
||||
return share
|
||||
|
||||
@staticmethod
|
||||
def get_by_token(db: Session, share_token: str) -> Optional[PlanShare]:
|
||||
"""根据 token 获取分享记录"""
|
||||
return db.query(PlanShare).filter(PlanShare.share_token == share_token).first()
|
||||
|
||||
@staticmethod
|
||||
def get_by_task_id(
|
||||
db: Session, task_id: str, limit: int = 10
|
||||
) -> List[PlanShare]:
|
||||
"""根据任务 ID 获取分享记录列表"""
|
||||
return (
|
||||
db.query(PlanShare)
|
||||
.filter(PlanShare.task_id == task_id)
|
||||
.order_by(PlanShare.created_at.desc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def increment_view_count(db: Session, share_token: str) -> Optional[PlanShare]:
|
||||
"""增加查看次数"""
|
||||
share = db.query(PlanShare).filter(PlanShare.share_token == share_token).first()
|
||||
if share:
|
||||
share.view_count = (share.view_count or 0) + 1
|
||||
db.commit()
|
||||
db.refresh(share)
|
||||
return share
|
||||
|
||||
@staticmethod
|
||||
def delete(db: Session, share_token: str) -> bool:
|
||||
"""删除分享记录"""
|
||||
share = db.query(PlanShare).filter(PlanShare.share_token == share_token).first()
|
||||
if share:
|
||||
db.delete(share)
|
||||
db.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def delete_by_task_id(db: Session, task_id: str) -> bool:
|
||||
"""删除任务的所有分享记录"""
|
||||
shares = db.query(PlanShare).filter(PlanShare.task_id == task_id).all()
|
||||
if shares:
|
||||
for share in shares:
|
||||
db.delete(share)
|
||||
db.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -10,7 +10,7 @@ import os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from db.database import engine, Base
|
||||
from db.models import MultiAgentTask, UserAgent
|
||||
from db.models import MultiAgentTask, UserAgent, PlanShare
|
||||
|
||||
|
||||
def init_database():
|
||||
|
||||
@@ -137,3 +137,34 @@ class UserAgent(Base):
|
||||
"agent_config": self.agent_config,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
class PlanShare(Base):
|
||||
"""任务分享记录模型"""
|
||||
__tablename__ = "plan_shares"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
share_token = Column(String(64), unique=True, index=True, nullable=False) # 唯一分享码
|
||||
extraction_code = Column(String(8), nullable=True) # 提取码(4位字母数字)
|
||||
task_id = Column(String(64), nullable=False, index=True) # 关联的任务ID
|
||||
task_data = Column(JSONB, nullable=False) # 完整的任务数据(脱敏后)
|
||||
created_at = Column(DateTime(timezone=True), default=utc_now)
|
||||
expires_at = Column(DateTime(timezone=True), nullable=True) # 过期时间
|
||||
view_count = Column(Integer, default=0) # 查看次数
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_plan_shares_token", "share_token"),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""转换为字典"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"share_token": self.share_token,
|
||||
"extraction_code": self.extraction_code,
|
||||
"task_id": self.task_id,
|
||||
"task_data": self.task_data,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"expires_at": self.expires_at.isoformat() if self.expires_at else None,
|
||||
"view_count": self.view_count,
|
||||
}
|
||||
|
||||
@@ -62,9 +62,45 @@ CREATE TRIGGER update_multi_agent_tasks_updated_at
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- =============================================================================
|
||||
-- 表3: export_records (导出记录)
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS export_records (
|
||||
id SERIAL PRIMARY KEY,
|
||||
task_id VARCHAR(64) NOT NULL,
|
||||
user_id VARCHAR(64) NOT NULL,
|
||||
export_type VARCHAR(32) NOT NULL,
|
||||
file_name VARCHAR(256) NOT NULL,
|
||||
file_path VARCHAR(512) NOT NULL,
|
||||
file_url VARCHAR(512),
|
||||
file_size INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_export_records_task_user ON export_records(task_id, user_id);
|
||||
|
||||
-- =============================================================================
|
||||
-- 表4: plan_shares (任务分享记录)
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS plan_shares (
|
||||
id SERIAL PRIMARY KEY,
|
||||
share_token VARCHAR(64) NOT NULL UNIQUE,
|
||||
extraction_code VARCHAR(8),
|
||||
task_id VARCHAR(64) NOT NULL,
|
||||
task_data JSONB NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP WITH TIME ZONE,
|
||||
view_count INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_plan_shares_token ON plan_shares(share_token);
|
||||
CREATE INDEX IF NOT EXISTS idx_plan_shares_task_id ON plan_shares(task_id);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
RAISE NOTICE '✅ PostgreSQL 数据库表结构创建完成!';
|
||||
RAISE NOTICE '表: multi_agent_tasks (多智能体任务记录)';
|
||||
RAISE NOTICE '表: user_agents (用户智能体配置)';
|
||||
RAISE NOTICE '表: export_records (导出记录)';
|
||||
RAISE NOTICE '表: plan_shares (任务分享记录)';
|
||||
END $$;
|
||||
|
||||
@@ -23,6 +23,7 @@ import uuid
|
||||
import copy
|
||||
import base64
|
||||
from typing import List, Dict, Optional
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
# 数据库模块导入
|
||||
from db import (
|
||||
@@ -30,6 +31,7 @@ from db import (
|
||||
MultiAgentTaskCRUD,
|
||||
UserAgentCRUD,
|
||||
ExportRecordCRUD,
|
||||
PlanShareCRUD,
|
||||
TaskStatus,
|
||||
text,
|
||||
)
|
||||
@@ -2239,6 +2241,200 @@ def handle_pin_plan(data):
|
||||
})
|
||||
|
||||
|
||||
import secrets
|
||||
|
||||
|
||||
@socketio.on('share_plan')
|
||||
def handle_share_plan(data):
|
||||
"""
|
||||
WebSocket版本:分享任务
|
||||
"""
|
||||
# socketio 包装: data = { id: 'share_plan-xxx', action: 'share_plan', data: { id: 'ws_req_xxx', data: {...} } }
|
||||
request_id = data.get('id') # socketio 包装的 id
|
||||
incoming_data = data.get('data', {}).get('data', {}) # 真正的请求数据
|
||||
plan_id = incoming_data.get('plan_id')
|
||||
expiration_days = incoming_data.get('expiration_days', 7) # 默认为7天,0表示永久
|
||||
extraction_code = incoming_data.get('extraction_code', '') # 提取码
|
||||
auto_fill_code = incoming_data.get('auto_fill_code', True) # 是否在链接中自动填充提取码
|
||||
|
||||
if not plan_id:
|
||||
emit('response', {
|
||||
'id': request_id,
|
||||
'status': 'error',
|
||||
'error': '缺少 plan_id(task_id)'
|
||||
})
|
||||
return
|
||||
|
||||
try:
|
||||
with get_db_context() as db:
|
||||
# 获取任务详情
|
||||
task = MultiAgentTaskCRUD.get_by_id(db, plan_id)
|
||||
if not task:
|
||||
emit('response', {
|
||||
'id': request_id,
|
||||
'status': 'error',
|
||||
'error': f'任务不存在: {plan_id}'
|
||||
})
|
||||
return
|
||||
|
||||
# 生成唯一分享 token
|
||||
share_token = secrets.token_urlsafe(16)
|
||||
|
||||
# 设置过期时间
|
||||
if expiration_days == 0:
|
||||
# 永久有效
|
||||
expires_at = None
|
||||
else:
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(days=expiration_days)
|
||||
|
||||
# 准备分享数据(脱敏处理,移除敏感信息)
|
||||
task_data = {
|
||||
"general_goal": task.query,
|
||||
"task_outline": task.task_outline,
|
||||
"assigned_agents": task.assigned_agents,
|
||||
"agent_scores": task.agent_scores,
|
||||
"agents_info": task.agents_info,
|
||||
"branches": task.branches,
|
||||
"result": task.result,
|
||||
"rehearsal_log": task.rehearsal_log,
|
||||
"status": task.status.value if task.status else None,
|
||||
}
|
||||
|
||||
# 创建分享记录
|
||||
share = PlanShareCRUD.create(
|
||||
db=db,
|
||||
share_token=share_token,
|
||||
task_id=plan_id,
|
||||
task_data=task_data,
|
||||
expires_at=expires_at,
|
||||
extraction_code=extraction_code.upper() if extraction_code else None,
|
||||
)
|
||||
|
||||
# 生成分享链接(根据auto_fill_code决定是否在URL中携带提取码)
|
||||
if extraction_code and auto_fill_code:
|
||||
share_url = f"/share/{share_token}?code={extraction_code.upper()}"
|
||||
else:
|
||||
share_url = f"/share/{share_token}"
|
||||
|
||||
emit('response', {
|
||||
'id': request_id,
|
||||
'status': 'success',
|
||||
'data': {
|
||||
"share_url": share_url,
|
||||
"share_token": share_token,
|
||||
"extraction_code": extraction_code.upper() if extraction_code else None,
|
||||
"auto_fill_code": auto_fill_code,
|
||||
"task_id": plan_id,
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
emit('response', {
|
||||
'id': request_id,
|
||||
'status': 'error',
|
||||
'error': str(e)
|
||||
})
|
||||
|
||||
|
||||
@socketio.on('import_shared_plan')
|
||||
def handle_import_shared_plan(data):
|
||||
"""
|
||||
WebSocket版本:导入分享的任务到自己的历史记录
|
||||
"""
|
||||
# socketio 包装: data = { id: 'import_shared_plan-xxx', action: 'import_shared_plan', data: { id: 'ws_req_xxx', data: {...} } }
|
||||
request_id = data.get('id') # socketio 包装的 id
|
||||
incoming_data = data.get('data', {}).get('data', {}) # 真正的请求数据
|
||||
share_token = incoming_data.get('share_token')
|
||||
user_id = incoming_data.get('user_id')
|
||||
|
||||
if not share_token:
|
||||
emit('response', {
|
||||
'id': request_id,
|
||||
'status': 'error',
|
||||
'error': '缺少 share_token'
|
||||
})
|
||||
return
|
||||
|
||||
if not user_id:
|
||||
emit('response', {
|
||||
'id': request_id,
|
||||
'status': 'error',
|
||||
'error': '缺少 user_id'
|
||||
})
|
||||
return
|
||||
|
||||
try:
|
||||
with get_db_context() as db:
|
||||
# 获取分享记录
|
||||
share = PlanShareCRUD.get_by_token(db, share_token)
|
||||
if not share:
|
||||
emit('response', {
|
||||
'id': request_id,
|
||||
'status': 'error',
|
||||
'error': '分享链接无效或已失效'
|
||||
})
|
||||
return
|
||||
|
||||
# 检查是否过期
|
||||
if share.expires_at and share.expires_at.replace(tzinfo=None) < datetime.now():
|
||||
emit('response', {
|
||||
'id': request_id,
|
||||
'status': 'error',
|
||||
'error': '分享链接已过期'
|
||||
})
|
||||
return
|
||||
|
||||
# 获取分享的任务数据
|
||||
task_data = share.task_data
|
||||
|
||||
# 生成新的 task_id(因为是导入到自己的账号)
|
||||
import uuid
|
||||
new_task_id = str(uuid.uuid4())
|
||||
|
||||
# 创建新的任务记录
|
||||
task = MultiAgentTaskCRUD.create(
|
||||
db=db,
|
||||
task_id=new_task_id,
|
||||
user_id=user_id,
|
||||
query=task_data.get("general_goal", ""),
|
||||
agents_info=task_data.get("agents_info", []),
|
||||
task_outline=task_data.get("task_outline"),
|
||||
assigned_agents=task_data.get("assigned_agents"),
|
||||
agent_scores=task_data.get("agent_scores"),
|
||||
result=task_data.get("result"),
|
||||
)
|
||||
|
||||
# 如果有分支数据,也保存
|
||||
if task_data.get("branches"):
|
||||
MultiAgentTaskCRUD.update_branches(db, new_task_id, task_data["branches"])
|
||||
|
||||
# 如果有执行日志,也保存
|
||||
if task_data.get("rehearsal_log"):
|
||||
MultiAgentTaskCRUD.update_rehearsal_log(db, new_task_id, task_data["rehearsal_log"])
|
||||
|
||||
# 增加分享的查看次数
|
||||
PlanShareCRUD.increment_view_count(db, share_token)
|
||||
|
||||
# 通知所有客户端刷新历史列表
|
||||
socketio.emit('history_updated', {'user_id': user_id})
|
||||
|
||||
emit('response', {
|
||||
'id': request_id,
|
||||
'status': 'success',
|
||||
'data': {
|
||||
"message": "导入成功",
|
||||
"task_id": new_task_id,
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
emit('response', {
|
||||
'id': request_id,
|
||||
'status': 'error',
|
||||
'error': str(e)
|
||||
})
|
||||
|
||||
|
||||
@socketio.on('save_branches')
|
||||
def handle_save_branches(data):
|
||||
"""
|
||||
@@ -3157,6 +3353,168 @@ def get_share_info(record_id: int):
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
# ==================== 任务分享页面 ====================
|
||||
|
||||
@app.route('/share/<share_token>', methods=['GET'])
|
||||
def get_shared_plan_page(share_token: str):
|
||||
"""获取分享任务页面(无需登录验证)"""
|
||||
try:
|
||||
with get_db_context() as db:
|
||||
share = PlanShareCRUD.get_by_token(db, share_token)
|
||||
if not share:
|
||||
return jsonify({'error': '分享链接无效或已失效'}), 404
|
||||
|
||||
# 检查是否过期
|
||||
if share.expires_at and share.expires_at.replace(tzinfo=None) < datetime.now():
|
||||
return jsonify({'error': '分享链接已过期'}), 404
|
||||
|
||||
# 增加查看次数
|
||||
PlanShareCRUD.increment_view_count(db, share_token)
|
||||
|
||||
# 返回分享数据
|
||||
task_data = share.task_data
|
||||
return jsonify({
|
||||
'share_token': share_token,
|
||||
'task_id': share.task_id,
|
||||
'task_data': task_data,
|
||||
'created_at': share.created_at.isoformat() if share.created_at else None,
|
||||
'view_count': share.view_count,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/share/<share_token>/check', methods=['GET'])
|
||||
def check_share_code(share_token: str):
|
||||
"""检查分享链接是否需要提取码"""
|
||||
try:
|
||||
with get_db_context() as db:
|
||||
share = PlanShareCRUD.get_by_token(db, share_token)
|
||||
if not share:
|
||||
return jsonify({'error': '分享链接无效或已失效'}), 404
|
||||
|
||||
# 检查是否过期
|
||||
if share.expires_at and share.expires_at.replace(tzinfo=None) < datetime.now():
|
||||
return jsonify({'error': '分享链接已过期'}), 404
|
||||
|
||||
# 如果有提取码,则需要提取码
|
||||
need_code = bool(share.extraction_code)
|
||||
return jsonify({
|
||||
'need_code': need_code,
|
||||
'has_extraction_code': bool(share.extraction_code)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/share/<share_token>', methods=['GET'])
|
||||
def get_shared_plan_info(share_token: str):
|
||||
"""获取分享任务详情(API 接口,无需登录验证)"""
|
||||
# 获取URL参数中的提取码
|
||||
code = request.args.get('code', '').upper()
|
||||
|
||||
try:
|
||||
with get_db_context() as db:
|
||||
share = PlanShareCRUD.get_by_token(db, share_token)
|
||||
if not share:
|
||||
return jsonify({'error': '分享链接无效或已失效'}), 404
|
||||
|
||||
# 检查是否过期
|
||||
if share.expires_at and share.expires_at.replace(tzinfo=None) < datetime.now():
|
||||
return jsonify({'error': '分享链接已过期'}), 404
|
||||
|
||||
# 验证提取码
|
||||
if share.extraction_code:
|
||||
if not code:
|
||||
return jsonify({'error': '请输入提取码'}), 403
|
||||
if code != share.extraction_code:
|
||||
return jsonify({'error': '提取码错误'}), 403
|
||||
|
||||
# 增加查看次数
|
||||
PlanShareCRUD.increment_view_count(db, share_token)
|
||||
|
||||
# 返回分享数据
|
||||
task_data = share.task_data
|
||||
return jsonify({
|
||||
'share_token': share_token,
|
||||
'task_id': share.task_id,
|
||||
'task_data': task_data,
|
||||
'created_at': share.created_at.isoformat() if share.created_at else None,
|
||||
'expires_at': share.expires_at.isoformat() if share.expires_at else None,
|
||||
'view_count': share.view_count,
|
||||
'extraction_code': share.extraction_code,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/share/import', methods=['POST'])
|
||||
def import_shared_plan():
|
||||
"""导入分享的任务到自己的历史记录(HTTP API,无需 WebSocket)"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
share_token = data.get('share_token')
|
||||
user_id = data.get('user_id')
|
||||
|
||||
if not share_token:
|
||||
return jsonify({'error': '缺少 share_token'}), 400
|
||||
|
||||
if not user_id:
|
||||
return jsonify({'error': '缺少 user_id,请先登录'}), 401
|
||||
|
||||
with get_db_context() as db:
|
||||
# 获取分享记录
|
||||
share = PlanShareCRUD.get_by_token(db, share_token)
|
||||
if not share:
|
||||
return jsonify({'error': '分享链接无效或已失效'}), 404
|
||||
|
||||
# 检查是否过期
|
||||
if share.expires_at and share.expires_at.replace(tzinfo=None) < datetime.now():
|
||||
return jsonify({'error': '分享链接已过期'}), 404
|
||||
|
||||
# 获取分享的任务数据
|
||||
task_data = share.task_data
|
||||
|
||||
# 生成新的 task_id(因为是导入到自己的账号)
|
||||
new_task_id = str(uuid.uuid4())
|
||||
|
||||
# 创建新的任务记录
|
||||
task = MultiAgentTaskCRUD.create(
|
||||
db=db,
|
||||
task_id=new_task_id,
|
||||
user_id=user_id,
|
||||
query=task_data.get("general_goal", ""),
|
||||
agents_info=task_data.get("agents_info", []),
|
||||
task_outline=task_data.get("task_outline"),
|
||||
assigned_agents=task_data.get("assigned_agents"),
|
||||
agent_scores=task_data.get("agent_scores"),
|
||||
result=task_data.get("result"),
|
||||
)
|
||||
|
||||
# 如果有分支数据,也保存
|
||||
if task_data.get("branches"):
|
||||
MultiAgentTaskCRUD.update_branches(db, new_task_id, task_data["branches"])
|
||||
|
||||
# 如果有执行日志,也保存
|
||||
if task_data.get("rehearsal_log"):
|
||||
MultiAgentTaskCRUD.update_rehearsal_log(db, new_task_id, task_data["rehearsal_log"])
|
||||
|
||||
# 增加分享的查看次数
|
||||
PlanShareCRUD.increment_view_count(db, share_token)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': '导入成功',
|
||||
'task_id': new_task_id,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/export/<int:record_id>', methods=['DELETE'])
|
||||
def delete_export(record_id: int):
|
||||
"""删除导出记录"""
|
||||
|
||||
Reference in New Issue
Block a user