Build lightweight AI agent admin
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
"""
|
||||
AI 平台服务层
|
||||
"""
|
||||
from .llm_service import LLMService
|
||||
from .chat_service import ChatService
|
||||
from .workflow_service import AIWorkflowService
|
||||
from .agent_service import AgentService
|
||||
|
||||
__all__ = [
|
||||
'LLMService',
|
||||
'ChatService',
|
||||
'AIWorkflowService',
|
||||
'AgentService',
|
||||
]
|
||||
@@ -0,0 +1,229 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
智能体导入/导出
|
||||
"""
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ai_platform.models import Agent, AIWorkflow, LLMModel
|
||||
from ai_platform.knowledge.models import KnowledgeBase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AgentImportExportException(Exception):
|
||||
"""智能体导入导出异常"""
|
||||
pass
|
||||
|
||||
|
||||
async def export_config(db: AsyncSession, agent_id: str) -> Dict[str, Any]:
|
||||
"""导出智能体配置(外部引用使用 code / model_name)"""
|
||||
result = await db.execute(
|
||||
select(Agent).where(
|
||||
Agent.id == agent_id,
|
||||
Agent.is_deleted == False,
|
||||
)
|
||||
)
|
||||
agent = result.scalar_one_or_none()
|
||||
if not agent:
|
||||
raise AgentImportExportException("智能体不存在")
|
||||
|
||||
model_name = ""
|
||||
if agent.model_id:
|
||||
model_result = await db.execute(
|
||||
select(LLMModel.model_name).where(
|
||||
LLMModel.id == agent.model_id,
|
||||
LLMModel.is_deleted == False,
|
||||
)
|
||||
)
|
||||
row = model_result.first()
|
||||
if row:
|
||||
model_name = row[0] or ""
|
||||
|
||||
workflow_code = ""
|
||||
if agent.workflow_id:
|
||||
wf_result = await db.execute(
|
||||
select(AIWorkflow.code).where(
|
||||
AIWorkflow.id == agent.workflow_id,
|
||||
AIWorkflow.is_deleted == False,
|
||||
)
|
||||
)
|
||||
row = wf_result.first()
|
||||
if row:
|
||||
workflow_code = row[0] or ""
|
||||
|
||||
knowledge_base_codes: List[str] = []
|
||||
kb_ids = agent.knowledge_base_ids or []
|
||||
if kb_ids:
|
||||
kb_result = await db.execute(
|
||||
select(KnowledgeBase.code).where(
|
||||
KnowledgeBase.id.in_(kb_ids),
|
||||
KnowledgeBase.is_deleted == False,
|
||||
)
|
||||
)
|
||||
knowledge_base_codes = [row[0] for row in kb_result if row[0]]
|
||||
|
||||
return {
|
||||
"name": agent.name,
|
||||
"code": agent.code,
|
||||
"description": agent.description or "",
|
||||
"avatar": agent.avatar or "",
|
||||
"mode": agent.mode or "autonomous",
|
||||
"is_global": agent.is_global or False,
|
||||
"persona": agent.persona or {},
|
||||
"system_prompt": agent.system_prompt or "",
|
||||
"model_name": model_name,
|
||||
"temperature": agent.temperature if agent.temperature is not None else 0.7,
|
||||
"top_p": agent.top_p if agent.top_p is not None else 1.0,
|
||||
"max_tokens": agent.max_tokens or 4096,
|
||||
"max_iterations": agent.max_iterations or 10,
|
||||
"welcome_message": agent.welcome_message or "",
|
||||
"suggested_questions": agent.suggested_questions or [],
|
||||
"workflow_code": workflow_code,
|
||||
"knowledge_base_codes": knowledge_base_codes,
|
||||
"knowledge_config": agent.knowledge_config or {},
|
||||
"enable_memory": agent.enable_memory or False,
|
||||
"memory_window": agent.memory_window or 10,
|
||||
"enable_streaming": (
|
||||
agent.enable_streaming if agent.enable_streaming is not None else True
|
||||
),
|
||||
"is_public": agent.is_public or False,
|
||||
}
|
||||
|
||||
|
||||
async def check_import(db: AsyncSession, code: str) -> Dict[str, Any]:
|
||||
"""导入预检查:编码是否冲突"""
|
||||
code_exists = False
|
||||
if code:
|
||||
result = await db.execute(
|
||||
select(Agent).where(
|
||||
Agent.code == code,
|
||||
Agent.is_deleted == False,
|
||||
)
|
||||
)
|
||||
code_exists = result.scalar_one_or_none() is not None
|
||||
|
||||
return {
|
||||
"code_exists": code_exists,
|
||||
"can_import": not code_exists,
|
||||
}
|
||||
|
||||
|
||||
async def _resolve_model_id(db: AsyncSession, model_name: str) -> Optional[str]:
|
||||
if not model_name:
|
||||
return None
|
||||
result = await db.execute(
|
||||
select(LLMModel.id).where(
|
||||
LLMModel.model_name == model_name,
|
||||
LLMModel.is_deleted == False,
|
||||
LLMModel.is_active == True,
|
||||
).limit(1)
|
||||
)
|
||||
row = result.first()
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
async def _resolve_workflow_id(db: AsyncSession, workflow_code: str) -> Optional[str]:
|
||||
if not workflow_code:
|
||||
return None
|
||||
result = await db.execute(
|
||||
select(AIWorkflow.id).where(
|
||||
AIWorkflow.code == workflow_code,
|
||||
AIWorkflow.is_deleted == False,
|
||||
)
|
||||
)
|
||||
row = result.first()
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
async def _resolve_knowledge_base_ids(
|
||||
db: AsyncSession,
|
||||
codes: List[str],
|
||||
) -> tuple[List[str], List[str]]:
|
||||
if not codes:
|
||||
return [], []
|
||||
result = await db.execute(
|
||||
select(KnowledgeBase.id, KnowledgeBase.code).where(
|
||||
KnowledgeBase.code.in_(codes),
|
||||
KnowledgeBase.is_deleted == False,
|
||||
)
|
||||
)
|
||||
code_to_id = {row.code: row.id for row in result}
|
||||
resolved_ids = []
|
||||
unresolved: List[str] = []
|
||||
for code in codes:
|
||||
if code in code_to_id:
|
||||
resolved_ids.append(code_to_id[code])
|
||||
else:
|
||||
unresolved.append(code)
|
||||
return resolved_ids, unresolved
|
||||
|
||||
|
||||
async def import_config(db: AsyncSession, data: Dict[str, Any]) -> Agent:
|
||||
"""导入智能体配置"""
|
||||
required_fields = ["name", "code"]
|
||||
for field in required_fields:
|
||||
if not data.get(field):
|
||||
raise AgentImportExportException(f"缺少必要字段: {field}")
|
||||
|
||||
exists = await db.execute(
|
||||
select(Agent).where(
|
||||
Agent.code == data["code"],
|
||||
Agent.is_deleted == False,
|
||||
)
|
||||
)
|
||||
if exists.scalar_one_or_none():
|
||||
raise AgentImportExportException(f"智能体编码已存在: {data['code']}")
|
||||
|
||||
model_id = await _resolve_model_id(db, data.get("model_name") or "")
|
||||
if data.get("model_name") and not model_id:
|
||||
logger.warning("导入智能体时未找到模型: %s", data.get("model_name"))
|
||||
|
||||
workflow_id = await _resolve_workflow_id(db, data.get("workflow_code") or "")
|
||||
if data.get("workflow_code") and not workflow_id:
|
||||
logger.warning("导入智能体时未找到工作流: %s", data.get("workflow_code"))
|
||||
|
||||
kb_codes = data.get("knowledge_base_codes") or []
|
||||
knowledge_base_ids, unresolved_kb = await _resolve_knowledge_base_ids(db, kb_codes)
|
||||
if unresolved_kb:
|
||||
logger.warning(
|
||||
"导入智能体时部分知识库未找到: %s",
|
||||
", ".join(unresolved_kb),
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
application_id=data.get("application_id"),
|
||||
is_global=data.get("is_global", False),
|
||||
name=data["name"],
|
||||
code=data["code"],
|
||||
description=data.get("description", ""),
|
||||
avatar=data.get("avatar", ""),
|
||||
mode=data.get("mode", "autonomous"),
|
||||
status="draft",
|
||||
persona=data.get("persona") or {},
|
||||
system_prompt=data.get("system_prompt", ""),
|
||||
model_id=model_id,
|
||||
temperature=data.get("temperature", 0.7),
|
||||
top_p=data.get("top_p", 1.0),
|
||||
max_tokens=data.get("max_tokens", 4096),
|
||||
max_iterations=data.get("max_iterations", 10),
|
||||
welcome_message=data.get("welcome_message", ""),
|
||||
suggested_questions=data.get("suggested_questions") or [],
|
||||
workflow_id=workflow_id,
|
||||
knowledge_base_ids=knowledge_base_ids,
|
||||
knowledge_config=data.get("knowledge_config") or {},
|
||||
enable_memory=data.get("enable_memory", False),
|
||||
memory_window=data.get("memory_window", 10),
|
||||
enable_streaming=data.get("enable_streaming", True),
|
||||
is_public=data.get("is_public", False),
|
||||
)
|
||||
db.add(agent)
|
||||
await db.flush()
|
||||
await db.refresh(agent)
|
||||
|
||||
logger.info("智能体导入成功: %s", agent.code)
|
||||
return agent
|
||||
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
Built-in agent Chinese display normalization.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from typing import Any, Dict, Iterable, Optional
|
||||
|
||||
|
||||
class BuiltinAgentText:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
code_label: str,
|
||||
description: str,
|
||||
role: str = "",
|
||||
skills: Optional[list[str]] = None,
|
||||
constraints: Optional[list[str]] = None,
|
||||
background: str = "",
|
||||
keys: Iterable[str],
|
||||
description_keywords: Iterable[str] = (),
|
||||
persona_keywords: Iterable[str] = (),
|
||||
):
|
||||
self.name = name
|
||||
self.code_label = code_label
|
||||
self.description = description
|
||||
self.role = role
|
||||
self.skills = skills or []
|
||||
self.constraints = constraints or []
|
||||
self.background = background
|
||||
self.keys = list(keys)
|
||||
self.description_keywords = list(description_keywords)
|
||||
self.persona_keywords = list(persona_keywords)
|
||||
|
||||
|
||||
BUILTIN_AGENT_TEXTS = [
|
||||
BuiltinAgentText(
|
||||
name="项目经理",
|
||||
code_label="项目管理",
|
||||
description="协调交付状态、进度风险和跨角色协作,确保项目按计划推进。",
|
||||
keys=("project manager", "project_manager", "project-manager", "projectmanager"),
|
||||
description_keywords=("coordinates delivery status",),
|
||||
),
|
||||
BuiltinAgentText(
|
||||
name="Multica 产品经理",
|
||||
code_label="产品管理",
|
||||
description="负责产品需求分诊、优先级判断和方案澄清,推动产品决策落地。",
|
||||
role="负责 Multica 协作工作的产品需求分诊、范围澄清、验收标准和质量推进决策。",
|
||||
skills=["范围澄清", "验收标准设计", "优先级取舍分析", "干系人沟通"],
|
||||
constraints=[
|
||||
"需求必须能追溯到具体 issue 或用户请求。",
|
||||
"没有明确验收标准时,不推进下游工作。",
|
||||
"遇到未决产品决策时要明确指出,不能擅自假设。",
|
||||
],
|
||||
background="擅长把模糊的平台需求转化为责任清晰、可评审、可交付的 Multica issue。",
|
||||
keys=(
|
||||
"multica product manager",
|
||||
"multica_product_manager",
|
||||
"multica-product-manager",
|
||||
"product manager",
|
||||
"product_manager",
|
||||
"product-manager",
|
||||
),
|
||||
description_keywords=("owns product triage",),
|
||||
persona_keywords=(
|
||||
"product manager for multica",
|
||||
"scope clarification",
|
||||
"acceptance criteria design",
|
||||
"priority tradeoff analysis",
|
||||
"stakeholder communication",
|
||||
"keep requirements traceable",
|
||||
),
|
||||
),
|
||||
BuiltinAgentText(
|
||||
name="业务需求分析师",
|
||||
code_label="需求分析",
|
||||
description="将业务诉求转化为清晰需求、验收标准和可执行的交付范围。",
|
||||
keys=(
|
||||
"business requirements analyst",
|
||||
"business_requirements_analyst",
|
||||
"business-requirements-analyst",
|
||||
"business analyst",
|
||||
"business_analyst",
|
||||
"business-analyst",
|
||||
),
|
||||
description_keywords=("translates business needs",),
|
||||
),
|
||||
BuiltinAgentText(
|
||||
name="系统架构师",
|
||||
code_label="系统架构",
|
||||
description="设计系统边界、模块职责和技术方案,保障架构一致性与可演进性。",
|
||||
keys=("system architect", "system_architect", "system-architect"),
|
||||
),
|
||||
BuiltinAgentText(
|
||||
name="前端工程师",
|
||||
code_label="前端开发",
|
||||
description="负责前端页面、组件交互、路由状态和浏览器端体验实现。",
|
||||
keys=("frontend engineer", "frontend_engineer", "frontend-engineer"),
|
||||
),
|
||||
BuiltinAgentText(
|
||||
name="后端工程师",
|
||||
code_label="后端开发",
|
||||
description="负责后端接口、业务服务、数据模型和系统集成能力建设。",
|
||||
keys=("backend engineer", "backend_engineer", "backend-engineer"),
|
||||
),
|
||||
BuiltinAgentText(
|
||||
name="测试工程师",
|
||||
code_label="质量保障",
|
||||
description="负责测试策略、用例设计、缺陷验证和发布质量把关。",
|
||||
keys=("qa engineer", "qa_engineer", "qa-engineer"),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _normalize_text(value: Any) -> str:
|
||||
return " ".join(str(value or "").strip().lower().replace("-", " ").replace("_", " ").split())
|
||||
|
||||
|
||||
def _iter_persona_values(persona: Any) -> Iterable[str]:
|
||||
if not isinstance(persona, dict):
|
||||
return []
|
||||
|
||||
values: list[str] = []
|
||||
for key in ("role", "background"):
|
||||
value = persona.get(key)
|
||||
if isinstance(value, str):
|
||||
values.append(value)
|
||||
for key in ("personality", "skills", "constraints"):
|
||||
values.extend(item for item in persona.get(key) or [] if isinstance(item, str))
|
||||
return values
|
||||
|
||||
|
||||
def find_builtin_agent_text(data: Dict[str, Any]) -> Optional[BuiltinAgentText]:
|
||||
name = _normalize_text(data.get("name"))
|
||||
code = _normalize_text(data.get("code"))
|
||||
description = _normalize_text(data.get("description"))
|
||||
persona_text = _normalize_text(" ".join(_iter_persona_values(data.get("persona"))))
|
||||
|
||||
for display in BUILTIN_AGENT_TEXTS:
|
||||
keys = {_normalize_text(key) for key in display.keys}
|
||||
if name in keys or code in keys:
|
||||
return display
|
||||
if any(keyword in description for keyword in display.description_keywords):
|
||||
return display
|
||||
if any(keyword in persona_text for keyword in display.persona_keywords):
|
||||
return display
|
||||
return None
|
||||
|
||||
|
||||
def normalize_builtin_agent_payload(data: Dict[str, Any], *, include_code_label: bool = False) -> Dict[str, Any]:
|
||||
display = find_builtin_agent_text(data)
|
||||
if not display:
|
||||
return data
|
||||
|
||||
normalized = deepcopy(data)
|
||||
normalized["name"] = display.name
|
||||
normalized["description"] = display.description
|
||||
if include_code_label:
|
||||
normalized["code_label"] = display.code_label
|
||||
|
||||
persona = deepcopy(normalized.get("persona") or {})
|
||||
if display.role:
|
||||
persona["role"] = display.role
|
||||
if display.skills:
|
||||
persona["skills"] = list(display.skills)
|
||||
if display.constraints:
|
||||
persona["constraints"] = list(display.constraints)
|
||||
if display.background:
|
||||
persona["background"] = display.background
|
||||
normalized["persona"] = persona
|
||||
return normalized
|
||||
@@ -0,0 +1,864 @@
|
||||
"""
|
||||
智能体服务
|
||||
"""
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ai_platform.models import (
|
||||
Agent, AgentConversation, AgentMessage, LLMModel,
|
||||
)
|
||||
from .llm_service import LLMService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AgentService:
|
||||
"""
|
||||
智能体服务
|
||||
|
||||
管理智能体的对话和推理
|
||||
"""
|
||||
|
||||
def __init__(self, db: Optional[AsyncSession] = None):
|
||||
self._db = db
|
||||
self.llm_service = LLMService(db)
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
agent: Agent,
|
||||
conversation: AgentConversation,
|
||||
user_message: str,
|
||||
application_id: str = None,
|
||||
form_code: str = None,
|
||||
attachments: List[Dict] = None,
|
||||
):
|
||||
"""
|
||||
与智能体对话(流式)
|
||||
|
||||
Args:
|
||||
agent: 智能体
|
||||
conversation: 对话
|
||||
user_message: 用户消息
|
||||
application_id: 子应用 ID(用于表单创建等场景)
|
||||
form_code: 表单编码(从表单列表调用时传入)
|
||||
attachments: 附件列表 [{id, type, name, url, mime_type, size}]
|
||||
|
||||
Yields:
|
||||
事件流
|
||||
"""
|
||||
if agent.mode == 'autonomous':
|
||||
async for event in self._chat_autonomous(agent, conversation, user_message, attachments):
|
||||
yield event
|
||||
elif agent.mode == 'dialog_flow':
|
||||
async for event in self._chat_dialog_flow(agent, conversation, user_message, application_id, form_code, attachments):
|
||||
yield event
|
||||
else:
|
||||
yield {'type': 'error', 'content': f'不支持的模式: {agent.mode}'}
|
||||
|
||||
async def _chat_autonomous(
|
||||
self,
|
||||
agent: Agent,
|
||||
conversation: AgentConversation,
|
||||
user_message: str,
|
||||
attachments: List[Dict] = None,
|
||||
):
|
||||
"""
|
||||
自主规划模式对话
|
||||
|
||||
优先使用原生 Function Calling,如果模型不支持则回退到 ReAct 模式
|
||||
|
||||
Yields:
|
||||
流式事件
|
||||
"""
|
||||
# 检查模型是否支持 Function Calling
|
||||
supports_function_call = await self._check_model_supports_function_call(agent.model_id)
|
||||
|
||||
if supports_function_call:
|
||||
async for event in self._chat_autonomous_function_calling(agent, conversation, user_message, attachments):
|
||||
yield event
|
||||
else:
|
||||
async for event in self._chat_autonomous_react(agent, conversation, user_message, attachments):
|
||||
yield event
|
||||
|
||||
async def _check_model_supports_function_call(self, model_id) -> bool:
|
||||
"""检查模型是否支持 Function Calling"""
|
||||
if not self._db or not model_id:
|
||||
return False
|
||||
|
||||
result = await self._db.execute(
|
||||
select(LLMModel).where(LLMModel.id == model_id, LLMModel.is_deleted == False)
|
||||
)
|
||||
model = result.scalar_one_or_none()
|
||||
return model.supports_function_call if model else False
|
||||
|
||||
async def _chat_autonomous_function_calling(
|
||||
self,
|
||||
agent: Agent,
|
||||
conversation: AgentConversation,
|
||||
user_message: str,
|
||||
attachments: List[Dict] = None,
|
||||
):
|
||||
"""
|
||||
自主规划模式对话(支持多模态附件)
|
||||
|
||||
Yields:
|
||||
流式事件
|
||||
"""
|
||||
from ai_platform.providers.base import LLMMessage
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
if not self._db:
|
||||
yield {'type': 'error', 'content': '数据库会话未初始化'}
|
||||
return
|
||||
|
||||
# 创建用户消息(包含附件)
|
||||
# 附件格式: [{file_id, type, name, mime_type, size}]
|
||||
stored_attachments = []
|
||||
if attachments:
|
||||
for att in attachments:
|
||||
stored_attachments.append({
|
||||
'file_id': att.get('file_id'),
|
||||
'type': att.get('type', 'file'),
|
||||
'name': att.get('name', ''),
|
||||
'mime_type': att.get('mime_type', ''),
|
||||
'size': att.get('size', 0),
|
||||
})
|
||||
|
||||
user_msg = AgentMessage(
|
||||
conversation_id=conversation.id,
|
||||
role='user',
|
||||
content=user_message,
|
||||
attachments=stored_attachments,
|
||||
status='completed',
|
||||
)
|
||||
self._db.add(user_msg)
|
||||
await self._db.flush()
|
||||
|
||||
# 创建助手消息(pending 状态)
|
||||
assistant_msg = AgentMessage(
|
||||
conversation_id=conversation.id,
|
||||
role='assistant',
|
||||
content='',
|
||||
status='pending',
|
||||
)
|
||||
self._db.add(assistant_msg)
|
||||
await self._db.flush()
|
||||
|
||||
yield {
|
||||
'type': 'start',
|
||||
'conversation_id': str(conversation.id),
|
||||
'message_id': str(assistant_msg.id),
|
||||
}
|
||||
|
||||
try:
|
||||
# 构建系统提示词
|
||||
system_prompt = self._build_system_prompt_simple(agent)
|
||||
|
||||
# 标注直接回复检查(高相似度时跳过 LLM,直接返回标注答案)
|
||||
annotation_reply = await self._check_annotation_direct_reply(agent, user_message)
|
||||
if annotation_reply:
|
||||
final_answer = annotation_reply['answer']
|
||||
elapsed_time = int((time.time() - start_time) * 1000)
|
||||
assistant_msg.content = final_answer
|
||||
assistant_msg.status = 'completed'
|
||||
assistant_msg.elapsed_time = elapsed_time
|
||||
|
||||
yield {
|
||||
'type': 'annotation_reply',
|
||||
'content': f"标注直接回复 (相似度: {annotation_reply['score']:.2f})",
|
||||
}
|
||||
yield {
|
||||
'type': 'answer',
|
||||
'content': final_answer,
|
||||
}
|
||||
|
||||
conversation.message_count = (conversation.message_count or 0) + 2
|
||||
if conversation.message_count == 2 and (not conversation.title or conversation.title == "新建对话"):
|
||||
self._generate_title(conversation, user_message)
|
||||
agent.message_count = (agent.message_count or 0) + 2
|
||||
await self._db.commit()
|
||||
|
||||
yield {
|
||||
'type': 'complete',
|
||||
'message_id': str(assistant_msg.id),
|
||||
'conversation_id': str(conversation.id),
|
||||
'tokens_used': 0,
|
||||
'elapsed_time': elapsed_time,
|
||||
}
|
||||
return
|
||||
|
||||
# 知识库检索增强(RAG)
|
||||
knowledge_context = await self._retrieve_knowledge_context(agent, user_message)
|
||||
if knowledge_context:
|
||||
system_prompt = self._inject_knowledge_context(system_prompt, knowledge_context)
|
||||
yield {
|
||||
'type': 'knowledge_retrieval',
|
||||
'content': knowledge_context['summary'],
|
||||
'result_count': knowledge_context['result_count'],
|
||||
}
|
||||
|
||||
# 获取对话历史
|
||||
history = await self._get_conversation_history(conversation, limit=10)
|
||||
|
||||
# 构建当前用户消息内容(支持多模态)
|
||||
user_content = LLMMessage.create_multimodal_content(user_message, attachments)
|
||||
history.append({'role': 'user', 'content': user_content})
|
||||
|
||||
# 构建消息
|
||||
messages = [{'role': 'system', 'content': system_prompt}] + history
|
||||
|
||||
# 检查是否启用流式输出
|
||||
enable_streaming = getattr(agent, 'enable_streaming', True)
|
||||
|
||||
total_tokens = 0
|
||||
final_answer = ''
|
||||
|
||||
if enable_streaming:
|
||||
# 流式输出
|
||||
accumulated_content = ''
|
||||
async for chunk in self.llm_service.chat_stream(
|
||||
model_id=str(agent.model_id),
|
||||
messages=messages,
|
||||
temperature=agent.temperature or 0.7,
|
||||
max_tokens=agent.max_tokens or 2048,
|
||||
):
|
||||
if chunk.content:
|
||||
accumulated_content += chunk.content
|
||||
yield {
|
||||
'type': 'llm_chunk',
|
||||
'content': chunk.content,
|
||||
'accumulated_content': accumulated_content,
|
||||
}
|
||||
|
||||
if chunk.is_finished:
|
||||
total_tokens = chunk.total_tokens
|
||||
final_answer = accumulated_content
|
||||
else:
|
||||
# 非流式输出
|
||||
response = await self.llm_service.chat_async(
|
||||
model_id=str(agent.model_id),
|
||||
messages=messages,
|
||||
temperature=agent.temperature or 0.7,
|
||||
max_tokens=agent.max_tokens or 2048,
|
||||
)
|
||||
|
||||
total_tokens = response.total_tokens
|
||||
final_answer = response.content
|
||||
|
||||
yield {
|
||||
'type': 'answer',
|
||||
'content': final_answer,
|
||||
}
|
||||
|
||||
# 更新助手消息
|
||||
elapsed_time = int((time.time() - start_time) * 1000)
|
||||
assistant_msg.content = final_answer
|
||||
assistant_msg.status = 'completed'
|
||||
assistant_msg.total_tokens = total_tokens
|
||||
assistant_msg.elapsed_time = elapsed_time
|
||||
|
||||
# 更新对话统计
|
||||
conversation.message_count = (conversation.message_count or 0) + 2
|
||||
conversation.total_tokens = (conversation.total_tokens or 0) + total_tokens
|
||||
|
||||
# 自动生成标题(第一次对话时,且标题为默认值)
|
||||
if conversation.message_count == 2 and (not conversation.title or conversation.title == "新建对话"):
|
||||
self._generate_title(conversation, user_message)
|
||||
|
||||
# 更新智能体统计
|
||||
agent.message_count = (agent.message_count or 0) + 2
|
||||
agent.total_tokens = (agent.total_tokens or 0) + total_tokens
|
||||
|
||||
await self._db.commit()
|
||||
|
||||
yield {
|
||||
'type': 'complete',
|
||||
'message_id': str(assistant_msg.id),
|
||||
'conversation_id': str(conversation.id),
|
||||
'tokens_used': total_tokens,
|
||||
'elapsed_time': elapsed_time,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f'Agent chat error: {e}')
|
||||
assistant_msg.status = 'failed'
|
||||
assistant_msg.error_message = str(e)
|
||||
await self._db.commit()
|
||||
yield {
|
||||
'type': 'error',
|
||||
'content': str(e),
|
||||
}
|
||||
|
||||
async def _chat_autonomous_react(
|
||||
self,
|
||||
agent: Agent,
|
||||
conversation: AgentConversation,
|
||||
user_message: str,
|
||||
attachments: List[Dict] = None,
|
||||
):
|
||||
"""
|
||||
ReAct 模式(简化版,不使用工具,直接复用 function calling 逻辑)
|
||||
|
||||
Yields:
|
||||
流式事件
|
||||
"""
|
||||
# 直接复用简化后的 function calling 逻辑
|
||||
async for event in self._chat_autonomous_function_calling(agent, conversation, user_message, attachments):
|
||||
yield event
|
||||
|
||||
async def _chat_dialog_flow(
|
||||
self,
|
||||
agent: Agent,
|
||||
conversation: AgentConversation,
|
||||
user_message: str,
|
||||
application_id: str = None,
|
||||
form_code: str = None,
|
||||
attachments: List[Dict] = None,
|
||||
):
|
||||
"""
|
||||
对话流模式对话
|
||||
|
||||
直接复用工作流服务的执行逻辑,透传工作流事件
|
||||
|
||||
Args:
|
||||
agent: 智能体
|
||||
conversation: 对话
|
||||
user_message: 用户消息
|
||||
application_id: 子应用 ID(用于表单创建等场景)
|
||||
form_code: 表单编码(从表单列表调用时传入)
|
||||
attachments: 附件列表
|
||||
|
||||
Yields:
|
||||
流式事件
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
if not self._db:
|
||||
yield {'type': 'error', 'content': '数据库会话未初始化'}
|
||||
return
|
||||
|
||||
# 创建用户消息(包含附件)
|
||||
# 附件格式: [{file_id, type, name, mime_type, size}]
|
||||
stored_attachments = []
|
||||
if attachments:
|
||||
for att in attachments:
|
||||
stored_attachments.append({
|
||||
'file_id': att.get('file_id'),
|
||||
'type': att.get('type', 'file'),
|
||||
'name': att.get('name', ''),
|
||||
'mime_type': att.get('mime_type', ''),
|
||||
'size': att.get('size', 0),
|
||||
})
|
||||
|
||||
user_msg = AgentMessage(
|
||||
conversation_id=conversation.id,
|
||||
role='user',
|
||||
content=user_message,
|
||||
attachments=stored_attachments,
|
||||
status='completed',
|
||||
)
|
||||
self._db.add(user_msg)
|
||||
await self._db.flush()
|
||||
|
||||
# 创建助手消息
|
||||
assistant_msg = AgentMessage(
|
||||
conversation_id=conversation.id,
|
||||
role='assistant',
|
||||
content='',
|
||||
status='pending',
|
||||
)
|
||||
self._db.add(assistant_msg)
|
||||
await self._db.flush()
|
||||
|
||||
yield {
|
||||
'type': 'start',
|
||||
'conversation_id': str(conversation.id),
|
||||
'message_id': str(assistant_msg.id),
|
||||
}
|
||||
|
||||
try:
|
||||
from ai_platform.services.workflow_service import AIWorkflowService
|
||||
|
||||
workflow_service = AIWorkflowService(self._db)
|
||||
|
||||
# 检查是否有进行中的工作流运行
|
||||
workflow_run_id = None
|
||||
if conversation.extra_data:
|
||||
workflow_run_id = conversation.extra_data.get('workflow_run_id')
|
||||
|
||||
final_content = ''
|
||||
is_waiting = False
|
||||
total_tokens = 0
|
||||
current_run_id = workflow_run_id
|
||||
|
||||
# 获取对话历史(如果启用了记忆功能)
|
||||
conversation_history = []
|
||||
if agent.enable_memory:
|
||||
conversation_history = await self._get_conversation_history(
|
||||
conversation,
|
||||
limit=agent.memory_window or 10
|
||||
)
|
||||
|
||||
if workflow_run_id:
|
||||
# 恢复工作流执行 - 使用异步方法
|
||||
# 将附件内容(文件解析内容或图片OCR)整合到 user_input 中
|
||||
enhanced_user_input = user_message
|
||||
if attachments:
|
||||
attachment_texts = []
|
||||
for att in attachments:
|
||||
text_content = att.get('text_content', '')
|
||||
if text_content:
|
||||
file_name = att.get('name', 'unknown')
|
||||
attachment_texts.append(f"\n[附件: {file_name}]\n{text_content}")
|
||||
|
||||
if attachment_texts:
|
||||
enhanced_user_input = user_message + '\n' + '\n'.join(attachment_texts)
|
||||
|
||||
async for event in workflow_service.resume_workflow_stream_async(
|
||||
run_id=workflow_run_id,
|
||||
user_input=enhanced_user_input,
|
||||
conversation_history=conversation_history,
|
||||
):
|
||||
event_type = event.get('type')
|
||||
|
||||
if event_type == 'answer':
|
||||
final_content = event.get('content', '')
|
||||
elif event_type == 'llm_chunk':
|
||||
accumulated = event.get('accumulated_content', '')
|
||||
if accumulated:
|
||||
final_content = accumulated
|
||||
elif event_type == 'node_complete':
|
||||
node_type = event.get('node_type')
|
||||
if node_type == 'llm':
|
||||
outputs = event.get('outputs', {})
|
||||
output = outputs.get('output', '')
|
||||
if output:
|
||||
final_content = output
|
||||
elif event_type == 'waiting_input':
|
||||
is_waiting = True
|
||||
elif event_type == 'complete':
|
||||
total_tokens = event.get('total_tokens', 0)
|
||||
|
||||
yield event
|
||||
else:
|
||||
# 新对话,启动工作流 - 使用异步方法
|
||||
if not agent.workflow_id:
|
||||
yield {'type': 'error', 'content': '智能体未配置工作流'}
|
||||
return
|
||||
|
||||
# 构建工作流输入变量
|
||||
# 将附件内容(文件解析内容或图片OCR)整合到 user_input 中
|
||||
enhanced_user_input = user_message
|
||||
if attachments:
|
||||
attachment_texts = []
|
||||
for att in attachments:
|
||||
text_content = att.get('text_content', '')
|
||||
if text_content:
|
||||
file_name = att.get('name', 'unknown')
|
||||
attachment_texts.append(f"\n[附件: {file_name}]\n{text_content}")
|
||||
|
||||
if attachment_texts:
|
||||
enhanced_user_input = user_message + '\n' + '\n'.join(attachment_texts)
|
||||
|
||||
workflow_inputs = {'user_input': enhanced_user_input}
|
||||
if application_id:
|
||||
workflow_inputs['application_id'] = application_id
|
||||
if form_code:
|
||||
workflow_inputs['form_code'] = form_code
|
||||
|
||||
# 调试日志
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"[AgentService] form_code parameter: {form_code}")
|
||||
logger.info(f"[AgentService] workflow_inputs: {workflow_inputs}")
|
||||
|
||||
async for event in workflow_service.run_workflow_stream_async(
|
||||
workflow_id=str(agent.workflow_id),
|
||||
inputs=workflow_inputs,
|
||||
conversation_id=str(conversation.id),
|
||||
conversation_history=conversation_history,
|
||||
trigger_type='agent',
|
||||
):
|
||||
event_type = event.get('type')
|
||||
|
||||
if event_type == 'start':
|
||||
current_run_id = event.get('run_id')
|
||||
elif event_type == 'answer':
|
||||
final_content = event.get('content', '')
|
||||
elif event_type == 'llm_chunk':
|
||||
accumulated = event.get('accumulated_content', '')
|
||||
if accumulated:
|
||||
final_content = accumulated
|
||||
elif event_type == 'node_complete':
|
||||
node_type = event.get('node_type')
|
||||
if node_type == 'llm':
|
||||
outputs = event.get('outputs', {})
|
||||
output = outputs.get('output', '')
|
||||
if output:
|
||||
final_content = output
|
||||
elif event_type == 'waiting_input':
|
||||
is_waiting = True
|
||||
# 保存工作流运行 ID 到对话元数据
|
||||
if current_run_id:
|
||||
conversation.extra_data = conversation.extra_data or {}
|
||||
conversation.extra_data['workflow_run_id'] = current_run_id
|
||||
elif event_type == 'complete':
|
||||
total_tokens = event.get('total_tokens', 0)
|
||||
|
||||
yield event
|
||||
|
||||
# 更新助手消息
|
||||
elapsed_time = int((time.time() - start_time) * 1000)
|
||||
|
||||
if final_content:
|
||||
assistant_msg.content = final_content
|
||||
|
||||
assistant_msg.status = 'completed'
|
||||
assistant_msg.elapsed_time = elapsed_time
|
||||
assistant_msg.total_tokens = total_tokens
|
||||
|
||||
# 更新对话统计
|
||||
conversation.message_count = (conversation.message_count or 0) + 2
|
||||
conversation.total_tokens = (conversation.total_tokens or 0) + total_tokens
|
||||
|
||||
# 自动生成标题(第一次对话时,且标题为默认值)
|
||||
if conversation.message_count == 2 and (not conversation.title or conversation.title == "新建对话"):
|
||||
self._generate_title(conversation, user_message)
|
||||
|
||||
# 如果工作流完成(非等待状态),清除对话元数据
|
||||
if not is_waiting:
|
||||
if conversation.extra_data:
|
||||
conversation.extra_data.pop('workflow_run_id', None)
|
||||
|
||||
await self._db.commit()
|
||||
|
||||
yield {
|
||||
'type': 'complete',
|
||||
'elapsed_time': elapsed_time,
|
||||
'tokens_used': total_tokens,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f'Dialog flow error: {e}')
|
||||
assistant_msg.status = 'failed'
|
||||
assistant_msg.error_message = str(e)
|
||||
await self._db.commit()
|
||||
yield {
|
||||
'type': 'error',
|
||||
'content': str(e),
|
||||
}
|
||||
|
||||
def _build_system_prompt_simple(self, agent: Agent) -> str:
|
||||
"""构建简化的系统提示词"""
|
||||
# 基础提示词
|
||||
base_prompt = agent.system_prompt
|
||||
|
||||
# 如果没有系统提示词,从人设生成
|
||||
if not base_prompt and agent.persona:
|
||||
base_prompt = self._generate_prompt_from_persona(agent.persona)
|
||||
|
||||
if not base_prompt:
|
||||
base_prompt = "你是一个智能助手,可以帮助用户完成各种任务。回答要简洁明了,使用中文。"
|
||||
|
||||
return base_prompt
|
||||
|
||||
def _generate_prompt_from_persona(self, persona: Dict[str, Any]) -> str:
|
||||
"""从人设配置生成提示词"""
|
||||
parts = []
|
||||
|
||||
if persona.get('role'):
|
||||
parts.append(persona['role'])
|
||||
|
||||
if persona.get('personality'):
|
||||
personalities = persona['personality']
|
||||
if isinstance(personalities, list):
|
||||
parts.append(f"你的性格特点是:{', '.join(personalities)}。")
|
||||
|
||||
if persona.get('skills'):
|
||||
skills = persona['skills']
|
||||
if isinstance(skills, list):
|
||||
parts.append(f"你擅长:{', '.join(skills)}。")
|
||||
|
||||
if persona.get('background'):
|
||||
parts.append(persona['background'])
|
||||
|
||||
if persona.get('constraints'):
|
||||
constraints = persona['constraints']
|
||||
if isinstance(constraints, list):
|
||||
parts.append("注意事项:\n" + "\n".join(f"- {c}" for c in constraints))
|
||||
|
||||
return "\n\n".join(parts)
|
||||
|
||||
async def _get_conversation_history(
|
||||
self,
|
||||
conversation: AgentConversation,
|
||||
limit: int = 10,
|
||||
) -> List[Dict[str, str]]:
|
||||
"""获取对话历史"""
|
||||
if not self._db:
|
||||
return []
|
||||
|
||||
result = await self._db.execute(
|
||||
select(AgentMessage).where(
|
||||
AgentMessage.conversation_id == conversation.id,
|
||||
AgentMessage.is_deleted == False,
|
||||
AgentMessage.role.in_(['user', 'assistant']),
|
||||
AgentMessage.status == 'completed'
|
||||
).order_by(AgentMessage.sys_create_datetime.desc()).limit(limit)
|
||||
)
|
||||
messages = result.scalars().all()
|
||||
|
||||
history = []
|
||||
for msg in reversed(list(messages)):
|
||||
history.append({
|
||||
'role': msg.role,
|
||||
'content': msg.content,
|
||||
})
|
||||
|
||||
return history
|
||||
|
||||
def _generate_title(self, conversation, first_message: str):
|
||||
"""自动生成对话标题"""
|
||||
# 简单截取前 20 个字符作为标题
|
||||
title = first_message[:20]
|
||||
if len(first_message) > 20:
|
||||
title += '...'
|
||||
conversation.title = title
|
||||
|
||||
async def _check_annotation_direct_reply(
|
||||
self,
|
||||
agent: Agent,
|
||||
query: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
检查标注直接回复(Dify 风格)
|
||||
|
||||
当启用标注回复且匹配到高相似度标注时,直接返回标注答案,跳过 LLM 调用。
|
||||
|
||||
Returns:
|
||||
{'answer': str, 'score': float, 'question': str} 或 None
|
||||
"""
|
||||
knowledge_base_ids = getattr(agent, 'knowledge_base_ids', None) or []
|
||||
if not knowledge_base_ids or not self._db:
|
||||
return None
|
||||
|
||||
knowledge_config = getattr(agent, 'knowledge_config', None) or {}
|
||||
annotation_reply_enabled = knowledge_config.get('annotation_reply_enabled', False)
|
||||
if not annotation_reply_enabled:
|
||||
return None
|
||||
|
||||
annotation_threshold = knowledge_config.get('annotation_threshold', 0.9)
|
||||
|
||||
try:
|
||||
from ai_platform.knowledge.services.retrieval_service import RetrievalService
|
||||
from ai_platform.knowledge.models import KnowledgeBase
|
||||
from sqlalchemy import select
|
||||
|
||||
# 获取第一个知识库的 embedding 配置
|
||||
kb_result = await self._db.execute(
|
||||
select(KnowledgeBase).where(
|
||||
KnowledgeBase.id.in_(knowledge_base_ids),
|
||||
KnowledgeBase.is_deleted == False,
|
||||
)
|
||||
)
|
||||
first_kb = kb_result.scalars().first()
|
||||
if not first_kb or not first_kb.embedding_model_id:
|
||||
return None
|
||||
|
||||
service = RetrievalService(self._db)
|
||||
annotation_results = await service._match_annotations(
|
||||
query=query,
|
||||
knowledge_base_ids=knowledge_base_ids,
|
||||
embedding_model_id=first_kb.embedding_model_id,
|
||||
score_threshold=annotation_threshold,
|
||||
dimensions=first_kb.embedding_dimensions,
|
||||
max_results=1,
|
||||
)
|
||||
|
||||
if annotation_results:
|
||||
best = annotation_results[0]
|
||||
metadata = getattr(best, 'metadata', {}) or {}
|
||||
logger.info(
|
||||
f'标注直接回复命中: score={best.score}, '
|
||||
f'question={metadata.get("question", "")}, answer={best.content[:50]}'
|
||||
)
|
||||
|
||||
# 记录检索日志
|
||||
try:
|
||||
from ai_platform.knowledge.models import KnowledgeRetrievalLog
|
||||
log = KnowledgeRetrievalLog(
|
||||
query=query,
|
||||
knowledge_base_ids=knowledge_base_ids,
|
||||
retrieval_mode='annotation',
|
||||
top_k=1,
|
||||
score_threshold=annotation_threshold,
|
||||
result_count=1,
|
||||
results=[{'segment_id': best.segment_id, 'score': best.score, 'kb_id': best.knowledge_base_id}],
|
||||
rerank_applied='false',
|
||||
elapsed_time=0,
|
||||
source='agent_annotation',
|
||||
)
|
||||
self._db.add(log)
|
||||
await self._db.flush()
|
||||
except Exception as e:
|
||||
logger.warning(f'记录标注回复日志失败: {e}')
|
||||
|
||||
return {
|
||||
'answer': best.content,
|
||||
'score': best.score,
|
||||
'question': metadata.get('question', ''),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f'标注直接回复检查失败: {e}')
|
||||
|
||||
return None
|
||||
|
||||
async def _retrieve_knowledge_context(
|
||||
self,
|
||||
agent: Agent,
|
||||
query: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
检索知识库上下文(RAG)
|
||||
|
||||
Args:
|
||||
agent: 智能体(包含 knowledge_base_ids 和 knowledge_config)
|
||||
query: 用户查询文本
|
||||
|
||||
Returns:
|
||||
知识库上下文字典,无结果时返回 None
|
||||
"""
|
||||
knowledge_base_ids = getattr(agent, 'knowledge_base_ids', None) or []
|
||||
if not knowledge_base_ids or not self._db:
|
||||
logger.info(f'RAG 跳过: knowledge_base_ids={knowledge_base_ids}, db={bool(self._db)}')
|
||||
return None
|
||||
|
||||
knowledge_config = getattr(agent, 'knowledge_config', None) or {}
|
||||
logger.info(f'RAG 开始: kb_ids={knowledge_base_ids}, config={knowledge_config}, query={query[:100]}')
|
||||
top_k = knowledge_config.get('top_k', 5)
|
||||
score_threshold = knowledge_config.get('score_threshold', 0.5)
|
||||
retrieval_mode = knowledge_config.get('retrieval_mode', None)
|
||||
rerank_enabled = knowledge_config.get('rerank_enabled', None)
|
||||
rerank_model_id = knowledge_config.get('rerank_model_id', None)
|
||||
|
||||
try:
|
||||
from ai_platform.knowledge.services.retrieval_service import RetrievalService
|
||||
|
||||
start_time = time.time()
|
||||
service = RetrievalService(self._db)
|
||||
results = await service.retrieve(
|
||||
query=query,
|
||||
knowledge_base_ids=knowledge_base_ids,
|
||||
top_k=top_k,
|
||||
score_threshold=score_threshold,
|
||||
retrieval_mode=retrieval_mode,
|
||||
rerank_enabled=rerank_enabled,
|
||||
rerank_model_id=rerank_model_id,
|
||||
)
|
||||
|
||||
elapsed = int((time.time() - start_time) * 1000)
|
||||
|
||||
logger.info(f'RAG 检索结果: {len(results)} 条')
|
||||
for r in results:
|
||||
logger.info(f' - segment_id={r.segment_id}, score={r.score}, doc={getattr(r, "document_name", "")}, type={getattr(r, "metadata", {}).get("type", "segment")}')
|
||||
|
||||
# 记录检索日志(与召回测试共用同一张日志表)
|
||||
try:
|
||||
from ai_platform.knowledge.models import KnowledgeRetrievalLog
|
||||
log = KnowledgeRetrievalLog(
|
||||
query=query,
|
||||
knowledge_base_ids=knowledge_base_ids,
|
||||
retrieval_mode=retrieval_mode or 'hybrid',
|
||||
top_k=top_k,
|
||||
score_threshold=score_threshold,
|
||||
result_count=len(results),
|
||||
results=[
|
||||
{'segment_id': r.segment_id, 'score': r.score, 'kb_id': r.knowledge_base_id}
|
||||
for r in results
|
||||
],
|
||||
rerank_applied='true' if rerank_enabled else 'false',
|
||||
elapsed_time=elapsed,
|
||||
source='agent',
|
||||
)
|
||||
self._db.add(log)
|
||||
await self._db.flush()
|
||||
except Exception as e:
|
||||
logger.warning(f'记录 Agent 检索日志失败: {e}')
|
||||
|
||||
if not results:
|
||||
return None
|
||||
|
||||
# 构建上下文文本
|
||||
context_parts = []
|
||||
for i, r in enumerate(results, 1):
|
||||
score = getattr(r, 'score', 0)
|
||||
content = getattr(r, 'content', '')
|
||||
metadata = getattr(r, 'metadata', {}) or {}
|
||||
|
||||
if metadata.get('type') == 'annotation':
|
||||
# 标注类型:显示为 Q&A 对,让 LLM 明确知道这是预设的标准答案
|
||||
question = metadata.get('question', '')
|
||||
context_parts.append(
|
||||
f"[{i}] [标准问答] (相似度: {score:.2f})\n"
|
||||
f"问题: {question}\n"
|
||||
f"标准答案: {content}"
|
||||
)
|
||||
else:
|
||||
source = getattr(r, 'document_name', '') or ''
|
||||
context_parts.append(
|
||||
f"[{i}] (来源: {source}, 相似度: {score:.2f})\n{content}"
|
||||
)
|
||||
|
||||
context_text = '\n\n'.join(context_parts)
|
||||
summary = f"检索到 {len(results)} 条相关结果"
|
||||
|
||||
return {
|
||||
'context': context_text,
|
||||
'result_count': len(results),
|
||||
'summary': summary,
|
||||
'results': [
|
||||
{
|
||||
'segment_id': getattr(r, 'segment_id', ''),
|
||||
'document_name': getattr(r, 'document_name', ''),
|
||||
'score': getattr(r, 'score', 0),
|
||||
}
|
||||
for r in results
|
||||
],
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f'知识库检索失败: {e}')
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _inject_knowledge_context(
|
||||
system_prompt: str,
|
||||
knowledge_context: Dict[str, Any],
|
||||
) -> str:
|
||||
"""
|
||||
将知识库检索结果注入系统提示词
|
||||
|
||||
参考 Dify 的 RAG 注入方式:在 system prompt 末尾追加参考资料段落,
|
||||
并指示 LLM 基于资料回答,资料中无相关信息时如实告知。
|
||||
"""
|
||||
context_text = knowledge_context.get('context', '')
|
||||
if not context_text:
|
||||
return system_prompt
|
||||
|
||||
rag_instruction = (
|
||||
"\n\n"
|
||||
"# 参考资料\n"
|
||||
"以下是从知识库中检索到的与用户问题相关的参考资料。\n"
|
||||
"- 标记为[标准问答]的条目是预设的权威问答对,当用户问题与其匹配时,请直接使用标准答案回答,不要自行发挥。\n"
|
||||
"- 其他条目为文档参考内容,请基于这些资料回答用户的问题。\n"
|
||||
"- 如果参考资料中没有相关信息,请基于你自身的知识回答,并说明该回答未基于知识库。\n\n"
|
||||
f"{context_text}"
|
||||
)
|
||||
|
||||
return system_prompt + rag_instruction
|
||||
@@ -0,0 +1,413 @@
|
||||
"""
|
||||
对话服务
|
||||
"""
|
||||
import logging
|
||||
import time
|
||||
from typing import AsyncGenerator, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ai_platform.models import AIApp, Conversation, Message, LLMModel
|
||||
from utils.context import get_current_user_id_from_context
|
||||
from .llm_service import LLMService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ChatService:
|
||||
"""
|
||||
对话服务
|
||||
|
||||
管理对话和消息
|
||||
"""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self._db = db
|
||||
self.llm_service = LLMService(db)
|
||||
|
||||
async def create_conversation(
|
||||
self,
|
||||
app_id: str,
|
||||
title: str = '',
|
||||
user_id: Optional[str] = None,
|
||||
) -> Conversation:
|
||||
"""
|
||||
创建对话
|
||||
|
||||
Args:
|
||||
app_id: 应用 ID
|
||||
title: 对话标题
|
||||
|
||||
Returns:
|
||||
Conversation
|
||||
"""
|
||||
current_user_id = user_id or get_current_user_id_from_context()
|
||||
if not current_user_id:
|
||||
raise ValueError('未登录或登录已过期')
|
||||
|
||||
# 查询应用
|
||||
result = await self._db.execute(
|
||||
select(AIApp).where(AIApp.id == app_id, AIApp.is_deleted == False)
|
||||
)
|
||||
app = result.scalar_one_or_none()
|
||||
if not app:
|
||||
raise ValueError(f'应用不存在: {app_id}')
|
||||
|
||||
conversation = Conversation(
|
||||
app_id=app_id,
|
||||
user_id=current_user_id,
|
||||
title=title or '新对话',
|
||||
)
|
||||
self._db.add(conversation)
|
||||
|
||||
# 更新应用统计
|
||||
app.conversation_count = (app.conversation_count or 0) + 1
|
||||
|
||||
await self._db.commit()
|
||||
await self._db.refresh(conversation)
|
||||
|
||||
return conversation
|
||||
|
||||
async def get_conversation(self, conversation_id: str) -> Optional[Conversation]:
|
||||
"""获取对话"""
|
||||
result = await self._db.execute(
|
||||
select(Conversation).where(
|
||||
Conversation.id == conversation_id,
|
||||
Conversation.is_deleted == False
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_conversations(
|
||||
self,
|
||||
app_id: str,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> tuple:
|
||||
"""
|
||||
获取对话列表
|
||||
|
||||
Returns:
|
||||
(conversations, total)
|
||||
"""
|
||||
query = select(Conversation).where(
|
||||
Conversation.app_id == app_id,
|
||||
Conversation.is_deleted == False
|
||||
).order_by(Conversation.is_pinned.desc(), Conversation.sys_update_datetime.desc())
|
||||
|
||||
# 获取总数
|
||||
count_result = await self._db.execute(
|
||||
select(func.count()).select_from(query.subquery())
|
||||
)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
# 分页
|
||||
offset = (page - 1) * page_size
|
||||
query = query.offset(offset).limit(page_size)
|
||||
|
||||
result = await self._db.execute(query)
|
||||
conversations = result.scalars().all()
|
||||
|
||||
return list(conversations), total
|
||||
|
||||
async def delete_conversation(self, conversation_id: str) -> bool:
|
||||
"""删除对话"""
|
||||
result = await self._db.execute(
|
||||
select(Conversation).where(
|
||||
Conversation.id == conversation_id,
|
||||
Conversation.is_deleted == False
|
||||
)
|
||||
)
|
||||
conversation = result.scalar_one_or_none()
|
||||
if not conversation:
|
||||
return False
|
||||
|
||||
conversation.is_deleted = True
|
||||
await self._db.commit()
|
||||
return True
|
||||
|
||||
async def get_messages(
|
||||
self,
|
||||
conversation_id: str,
|
||||
limit: int = 50,
|
||||
) -> List[Message]:
|
||||
"""获取对话消息"""
|
||||
result = await self._db.execute(
|
||||
select(Message).where(
|
||||
Message.conversation_id == conversation_id,
|
||||
Message.is_deleted == False
|
||||
).order_by(Message.sys_create_datetime).limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
conversation_id: str,
|
||||
content: str,
|
||||
) -> tuple:
|
||||
"""
|
||||
发送消息并获取 AI 回复
|
||||
|
||||
Args:
|
||||
conversation_id: 对话 ID
|
||||
content: 消息内容
|
||||
|
||||
Returns:
|
||||
(user_message, assistant_message)
|
||||
"""
|
||||
conversation = await self.get_conversation(conversation_id)
|
||||
if not conversation:
|
||||
raise ValueError('对话不存在')
|
||||
|
||||
# 获取应用
|
||||
app_result = await self._db.execute(
|
||||
select(AIApp).where(AIApp.id == conversation.app_id)
|
||||
)
|
||||
app = app_result.scalar_one_or_none()
|
||||
if not app:
|
||||
raise ValueError('应用不存在')
|
||||
|
||||
# 获取模型
|
||||
model = await self._get_effective_model(conversation, app)
|
||||
if not model:
|
||||
raise ValueError('未配置模型')
|
||||
|
||||
# 创建用户消息
|
||||
user_message = Message(
|
||||
conversation_id=conversation_id,
|
||||
role='user',
|
||||
content=content,
|
||||
status='completed',
|
||||
)
|
||||
self._db.add(user_message)
|
||||
await self._db.flush()
|
||||
|
||||
# 构建消息列表
|
||||
messages = await self._build_messages(conversation, app, content)
|
||||
|
||||
# 创建助手消息(pending 状态)
|
||||
assistant_message = Message(
|
||||
conversation_id=conversation_id,
|
||||
role='assistant',
|
||||
content='',
|
||||
status='pending',
|
||||
model_name=model.model_name,
|
||||
)
|
||||
self._db.add(assistant_message)
|
||||
await self._db.flush()
|
||||
|
||||
try:
|
||||
start_time = time.time()
|
||||
|
||||
# 调用 LLM
|
||||
response = await self.llm_service.chat_async(
|
||||
model_id=str(model.id),
|
||||
messages=messages,
|
||||
temperature=app.temperature or 0.7,
|
||||
max_tokens=app.max_tokens or 2048,
|
||||
)
|
||||
|
||||
latency = int((time.time() - start_time) * 1000)
|
||||
|
||||
# 更新助手消息
|
||||
assistant_message.content = response.content
|
||||
assistant_message.status = 'completed'
|
||||
assistant_message.prompt_tokens = response.prompt_tokens
|
||||
assistant_message.completion_tokens = response.completion_tokens
|
||||
assistant_message.total_tokens = response.total_tokens
|
||||
assistant_message.latency = latency
|
||||
|
||||
# 更新对话统计
|
||||
conversation.message_count = (conversation.message_count or 0) + 2
|
||||
conversation.total_tokens = (conversation.total_tokens or 0) + response.total_tokens
|
||||
|
||||
# 更新应用统计
|
||||
app.message_count = (app.message_count or 0) + 2
|
||||
|
||||
# 自动生成标题
|
||||
if conversation.message_count == 2:
|
||||
self._generate_title(conversation, content)
|
||||
|
||||
await self._db.commit()
|
||||
|
||||
return user_message, assistant_message
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f'发送消息失败: {e}')
|
||||
assistant_message.status = 'failed'
|
||||
assistant_message.error_message = str(e)
|
||||
await self._db.commit()
|
||||
raise
|
||||
|
||||
async def send_message_stream(
|
||||
self,
|
||||
conversation_id: str,
|
||||
content: str,
|
||||
) -> AsyncGenerator[Dict, None]:
|
||||
"""
|
||||
流式发送消息
|
||||
|
||||
Yields:
|
||||
{"type": "content", "content": "..."} 或
|
||||
{"type": "done", "message": {...}}
|
||||
"""
|
||||
conversation = await self.get_conversation(conversation_id)
|
||||
if not conversation:
|
||||
raise ValueError('对话不存在')
|
||||
|
||||
# 获取应用
|
||||
app_result = await self._db.execute(
|
||||
select(AIApp).where(AIApp.id == conversation.app_id)
|
||||
)
|
||||
app = app_result.scalar_one_or_none()
|
||||
if not app:
|
||||
raise ValueError('应用不存在')
|
||||
|
||||
# 获取模型
|
||||
model = await self._get_effective_model(conversation, app)
|
||||
if not model:
|
||||
raise ValueError('未配置模型')
|
||||
|
||||
# 创建用户消息
|
||||
user_message = Message(
|
||||
conversation_id=conversation_id,
|
||||
role='user',
|
||||
content=content,
|
||||
status='completed',
|
||||
)
|
||||
self._db.add(user_message)
|
||||
await self._db.flush()
|
||||
|
||||
# 构建消息列表
|
||||
messages = await self._build_messages(conversation, app, content)
|
||||
|
||||
# 创建助手消息
|
||||
assistant_message = Message(
|
||||
conversation_id=conversation_id,
|
||||
role='assistant',
|
||||
content='',
|
||||
status='pending',
|
||||
model_name=model.model_name,
|
||||
)
|
||||
self._db.add(assistant_message)
|
||||
await self._db.flush()
|
||||
|
||||
try:
|
||||
start_time = time.time()
|
||||
full_content = ''
|
||||
total_tokens = 0
|
||||
prompt_tokens = 0
|
||||
completion_tokens = 0
|
||||
|
||||
# 使用异步流式方法
|
||||
async for chunk in self.llm_service.chat_stream(
|
||||
model_id=str(model.id),
|
||||
messages=messages,
|
||||
temperature=app.temperature or 0.7,
|
||||
max_tokens=app.max_tokens or 2048,
|
||||
):
|
||||
if chunk.content:
|
||||
full_content += chunk.content
|
||||
yield {'type': 'content', 'content': chunk.content}
|
||||
|
||||
if chunk.is_finished:
|
||||
prompt_tokens = chunk.prompt_tokens
|
||||
completion_tokens = chunk.completion_tokens
|
||||
total_tokens = chunk.total_tokens
|
||||
|
||||
latency = int((time.time() - start_time) * 1000)
|
||||
|
||||
# 更新助手消息
|
||||
assistant_message.content = full_content
|
||||
assistant_message.status = 'completed'
|
||||
assistant_message.prompt_tokens = prompt_tokens
|
||||
assistant_message.completion_tokens = completion_tokens
|
||||
assistant_message.total_tokens = total_tokens
|
||||
assistant_message.latency = latency
|
||||
|
||||
# 更新对话统计
|
||||
conversation.message_count = (conversation.message_count or 0) + 2
|
||||
conversation.total_tokens = (conversation.total_tokens or 0) + total_tokens
|
||||
|
||||
await self._db.commit()
|
||||
|
||||
yield {
|
||||
'type': 'done',
|
||||
'message': {
|
||||
'id': str(assistant_message.id),
|
||||
'content': full_content,
|
||||
'tokens': total_tokens,
|
||||
'latency': latency,
|
||||
},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f'流式发送消息失败: {e}')
|
||||
assistant_message.status = 'failed'
|
||||
assistant_message.error_message = str(e)
|
||||
await self._db.commit()
|
||||
yield {'type': 'error', 'error': str(e)}
|
||||
|
||||
async def _get_effective_model(self, conversation: Conversation, app: AIApp) -> Optional[LLMModel]:
|
||||
"""获取有效的模型"""
|
||||
model_id = conversation.model_override_id or app.model_id
|
||||
if not model_id:
|
||||
return None
|
||||
|
||||
result = await self._db.execute(
|
||||
select(LLMModel).where(
|
||||
LLMModel.id == model_id,
|
||||
LLMModel.is_active == True,
|
||||
LLMModel.is_deleted == False
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def _build_messages(
|
||||
self,
|
||||
conversation: Conversation,
|
||||
app: AIApp,
|
||||
user_content: str,
|
||||
) -> List[Dict[str, str]]:
|
||||
"""构建消息列表"""
|
||||
messages = []
|
||||
|
||||
# 系统提示词
|
||||
if app.system_prompt:
|
||||
messages.append({
|
||||
'role': 'system',
|
||||
'content': app.system_prompt,
|
||||
})
|
||||
|
||||
# 历史消息
|
||||
result = await self._db.execute(
|
||||
select(Message).where(
|
||||
Message.conversation_id == conversation.id,
|
||||
Message.is_deleted == False,
|
||||
Message.status == 'completed'
|
||||
).order_by(Message.sys_create_datetime.desc()).limit(20)
|
||||
)
|
||||
history = result.scalars().all()
|
||||
|
||||
for msg in reversed(list(history)):
|
||||
messages.append({
|
||||
'role': msg.role,
|
||||
'content': msg.content,
|
||||
})
|
||||
|
||||
# 当前用户消息
|
||||
messages.append({
|
||||
'role': 'user',
|
||||
'content': user_content,
|
||||
})
|
||||
|
||||
return messages
|
||||
|
||||
def _generate_title(self, conversation: Conversation, first_message: str):
|
||||
"""自动生成对话标题"""
|
||||
# 简单截取前 20 个字符作为标题
|
||||
title = first_message[:20]
|
||||
if len(first_message) > 20:
|
||||
title += '...'
|
||||
conversation.title = title
|
||||
@@ -0,0 +1,404 @@
|
||||
"""
|
||||
LLM 服务
|
||||
"""
|
||||
import logging
|
||||
from typing import AsyncGenerator, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ai_platform.providers import (
|
||||
BaseLLMProvider,
|
||||
LLMConfig,
|
||||
LLMMessage,
|
||||
LLMResponse,
|
||||
ProviderRegistry,
|
||||
)
|
||||
from ai_platform.providers.base import LLMStreamChunk, ToolDefinition
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LLMService:
|
||||
"""
|
||||
LLM 服务
|
||||
|
||||
统一管理 LLM 调用,支持多种提供商
|
||||
"""
|
||||
|
||||
def __init__(self, db: Optional[AsyncSession] = None):
|
||||
self._provider_cache: Dict[str, BaseLLMProvider] = {}
|
||||
self._db = db
|
||||
|
||||
async def _get_provider_async(self, model_id: str) -> tuple:
|
||||
"""
|
||||
异步获取模型对应的提供商实例
|
||||
|
||||
Args:
|
||||
model_id: 模型 ID
|
||||
|
||||
Returns:
|
||||
(provider, model_name)
|
||||
"""
|
||||
from ai_platform.models import LLMModel, LLMProvider
|
||||
|
||||
if not self._db:
|
||||
raise ValueError("数据库会话未初始化")
|
||||
|
||||
# 查询模型
|
||||
result = await self._db.execute(
|
||||
select(LLMModel).where(
|
||||
LLMModel.id == model_id,
|
||||
LLMModel.is_active == True,
|
||||
LLMModel.is_deleted == False
|
||||
)
|
||||
)
|
||||
model = result.scalar_one_or_none()
|
||||
if not model:
|
||||
raise ValueError(f'模型不存在或已禁用: {model_id}')
|
||||
|
||||
# 查询提供商
|
||||
provider_result = await self._db.execute(
|
||||
select(LLMProvider).where(LLMProvider.id == model.provider_id)
|
||||
)
|
||||
provider = provider_result.scalar_one_or_none()
|
||||
if not provider or not provider.is_active:
|
||||
raise ValueError(f'提供商不存在或已禁用')
|
||||
|
||||
# 缓存提供商实例
|
||||
cache_key = str(provider.id)
|
||||
if cache_key not in self._provider_cache:
|
||||
provider_instance = ProviderRegistry.create_instance(
|
||||
provider_type=provider.provider_type,
|
||||
api_key=provider.api_key,
|
||||
api_base=provider.api_base or "",
|
||||
ollama_host=provider.ollama_host or "",
|
||||
)
|
||||
if not provider_instance:
|
||||
raise ValueError(f'不支持的提供商类型: {provider.provider_type}')
|
||||
self._provider_cache[cache_key] = provider_instance
|
||||
|
||||
return self._provider_cache[cache_key], model.model_name
|
||||
|
||||
def _get_provider_sync(self, model_id: str, model_data: dict) -> tuple:
|
||||
"""
|
||||
同步获取提供商实例(使用预加载的数据)
|
||||
|
||||
Args:
|
||||
model_id: 模型 ID
|
||||
model_data: 预加载的模型和提供商数据
|
||||
|
||||
Returns:
|
||||
(provider, model_name)
|
||||
"""
|
||||
provider_type = model_data.get('provider_type')
|
||||
api_key = model_data.get('api_key', '')
|
||||
api_base = model_data.get('api_base', '')
|
||||
ollama_host = model_data.get('ollama_host', '')
|
||||
model_name = model_data.get('model_name', '')
|
||||
provider_id = model_data.get('provider_id', '')
|
||||
|
||||
# 缓存提供商实例
|
||||
cache_key = str(provider_id)
|
||||
if cache_key not in self._provider_cache:
|
||||
provider_instance = ProviderRegistry.create_instance(
|
||||
provider_type=provider_type,
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
ollama_host=ollama_host,
|
||||
)
|
||||
if not provider_instance:
|
||||
raise ValueError(f'不支持的提供商类型: {provider_type}')
|
||||
self._provider_cache[cache_key] = provider_instance
|
||||
|
||||
return self._provider_cache[cache_key], model_name
|
||||
|
||||
def chat_with_provider(
|
||||
self,
|
||||
provider: BaseLLMProvider,
|
||||
model_name: str,
|
||||
messages: List[Dict[str, str]],
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 2048,
|
||||
tools: List[Dict] = None,
|
||||
tool_choice: str = 'auto',
|
||||
**kwargs
|
||||
) -> LLMResponse:
|
||||
"""
|
||||
使用指定提供商进行同步对话
|
||||
|
||||
Args:
|
||||
provider: 提供商实例
|
||||
model_name: 模型名称
|
||||
messages: 消息列表 [{"role": "user", "content": "..."}]
|
||||
temperature: 温度参数
|
||||
max_tokens: 最大 Token
|
||||
tools: 工具定义列表 [{"name": "...", "description": "...", "parameters": {...}}]
|
||||
tool_choice: 工具选择策略 (auto, none, required, 或具体工具名)
|
||||
**kwargs: 其他参数
|
||||
|
||||
Returns:
|
||||
LLMResponse
|
||||
"""
|
||||
llm_messages = self._convert_messages(messages)
|
||||
|
||||
# 转换工具定义
|
||||
tool_definitions = None
|
||||
if tools:
|
||||
tool_definitions = [
|
||||
ToolDefinition(
|
||||
name=t['name'],
|
||||
description=t.get('description', ''),
|
||||
parameters=t.get('parameters', {}),
|
||||
)
|
||||
for t in tools
|
||||
]
|
||||
|
||||
config = LLMConfig(
|
||||
model=model_name,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
tools=tool_definitions,
|
||||
tool_choice=tool_choice,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
return provider.chat(llm_messages, config)
|
||||
|
||||
def _convert_messages(self, messages: List[Dict]) -> List[LLMMessage]:
|
||||
"""转换消息格式,支持 tool 消息"""
|
||||
llm_messages = []
|
||||
for m in messages:
|
||||
msg = LLMMessage(
|
||||
role=m['role'],
|
||||
content=m.get('content', ''),
|
||||
name=m.get('name'),
|
||||
tool_calls=m.get('tool_calls'),
|
||||
tool_call_id=m.get('tool_call_id'),
|
||||
)
|
||||
llm_messages.append(msg)
|
||||
return llm_messages
|
||||
|
||||
async def chat_async(
|
||||
self,
|
||||
model_id: str,
|
||||
messages: List[Dict[str, str]],
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 2048,
|
||||
tools: List[Dict] = None,
|
||||
tool_choice: str = 'auto',
|
||||
**kwargs
|
||||
) -> LLMResponse:
|
||||
"""
|
||||
异步对话
|
||||
|
||||
Args:
|
||||
model_id: 模型 ID
|
||||
messages: 消息列表
|
||||
temperature: 温度参数
|
||||
max_tokens: 最大 Token
|
||||
tools: 工具定义列表
|
||||
tool_choice: 工具选择策略
|
||||
**kwargs: 其他参数
|
||||
|
||||
Returns:
|
||||
LLMResponse
|
||||
"""
|
||||
provider, model_name = await self._get_provider_async(model_id)
|
||||
|
||||
llm_messages = self._convert_messages(messages)
|
||||
|
||||
# 转换工具定义
|
||||
tool_definitions = None
|
||||
if tools:
|
||||
tool_definitions = [
|
||||
ToolDefinition(
|
||||
name=t['name'],
|
||||
description=t.get('description', ''),
|
||||
parameters=t.get('parameters', {}),
|
||||
)
|
||||
for t in tools
|
||||
]
|
||||
|
||||
config = LLMConfig(
|
||||
model=model_name,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
tools=tool_definitions,
|
||||
tool_choice=tool_choice,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
return await provider.chat_async(llm_messages, config)
|
||||
|
||||
async def chat_stream(
|
||||
self,
|
||||
model_id: str,
|
||||
messages: List[Dict[str, str]],
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 2048,
|
||||
tools: List[Dict] = None,
|
||||
tool_choice: str = 'auto',
|
||||
**kwargs
|
||||
) -> AsyncGenerator[LLMStreamChunk, None]:
|
||||
"""
|
||||
异步流式对话
|
||||
|
||||
Args:
|
||||
model_id: 模型 ID
|
||||
messages: 消息列表
|
||||
temperature: 温度参数
|
||||
max_tokens: 最大 Token
|
||||
tools: 工具定义列表
|
||||
tool_choice: 工具选择策略
|
||||
**kwargs: 其他参数
|
||||
|
||||
Yields:
|
||||
LLMStreamChunk
|
||||
"""
|
||||
provider, model_name = await self._get_provider_async(model_id)
|
||||
|
||||
llm_messages = self._convert_messages(messages)
|
||||
|
||||
# 转换工具定义
|
||||
tool_definitions = None
|
||||
if tools:
|
||||
tool_definitions = [
|
||||
ToolDefinition(
|
||||
name=t['name'],
|
||||
description=t.get('description', ''),
|
||||
parameters=t.get('parameters', {}),
|
||||
)
|
||||
for t in tools
|
||||
]
|
||||
|
||||
config = LLMConfig(
|
||||
model=model_name,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
tools=tool_definitions,
|
||||
tool_choice=tool_choice,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
async for chunk in provider.chat_stream(llm_messages, config):
|
||||
yield chunk
|
||||
|
||||
def chat_stream_sync(
|
||||
self,
|
||||
model_id: str,
|
||||
messages: List[Dict[str, str]],
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 2048,
|
||||
tools: List[Dict] = None,
|
||||
tool_choice: str = 'auto',
|
||||
**kwargs
|
||||
):
|
||||
"""
|
||||
同步流式对话(生成器)
|
||||
|
||||
注意:此方法会在内部运行异步代码来获取 provider,
|
||||
需要确保 LLMService 初始化时传入了 db_session
|
||||
|
||||
Args:
|
||||
model_id: 模型 ID
|
||||
messages: 消息列表
|
||||
temperature: 温度参数
|
||||
max_tokens: 最大 Token
|
||||
tools: 工具定义列表
|
||||
tool_choice: 工具选择策略
|
||||
**kwargs: 其他参数
|
||||
|
||||
Yields:
|
||||
LLMStreamChunk
|
||||
"""
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
|
||||
# 在同步上下文中运行异步代码获取 provider
|
||||
async def get_provider():
|
||||
return await self._get_provider_async(model_id)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
# 如果已有事件循环在运行,使用线程池
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
future = executor.submit(asyncio.run, get_provider())
|
||||
provider, model_name = future.result()
|
||||
else:
|
||||
provider, model_name = loop.run_until_complete(get_provider())
|
||||
|
||||
# 使用获取到的 provider 进行流式调用
|
||||
yield from self.chat_stream_sync_with_provider(
|
||||
provider=provider,
|
||||
model_name=model_name,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
tools=tools,
|
||||
tool_choice=tool_choice,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def chat_stream_sync_with_provider(
|
||||
self,
|
||||
provider: BaseLLMProvider,
|
||||
model_name: str,
|
||||
messages: List[Dict[str, str]],
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 2048,
|
||||
tools: List[Dict] = None,
|
||||
tool_choice: str = 'auto',
|
||||
**kwargs
|
||||
):
|
||||
"""
|
||||
使用指定提供商进行同步流式对话(生成器)
|
||||
|
||||
Args:
|
||||
provider: 提供商实例
|
||||
model_name: 模型名称
|
||||
messages: 消息列表
|
||||
temperature: 温度参数
|
||||
max_tokens: 最大 Token
|
||||
tools: 工具定义列表
|
||||
tool_choice: 工具选择策略
|
||||
**kwargs: 其他参数
|
||||
|
||||
Yields:
|
||||
LLMStreamChunk
|
||||
"""
|
||||
llm_messages = self._convert_messages(messages)
|
||||
|
||||
# 转换工具定义
|
||||
tool_definitions = None
|
||||
if tools:
|
||||
tool_definitions = [
|
||||
ToolDefinition(
|
||||
name=t['name'],
|
||||
description=t.get('description', ''),
|
||||
parameters=t.get('parameters', {}),
|
||||
)
|
||||
for t in tools
|
||||
]
|
||||
|
||||
config = LLMConfig(
|
||||
model=model_name,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
tools=tool_definitions,
|
||||
tool_choice=tool_choice,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
for chunk in provider.chat_stream_sync(llm_messages, config):
|
||||
yield chunk
|
||||
|
||||
@staticmethod
|
||||
def get_available_providers() -> List[Dict]:
|
||||
"""获取可用的提供商列表"""
|
||||
return ProviderRegistry.get_all_types()
|
||||
|
||||
@staticmethod
|
||||
def get_default_models(provider_type: str) -> List[Dict]:
|
||||
"""获取提供商的默认模型列表"""
|
||||
return ProviderRegistry.get_default_models(provider_type)
|
||||
@@ -0,0 +1,470 @@
|
||||
"""
|
||||
语音识别服务
|
||||
|
||||
支持:
|
||||
- 阿里云百炼 DashScope ASR(推荐)
|
||||
- OpenAI Whisper API
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import dashscope
|
||||
from dashscope.audio.asr import Recognition, RecognitionCallback, RecognitionResult
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from ai_platform.models import LLMProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SpeechService:
|
||||
"""
|
||||
语音识别服务
|
||||
|
||||
支持多种语音识别后端:
|
||||
- 阿里云百炼 DashScope(默认,推荐)
|
||||
- OpenAI Whisper API
|
||||
"""
|
||||
|
||||
def __init__(self, db: Optional[AsyncSession] = None):
|
||||
self._db = db
|
||||
# 延迟解析:优先从数据库获取,初始化时先从 settings 读取作为兜底(settings 已自动从环境变量读取)
|
||||
self.dashscope_api_key = getattr(settings, 'DASHSCOPE_API_KEY', None)
|
||||
self._resolved = False
|
||||
|
||||
async def _resolve_dashscope_api_key(self):
|
||||
"""
|
||||
异步解析 DashScope API Key
|
||||
|
||||
优先级:
|
||||
1. 数据库中 qwen 类型提供商的 API Key
|
||||
2. settings / 环境变量中的 DASHSCOPE_API_KEY(兜底)
|
||||
"""
|
||||
if self._resolved:
|
||||
return
|
||||
self._resolved = True
|
||||
|
||||
if not self._db:
|
||||
return
|
||||
|
||||
try:
|
||||
result = await self._db.execute(
|
||||
select(LLMProvider).where(
|
||||
LLMProvider.provider_type == 'qwen',
|
||||
LLMProvider.is_active == True,
|
||||
LLMProvider.is_deleted == False
|
||||
)
|
||||
)
|
||||
provider = result.scalar_one_or_none()
|
||||
if provider and provider.api_key:
|
||||
self.dashscope_api_key = provider.api_key
|
||||
logger.info(f"Speech: Using API key from database provider: {provider.name}")
|
||||
except Exception as e:
|
||||
logger.debug(f"Speech: Failed to get provider from database: {e}")
|
||||
|
||||
def transcribe(
|
||||
self,
|
||||
audio_file,
|
||||
language: str = 'zh',
|
||||
provider: str = 'dashscope',
|
||||
) -> dict:
|
||||
"""
|
||||
语音转文字
|
||||
|
||||
Args:
|
||||
audio_file: 音频文件(Django UploadedFile 或文件路径或 bytes)
|
||||
language: 语言代码,如 'zh', 'en'
|
||||
provider: 服务提供商,'dashscope' 或 'openai'
|
||||
|
||||
Returns:
|
||||
{
|
||||
'success': bool,
|
||||
'text': str,
|
||||
'duration': float,
|
||||
'error': str,
|
||||
}
|
||||
"""
|
||||
if provider == 'dashscope':
|
||||
return self._transcribe_dashscope(audio_file, language)
|
||||
elif provider == 'openai':
|
||||
return self._transcribe_openai(audio_file, language)
|
||||
else:
|
||||
return {
|
||||
'success': False,
|
||||
'text': '',
|
||||
'error': f'不支持的提供商: {provider}',
|
||||
}
|
||||
|
||||
def _transcribe_dashscope(self, audio_file, language: str) -> dict:
|
||||
"""
|
||||
使用阿里云百炼 DashScope 进行语音识别
|
||||
|
||||
使用 fun-asr-realtime 模型,支持多种音频格式
|
||||
"""
|
||||
try:
|
||||
if not self.dashscope_api_key:
|
||||
return {
|
||||
'success': False,
|
||||
'text': '',
|
||||
'error': '未配置 DASHSCOPE_API_KEY',
|
||||
}
|
||||
|
||||
# 设置 API Key
|
||||
dashscope.api_key = self.dashscope_api_key
|
||||
dashscope.base_websocket_api_url = 'wss://dashscope.aliyuncs.com/api-ws/v1/inference'
|
||||
|
||||
# 获取音频数据和格式
|
||||
audio_data, audio_format, sample_rate = self._prepare_audio(audio_file)
|
||||
|
||||
if not audio_data:
|
||||
return {
|
||||
'success': False,
|
||||
'text': '',
|
||||
'error': '无法读取音频文件',
|
||||
}
|
||||
|
||||
# 使用同步方式收集识别结果
|
||||
result_text = []
|
||||
error_message = []
|
||||
completed = threading.Event()
|
||||
|
||||
class SyncCallback(RecognitionCallback):
|
||||
def on_complete(self) -> None:
|
||||
completed.set()
|
||||
|
||||
def on_error(self, result: RecognitionResult) -> None:
|
||||
error_message.append(result.message if hasattr(result, 'message') else str(result))
|
||||
completed.set()
|
||||
|
||||
def on_event(self, result: RecognitionResult) -> None:
|
||||
sentence = result.get_sentence()
|
||||
if 'text' in sentence and RecognitionResult.is_sentence_end(sentence):
|
||||
result_text.append(sentence['text'])
|
||||
|
||||
callback = SyncCallback()
|
||||
|
||||
# 创建识别实例
|
||||
recognition = Recognition(
|
||||
model='paraformer-realtime-v2', # 使用 paraformer 模型,效果更好
|
||||
format=audio_format,
|
||||
sample_rate=sample_rate,
|
||||
callback=callback,
|
||||
)
|
||||
|
||||
# 开始识别
|
||||
recognition.start()
|
||||
|
||||
# 分块发送音频数据
|
||||
chunk_size = 3200 # 每次发送 3200 字节
|
||||
offset = 0
|
||||
while offset < len(audio_data):
|
||||
chunk = audio_data[offset:offset + chunk_size]
|
||||
recognition.send_audio_frame(chunk)
|
||||
offset += chunk_size
|
||||
time.sleep(0.05) # 稍微延迟,模拟实时流
|
||||
|
||||
# 停止识别
|
||||
recognition.stop()
|
||||
|
||||
# 等待完成(最多 30 秒)
|
||||
completed.wait(timeout=30)
|
||||
|
||||
if error_message:
|
||||
return {
|
||||
'success': False,
|
||||
'text': '',
|
||||
'error': error_message[0],
|
||||
}
|
||||
|
||||
final_text = ''.join(result_text)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'text': final_text,
|
||||
'duration': len(audio_data) / (sample_rate * 2), # 估算时长
|
||||
'error': '',
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f'阿里云语音识别失败: {e}')
|
||||
return {
|
||||
'success': False,
|
||||
'text': '',
|
||||
'error': str(e),
|
||||
}
|
||||
|
||||
def _prepare_audio(self, audio_file) -> tuple:
|
||||
"""
|
||||
准备音频数据
|
||||
|
||||
前端已直接录制 WAV 格式(16kHz, 16bit, 单声道),无需转换
|
||||
阿里云 DashScope ASR 支持的格式:pcm, wav, mp3, opus, speex, aac, amr
|
||||
|
||||
Returns:
|
||||
(audio_data: bytes, format: str, sample_rate: int)
|
||||
"""
|
||||
audio_data = None
|
||||
audio_format = 'wav'
|
||||
sample_rate = 16000
|
||||
|
||||
if isinstance(audio_file, bytes):
|
||||
audio_data = audio_file
|
||||
elif hasattr(audio_file, 'read'):
|
||||
# Django UploadedFile
|
||||
audio_data = audio_file.read()
|
||||
file_name = getattr(audio_file, 'name', 'audio.wav').lower()
|
||||
|
||||
# 根据文件名判断格式
|
||||
if file_name.endswith('.mp3'):
|
||||
audio_format = 'mp3'
|
||||
elif file_name.endswith('.pcm'):
|
||||
audio_format = 'pcm'
|
||||
elif file_name.endswith('.opus'):
|
||||
audio_format = 'opus'
|
||||
else:
|
||||
audio_format = 'wav'
|
||||
elif isinstance(audio_file, (str, Path)):
|
||||
# 文件路径
|
||||
file_path = Path(audio_file)
|
||||
with open(file_path, 'rb') as f:
|
||||
audio_data = f.read()
|
||||
|
||||
suffix = file_path.suffix.lower()
|
||||
if suffix == '.mp3':
|
||||
audio_format = 'mp3'
|
||||
elif suffix == '.pcm':
|
||||
audio_format = 'pcm'
|
||||
elif suffix == '.opus':
|
||||
audio_format = 'opus'
|
||||
else:
|
||||
audio_format = 'wav'
|
||||
|
||||
return audio_data, audio_format, sample_rate
|
||||
|
||||
def _transcribe_openai(self, audio_file, language: str, provider_config: dict = None) -> dict:
|
||||
"""使用 OpenAI Whisper API 进行语音识别"""
|
||||
try:
|
||||
import openai
|
||||
|
||||
if not provider_config:
|
||||
return {
|
||||
'success': False,
|
||||
'text': '',
|
||||
'error': '未配置 OpenAI 提供商',
|
||||
}
|
||||
|
||||
# 创建客户端
|
||||
client = openai.OpenAI(
|
||||
api_key=provider_config.get('api_key', ''),
|
||||
base_url=provider_config.get('api_base') or None,
|
||||
)
|
||||
|
||||
# 处理文件
|
||||
if isinstance(audio_file, bytes):
|
||||
# bytes 数据,写入临时文件
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix='.wav',
|
||||
delete=False,
|
||||
) as tmp:
|
||||
tmp.write(audio_file)
|
||||
tmp_path = tmp.name
|
||||
|
||||
try:
|
||||
with open(tmp_path, 'rb') as f:
|
||||
response = client.audio.transcriptions.create(
|
||||
model='whisper-1',
|
||||
file=f,
|
||||
language=language,
|
||||
response_format='verbose_json',
|
||||
)
|
||||
finally:
|
||||
Path(tmp_path).unlink(missing_ok=True)
|
||||
elif hasattr(audio_file, 'read'):
|
||||
# UploadedFile
|
||||
file_content = audio_file.read()
|
||||
file_name = getattr(audio_file, 'name', 'audio.webm')
|
||||
|
||||
# 写入临时文件
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix=Path(file_name).suffix or '.webm',
|
||||
delete=False,
|
||||
) as tmp:
|
||||
tmp.write(file_content)
|
||||
tmp_path = tmp.name
|
||||
|
||||
try:
|
||||
with open(tmp_path, 'rb') as f:
|
||||
response = client.audio.transcriptions.create(
|
||||
model='whisper-1',
|
||||
file=f,
|
||||
language=language,
|
||||
response_format='verbose_json',
|
||||
)
|
||||
finally:
|
||||
Path(tmp_path).unlink(missing_ok=True)
|
||||
else:
|
||||
with open(audio_file, 'rb') as f:
|
||||
response = client.audio.transcriptions.create(
|
||||
model='whisper-1',
|
||||
file=f,
|
||||
language=language,
|
||||
response_format='verbose_json',
|
||||
)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'text': response.text,
|
||||
'duration': getattr(response, 'duration', 0),
|
||||
'error': '',
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f'OpenAI 语音识别失败: {e}')
|
||||
return {
|
||||
'success': False,
|
||||
'text': '',
|
||||
'error': str(e),
|
||||
}
|
||||
|
||||
def text_to_speech(
|
||||
self,
|
||||
text: str,
|
||||
voice: str = 'sambert-zhichu-v1',
|
||||
provider: str = 'dashscope',
|
||||
) -> dict:
|
||||
"""
|
||||
文字转语音(TTS)
|
||||
|
||||
Args:
|
||||
text: 要转换的文字
|
||||
voice: 声音类型
|
||||
provider: 服务提供商
|
||||
|
||||
Returns:
|
||||
{
|
||||
'success': bool,
|
||||
'audio_data': bytes,
|
||||
'content_type': str,
|
||||
'error': str,
|
||||
}
|
||||
"""
|
||||
if provider == 'dashscope':
|
||||
return self._tts_dashscope(text, voice)
|
||||
elif provider == 'openai':
|
||||
return self._tts_openai(text, voice)
|
||||
else:
|
||||
return {
|
||||
'success': False,
|
||||
'audio_data': b'',
|
||||
'error': f'不支持的提供商: {provider}',
|
||||
}
|
||||
|
||||
def _tts_dashscope(self, text: str, voice: str) -> dict:
|
||||
"""使用阿里云百炼 TTS"""
|
||||
try:
|
||||
from dashscope.audio.tts import SpeechSynthesizer
|
||||
|
||||
if not self.dashscope_api_key:
|
||||
return {
|
||||
'success': False,
|
||||
'audio_data': b'',
|
||||
'error': '未配置 DASHSCOPE_API_KEY',
|
||||
}
|
||||
|
||||
dashscope.api_key = self.dashscope_api_key
|
||||
|
||||
# 调用 TTS
|
||||
result = SpeechSynthesizer.call(
|
||||
model=voice, # sambert-zhichu-v1, sambert-zhimiao-emo-v1 等
|
||||
text=text,
|
||||
sample_rate=16000,
|
||||
format='wav',
|
||||
)
|
||||
|
||||
if result.get_audio_data():
|
||||
return {
|
||||
'success': True,
|
||||
'audio_data': result.get_audio_data(),
|
||||
'content_type': 'audio/wav',
|
||||
'error': '',
|
||||
}
|
||||
else:
|
||||
return {
|
||||
'success': False,
|
||||
'audio_data': b'',
|
||||
'error': '语音合成失败',
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f'阿里云 TTS 失败: {e}')
|
||||
return {
|
||||
'success': False,
|
||||
'audio_data': b'',
|
||||
'error': str(e),
|
||||
}
|
||||
|
||||
def _tts_openai(self, text: str, voice: str, provider_config: dict = None) -> dict:
|
||||
"""使用 OpenAI TTS API"""
|
||||
try:
|
||||
import openai
|
||||
|
||||
if not provider_config:
|
||||
return {
|
||||
'success': False,
|
||||
'audio_data': b'',
|
||||
'error': '未配置 OpenAI 提供商',
|
||||
}
|
||||
|
||||
client = openai.OpenAI(
|
||||
api_key=provider_config.get('api_key', ''),
|
||||
base_url=provider_config.get('api_base') or None,
|
||||
)
|
||||
|
||||
response = client.audio.speech.create(
|
||||
model='tts-1',
|
||||
voice=voice, # alloy, echo, fable, onyx, nova, shimmer
|
||||
input=text,
|
||||
)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'audio_data': response.content,
|
||||
'content_type': 'audio/mpeg',
|
||||
'error': '',
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f'OpenAI TTS 失败: {e}')
|
||||
return {
|
||||
'success': False,
|
||||
'audio_data': b'',
|
||||
'error': str(e),
|
||||
}
|
||||
|
||||
async def get_openai_provider_config(self) -> dict:
|
||||
"""异步获取OpenAI提供商配置"""
|
||||
if not self._db:
|
||||
return {}
|
||||
|
||||
result = await self._db.execute(
|
||||
select(LLMProvider).where(
|
||||
LLMProvider.provider_type == 'openai',
|
||||
LLMProvider.is_active == True,
|
||||
LLMProvider.is_deleted == False
|
||||
)
|
||||
)
|
||||
provider = result.scalar_one_or_none()
|
||||
if not provider:
|
||||
return {}
|
||||
|
||||
return {
|
||||
'api_key': provider.api_key,
|
||||
'api_base': provider.api_base,
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
AI 工作流导入/导出
|
||||
"""
|
||||
import copy
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ai_platform.models import AIWorkflow
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WorkflowImportExportException(Exception):
|
||||
"""工作流导入导出异常"""
|
||||
pass
|
||||
|
||||
|
||||
def _collect_subflow_codes_from_definition(definition: Dict[str, Any]) -> Set[str]:
|
||||
codes: Set[str] = set()
|
||||
for node in definition.get("nodes") or []:
|
||||
data = node.get("data") or {}
|
||||
code = data.get("subflow_code")
|
||||
if code:
|
||||
codes.add(code)
|
||||
return codes
|
||||
|
||||
|
||||
def _collect_subflow_ids_from_definition(definition: Dict[str, Any]) -> Set[str]:
|
||||
ids: Set[str] = set()
|
||||
for node in definition.get("nodes") or []:
|
||||
data = node.get("data") or {}
|
||||
sid = data.get("subflow_id")
|
||||
if sid:
|
||||
ids.add(sid)
|
||||
return ids
|
||||
|
||||
|
||||
def _definition_export_subflow_ids_to_codes(
|
||||
definition: Dict[str, Any],
|
||||
id_to_code: Dict[str, str],
|
||||
) -> Dict[str, Any]:
|
||||
"""导出时将子流程节点 subflow_id 转为 subflow_code"""
|
||||
result = copy.deepcopy(definition or {})
|
||||
for node in result.get("nodes") or []:
|
||||
data = node.get("data")
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
sid = data.get("subflow_id")
|
||||
if sid and sid in id_to_code:
|
||||
data["subflow_code"] = id_to_code[sid]
|
||||
data.pop("subflow_id", None)
|
||||
return result
|
||||
|
||||
|
||||
def _definition_import_subflow_codes_to_ids(
|
||||
definition: Dict[str, Any],
|
||||
code_to_id: Dict[str, str],
|
||||
) -> tuple[Dict[str, Any], List[str]]:
|
||||
"""导入时将 subflow_code 解析为 subflow_id"""
|
||||
result = copy.deepcopy(definition or {})
|
||||
unresolved: List[str] = []
|
||||
for node in result.get("nodes") or []:
|
||||
data = node.get("data")
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
code = data.pop("subflow_code", None)
|
||||
if not code:
|
||||
continue
|
||||
wf_id = code_to_id.get(code)
|
||||
if wf_id:
|
||||
data["subflow_id"] = wf_id
|
||||
else:
|
||||
unresolved.append(code)
|
||||
return result, unresolved
|
||||
|
||||
|
||||
async def export_config(db: AsyncSession, workflow_id: str) -> Dict[str, Any]:
|
||||
"""导出工作流配置(草稿 definition,子流程引用使用 code)"""
|
||||
result = await db.execute(
|
||||
select(AIWorkflow).where(
|
||||
AIWorkflow.id == workflow_id,
|
||||
AIWorkflow.is_deleted == False,
|
||||
)
|
||||
)
|
||||
workflow = result.scalar_one_or_none()
|
||||
if not workflow:
|
||||
raise WorkflowImportExportException("工作流不存在")
|
||||
|
||||
definition = copy.deepcopy(workflow.definition or {})
|
||||
subflow_ids = _collect_subflow_ids_from_definition(definition)
|
||||
|
||||
id_to_code: Dict[str, str] = {}
|
||||
if subflow_ids:
|
||||
rows = await db.execute(
|
||||
select(AIWorkflow.id, AIWorkflow.code).where(
|
||||
AIWorkflow.id.in_(subflow_ids),
|
||||
AIWorkflow.is_deleted == False,
|
||||
)
|
||||
)
|
||||
id_to_code = {row.id: row.code for row in rows}
|
||||
|
||||
definition = _definition_export_subflow_ids_to_codes(definition, id_to_code)
|
||||
|
||||
return {
|
||||
"name": workflow.name,
|
||||
"code": workflow.code,
|
||||
"workflow_type": workflow.workflow_type or "general",
|
||||
"description": workflow.description or "",
|
||||
"is_global": workflow.is_global or False,
|
||||
"definition": definition,
|
||||
"input_variables": workflow.input_variables or [],
|
||||
"output_variables": workflow.output_variables or [],
|
||||
}
|
||||
|
||||
|
||||
async def check_import(db: AsyncSession, code: str) -> Dict[str, Any]:
|
||||
"""导入预检查:编码是否冲突"""
|
||||
code_exists = False
|
||||
if code:
|
||||
result = await db.execute(
|
||||
select(AIWorkflow).where(
|
||||
AIWorkflow.code == code,
|
||||
AIWorkflow.is_deleted == False,
|
||||
)
|
||||
)
|
||||
code_exists = result.scalar_one_or_none() is not None
|
||||
|
||||
return {
|
||||
"code_exists": code_exists,
|
||||
"can_import": not code_exists,
|
||||
}
|
||||
|
||||
|
||||
async def import_config(db: AsyncSession, data: Dict[str, Any]) -> AIWorkflow:
|
||||
"""导入工作流配置"""
|
||||
required_fields = ["name", "code"]
|
||||
for field in required_fields:
|
||||
if not data.get(field):
|
||||
raise WorkflowImportExportException(f"缺少必要字段: {field}")
|
||||
|
||||
exists = await db.execute(
|
||||
select(AIWorkflow).where(
|
||||
AIWorkflow.code == data["code"],
|
||||
AIWorkflow.is_deleted == False,
|
||||
)
|
||||
)
|
||||
if exists.scalar_one_or_none():
|
||||
raise WorkflowImportExportException(f"工作流编码已存在: {data['code']}")
|
||||
|
||||
definition = copy.deepcopy(data.get("definition") or {})
|
||||
subflow_codes = _collect_subflow_codes_from_definition(definition)
|
||||
|
||||
code_to_id: Dict[str, str] = {}
|
||||
if subflow_codes:
|
||||
rows = await db.execute(
|
||||
select(AIWorkflow.id, AIWorkflow.code).where(
|
||||
AIWorkflow.code.in_(subflow_codes),
|
||||
AIWorkflow.is_deleted == False,
|
||||
)
|
||||
)
|
||||
code_to_id = {row.code: row.id for row in rows}
|
||||
|
||||
definition, unresolved = _definition_import_subflow_codes_to_ids(
|
||||
definition, code_to_id
|
||||
)
|
||||
if unresolved:
|
||||
logger.warning(
|
||||
"导入工作流时部分子流程未找到,已跳过引用: %s",
|
||||
", ".join(unresolved),
|
||||
)
|
||||
|
||||
workflow = AIWorkflow(
|
||||
application_id=data.get("application_id"),
|
||||
is_global=data.get("is_global", False),
|
||||
name=data["name"],
|
||||
code=data["code"],
|
||||
workflow_type=data.get("workflow_type", "general"),
|
||||
description=data.get("description", ""),
|
||||
definition=definition,
|
||||
input_variables=data.get("input_variables") or [],
|
||||
output_variables=data.get("output_variables") or [],
|
||||
status="draft",
|
||||
version=1,
|
||||
published_version=None,
|
||||
published_at=None,
|
||||
published_definition=None,
|
||||
)
|
||||
db.add(workflow)
|
||||
await db.flush()
|
||||
await db.refresh(workflow)
|
||||
|
||||
logger.info("工作流导入成功: %s", workflow.code)
|
||||
return workflow
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user