Build lightweight AI agent admin

This commit is contained in:
Codex
2026-06-08 18:14:59 +08:00
commit e164840f43
2530 changed files with 435693 additions and 0 deletions
@@ -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