feat: Implement AI Agent with enhanced tool processing capabilities (#89)

* feat: Implement AI Agent with tool processing capabilities

- Added tools for listing and running processors in the agent.
- Created data models for agent chat requests and tool calls.
- Developed API integration for agent chat and streaming responses.
- Built the AI Agent widget with a user interface for interaction.
- Styled the agent components for better user experience.

* feat: 增强 AI 助手工具功能,添加文件操作和搜索功能,更新界面显示

* feat: 更新 AI 助手组件

* feat: 更新 AiAgentWidget 组件样式,调整背景和边距以提升界面一致性
This commit is contained in:
时雨
2026-01-09 16:19:20 +08:00
committed by GitHub
parent 4638356a45
commit a727e77341
14 changed files with 2511 additions and 7 deletions

View File

@@ -1,5 +1,7 @@
import json
import httpx
from typing import List, Sequence, Tuple
from typing import Any, AsyncIterator, Dict, List, Sequence, Tuple
from models.database import AIModel, AIProvider
from domain.ai.service import AIProviderService
@@ -243,3 +245,195 @@ async def _rerank_with_gemini(
except (TypeError, ValueError):
scores.append(0.0)
return scores
async def chat_completion(
messages: List[Dict[str, Any]],
*,
ability: str = "chat",
tools: List[Dict[str, Any]] | None = None,
tool_choice: Any | None = None,
temperature: float | None = None,
timeout: float = 60.0,
) -> Dict[str, Any]:
model, provider = await _require_model(ability)
if provider.api_format != "openai":
raise MissingModelError("当前仅支持 OpenAI 兼容接口的对话模型。")
return await _chat_with_openai(
provider,
model,
messages,
tools=tools,
tool_choice=tool_choice,
temperature=temperature,
timeout=timeout,
)
async def _chat_with_openai(
provider: AIProvider,
model: AIModel,
messages: List[Dict[str, Any]],
*,
tools: List[Dict[str, Any]] | None,
tool_choice: Any | None,
temperature: float | None,
timeout: float,
) -> Dict[str, Any]:
url = _openai_endpoint(provider, "/chat/completions")
payload: Dict[str, Any] = {
"model": model.name,
"messages": messages,
}
if tools:
payload["tools"] = tools
payload["tool_choice"] = tool_choice or "auto"
if temperature is not None:
payload["temperature"] = float(temperature)
async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.post(url, headers=_openai_headers(provider), json=payload)
response.raise_for_status()
body = response.json()
choices = body.get("choices") or []
if not choices:
raise RuntimeError("对话接口返回为空")
message = choices[0].get("message")
if not isinstance(message, dict):
raise RuntimeError("对话接口返回格式异常")
return message
async def chat_completion_stream(
messages: List[Dict[str, Any]],
*,
ability: str = "chat",
tools: List[Dict[str, Any]] | None = None,
tool_choice: Any | None = None,
temperature: float | None = None,
timeout: float = 60.0,
) -> AsyncIterator[Dict[str, Any]]:
model, provider = await _require_model(ability)
if provider.api_format != "openai":
raise MissingModelError("当前仅支持 OpenAI 兼容接口的对话模型。")
async for event in _chat_stream_with_openai(
provider,
model,
messages,
tools=tools,
tool_choice=tool_choice,
temperature=temperature,
timeout=timeout,
):
yield event
async def _chat_stream_with_openai(
provider: AIProvider,
model: AIModel,
messages: List[Dict[str, Any]],
*,
tools: List[Dict[str, Any]] | None,
tool_choice: Any | None,
temperature: float | None,
timeout: float,
) -> AsyncIterator[Dict[str, Any]]:
url = _openai_endpoint(provider, "/chat/completions")
payload: Dict[str, Any] = {
"model": model.name,
"messages": messages,
"stream": True,
}
if tools:
payload["tools"] = tools
payload["tool_choice"] = tool_choice or "auto"
if temperature is not None:
payload["temperature"] = float(temperature)
content_parts: List[str] = []
tool_call_map: Dict[int, Dict[str, Any]] = {}
role = "assistant"
finish_reason: str | None = None
async with httpx.AsyncClient(timeout=timeout) as client:
async with client.stream("POST", url, headers=_openai_headers(provider), json=payload) as response:
response.raise_for_status()
async for line in response.aiter_lines():
if not line:
continue
if not line.startswith("data:"):
continue
data = line[5:].strip()
if not data:
continue
if data == "[DONE]":
break
try:
chunk = json.loads(data)
except json.JSONDecodeError:
continue
choices = chunk.get("choices") or []
if not choices:
continue
choice = choices[0] if isinstance(choices[0], dict) else {}
delta = choice.get("delta") if isinstance(choice, dict) else None
delta = delta if isinstance(delta, dict) else {}
if isinstance(delta.get("role"), str):
role = delta["role"]
delta_content = delta.get("content")
if isinstance(delta_content, str) and delta_content:
content_parts.append(delta_content)
yield {"type": "delta", "delta": delta_content}
delta_tool_calls = delta.get("tool_calls")
if isinstance(delta_tool_calls, list):
for item in delta_tool_calls:
if not isinstance(item, dict):
continue
idx = item.get("index")
if not isinstance(idx, int):
continue
entry = tool_call_map.setdefault(
idx,
{"id": None, "type": None, "function": {"name": None, "arguments": ""}},
)
if isinstance(item.get("id"), str) and item["id"].strip():
entry["id"] = item["id"]
if isinstance(item.get("type"), str) and item["type"].strip():
entry["type"] = item["type"]
fn = item.get("function")
if isinstance(fn, dict):
if isinstance(fn.get("name"), str) and fn["name"].strip():
entry["function"]["name"] = fn["name"]
args_part = fn.get("arguments")
if isinstance(args_part, str) and args_part:
entry["function"]["arguments"] += args_part
fr = choice.get("finish_reason") if isinstance(choice, dict) else None
if isinstance(fr, str) and fr:
finish_reason = fr
content = "".join(content_parts)
message: Dict[str, Any] = {"role": role, "content": content}
if tool_call_map:
tool_calls: List[Dict[str, Any]] = []
for idx in sorted(tool_call_map.keys()):
item = tool_call_map[idx]
fn = item.get("function") if isinstance(item.get("function"), dict) else {}
call_id = item.get("id") if isinstance(item.get("id"), str) and item.get("id") else f"call_{idx}"
call_type = item.get("type") if isinstance(item.get("type"), str) and item.get("type") else "function"
tool_calls.append({
"id": call_id,
"type": call_type,
"function": {
"name": fn.get("name") or "",
"arguments": fn.get("arguments") or "",
},
})
message["tool_calls"] = tool_calls
yield {"type": "message", "message": message, "finish_reason": finish_reason}