Files
EPEEAIKit/art-agent/backend/app/agent/loop.py

228 lines
9.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Agent Loop 主循环LLM 对话 -> 工具调用 -> 结果回传 -> 继续。"""
import json
import os
from typing import AsyncGenerator, Optional
from openai import AsyncOpenAI
from app.agent.tools import TOOL_DEFINITIONS, execute_tool
from app.config import get_llm_model, get_llm_max_iterations
from app.services.image_gen import to_data_uri
def _get_client() -> AsyncOpenAI:
base_url = os.getenv("OPENAI_BASE_URL")
return AsyncOpenAI(base_url=base_url) if base_url else AsyncOpenAI()
SYSTEM_PROMPT = """\
你是一个专业的游戏美术 AI 助手。你的工作是帮助美术人员通过对话生成游戏美术资源。
## 你的能力
- 根据用户的文字描述生成图片UI图标、按钮、插画、立绘、概念图等
- 理解用户的审美意图,将中文描述转化为高质量的英文生成 prompt
- 根据用户反馈迭代修改(调整颜色、风格、构图等)
- 如果用户提供了参考图,将参考图的风格元素融入生成 prompt
- 理解用户在图片上的标注(框选区域 + 文字批注),精准定位需要修改的部分
## 工作流程
1. 理解用户需求,必要时追问细节(尺寸、风格、用途等)
2. 将需求转化为详细的英文 prompt调用 generate_image 工具生成图片
3. 向用户展示结果并询问反馈
4. 根据反馈调整 prompt 并重新生成
## 标注理解
当用户发送带有「图片标注」的消息时,表示用户在之前生成的图片上做了标注。
标注格式为:`[区域 (x%, y%) 大小 w%×h%]: 修改意见`
- 区域坐标表示标注框在图片上的相对位置
- 你需要理解标注区域所指的图片内容,并将修改意见融入新的 prompt
- 如果附带了标注截图(参考图),仔细观察红色标注框和文字来理解用户意图
- 在调整 prompt 时,保持原图整体风格不变,只针对标注区域做修改
## 生成 prompt 要求
- 必须使用英文
- 尽量详细描述:主体内容、颜色方案、光照、构图、材质等
- 如果用户要求游戏 UI 元素,添加相关关键词如 "game UI", "icon", "button"
- 如果你能直接看到参考图(图片内容),可以在 prompt 中描述参考图的风格特征
- 如果你无法看到参考图(只收到了文字提示说有参考图),参考图会由生图工具的 IP-Adapter 自动处理风格融合。此时你的 prompt 中**绝对不要猜测或指定任何画风/艺术风格关键词**(如 pixel art、watercolor、oil painting 等),只描述画面内容(主体、构图、光照等),把风格完全交给参考图来决定
## 注意事项
- 用中文和用户交流
- 生成图片后简要说明你使用的 prompt 思路
- 主动建议迭代方向
"""
async def run_agent_loop(
messages: list[dict],
ref_image_url: Optional[str] = None,
image_model: Optional[str] = None,
) -> AsyncGenerator[dict, None]:
"""
运行 Agent Loop以 SSE 事件流形式 yield 结果。
事件类型:
- text_delta: LLM 文字增量
- tool_start: 开始执行工具
- image_result: 图片生成结果
- done: 完成
- error: 错误
"""
# 构建消息列表
api_messages = [{"role": "system", "content": SYSTEM_PROMPT}]
# 检测当前 LLM 是否支持 vision多模态图片输入
llm_model = get_llm_model().lower()
vision_capable = any(kw in llm_model for kw in ("gpt-4o", "gpt-4-vision", "claude"))
for msg in messages:
if msg["role"] == "user" and ref_image_url and msg is messages[-1]:
if vision_capable:
# 支持 vision 的模型:直接发送图片
api_messages.append({
"role": "user",
"content": [
{"type": "text", "text": msg["content"]},
{
"type": "image_url",
"image_url": {"url": to_data_uri(ref_image_url)},
},
],
})
else:
# 不支持 vision 的模型:以文字提示告知有参考图,风格由 IP-Adapter 处理
hint = (
f"{msg['content']}\n\n"
"【系统提示:用户上传了一张参考图,已自动传递给图片生成工具的 IP-Adapter。"
"IP-Adapter 会从参考图中提取风格并融合到生成结果中。"
"你无法看到这张参考图,因此在生成 prompt 时:\n"
"1. 只描述画面内容(主体、构图、光照、材质等)\n"
"2. 不要猜测或添加任何画风/艺术风格关键词(如 pixel art、watercolor、cartoon 等)\n"
"3. 风格完全由参考图通过 IP-Adapter 决定】"
)
api_messages.append({"role": "user", "content": hint})
continue
api_messages.append({"role": msg["role"], "content": msg["content"]})
client = _get_client()
for _ in range(get_llm_max_iterations()):
try:
response = await client.chat.completions.create(
model=get_llm_model(),
messages=api_messages,
tools=TOOL_DEFINITIONS,
stream=True,
)
except Exception as e:
yield {"type": "error", "data": {"message": str(e)}}
return
collected_text = ""
tool_calls_data: dict[int, dict] = {}
async for chunk in response:
delta = chunk.choices[0].delta if chunk.choices else None
if not delta:
continue
# 文字内容
if delta.content:
collected_text += delta.content
yield {"type": "text_delta", "data": {"text": delta.content}}
# 工具调用(流式累积)
if delta.tool_calls:
for tc in delta.tool_calls:
idx = tc.index
if idx not in tool_calls_data:
tool_calls_data[idx] = {
"id": "",
"name": "",
"arguments": "",
}
if tc.id:
tool_calls_data[idx]["id"] = tc.id
if tc.function and tc.function.name:
tool_calls_data[idx]["name"] = tc.function.name
if tc.function and tc.function.arguments:
tool_calls_data[idx]["arguments"] += tc.function.arguments
finish_reason = chunk.choices[0].finish_reason if chunk.choices else None
# 如果没有工具调用,对话结束
if not tool_calls_data:
yield {"type": "done", "data": {}}
return
# 将 assistant 消息(含 tool_calls加入历史
assistant_msg: dict = {"role": "assistant"}
if collected_text:
assistant_msg["content"] = collected_text
else:
assistant_msg["content"] = None
assistant_msg["tool_calls"] = []
for idx in sorted(tool_calls_data.keys()):
tc_data = tool_calls_data[idx]
assistant_msg["tool_calls"].append({
"id": tc_data["id"],
"type": "function",
"function": {
"name": tc_data["name"],
"arguments": tc_data["arguments"],
},
})
api_messages.append(assistant_msg)
# 依次执行每个工具调用
for idx in sorted(tool_calls_data.keys()):
tc_data = tool_calls_data[idx]
tool_name = tc_data["name"]
yield {
"type": "tool_start",
"data": {"tool": tool_name, "message": "正在生成图片..."},
}
try:
arguments = json.loads(tc_data["arguments"])
except json.JSONDecodeError:
arguments = {}
result = await execute_tool(tool_name, arguments, ref_image_url, image_model)
used_model = result.get("model_name", "")
if result.get("errors"):
yield {
"type": "tool_error",
"data": {
"tool": tool_name,
"errors": result["errors"],
"model_name": used_model,
},
}
if result.get("images"):
yield {
"type": "image_result",
"data": {
"images": result["images"],
"prompt_used": result.get("prompt_used", ""),
"model_name": used_model,
},
}
# 工具结果回传给 LLM
api_messages.append({
"role": "tool",
"tool_call_id": tc_data["id"],
"content": json.dumps(result, ensure_ascii=False),
})
# ref_image_url 不清除:消息构建只在循环外执行一次,
# 后续工具调用仍需参考图InstantStyle 等模型必须有 style_image
yield {"type": "done", "data": {}}