feat: image attachments via multimodal messages for vision models
- Backend: ChatRequest.attachments; image files read as base64 data URLs (8MB cap) and passed to run/run_stream as image_urls; last user message converted to multimodal parts only on the LLM-bound copy (history stays text); vision whitelist (kimi-k3, gpt-4o, claude, etc.), non-vision models keep text/OCR path - Frontend: send attachments metadata with chat body (also fixes attachment context never being sent — body now uses fullText); user bubble shows image thumbnails
This commit is contained in:
@@ -175,6 +175,9 @@ class AgentRuntime:
|
||||
self._streamlined_transformer = create_streamlined_transformer(enabled=True)
|
||||
logger.info("工具结果流式美化已启用")
|
||||
|
||||
# 本轮用户消息附带的图片(多模态),run()/run_stream() 调用时注入
|
||||
self._turn_image_urls: List[str] = []
|
||||
|
||||
# 系统提示词分层装配 (P2 — 参考 Claude Code systemPromptSections.ts)
|
||||
self._prompt_composer: Optional[PromptComposer] = None
|
||||
self._prompt_sections_enabled = self.config.prompt_sections.enabled
|
||||
@@ -369,12 +372,15 @@ class AgentRuntime:
|
||||
except Exception:
|
||||
pass # 日志记录失败不影响主流程
|
||||
|
||||
async def run(self, user_input: str) -> AgentResult:
|
||||
async def run(self, user_input: str, image_urls: Optional[List[str]] = None) -> AgentResult:
|
||||
"""
|
||||
执行 Agent 单轮对话。
|
||||
|
||||
流程:加载记忆 → 追加用户消息 → ReAct 循环 → 保存记忆 → 返回结果。
|
||||
image_urls: 本轮用户消息附带的图片(data URL 或可访问的 http URL),
|
||||
将以多模态 parts 形式挂到最后一条 user 消息上(不写入上下文历史)。
|
||||
"""
|
||||
self._turn_image_urls = image_urls or []
|
||||
max_iter = max(1, self.config.llm.max_iterations)
|
||||
self.context.iteration = 0
|
||||
self.context.tool_calls_made = 0
|
||||
@@ -487,6 +493,7 @@ class AgentRuntime:
|
||||
|
||||
# 裁剪过长历史
|
||||
messages = self.memory.trim_messages(self.context.messages)
|
||||
messages = self._with_image_parts(messages)
|
||||
|
||||
# 预算检查:LLM 调用次数(在调用 LLM 之前检查,避免浪费额度)
|
||||
budget = self.config.budget
|
||||
@@ -806,13 +813,15 @@ class AgentRuntime:
|
||||
self._attach_token_usage(result)
|
||||
return result
|
||||
|
||||
async def run_stream(self, user_input: str) -> AsyncGenerator[dict, None]:
|
||||
async def run_stream(self, user_input: str, image_urls: Optional[List[str]] = None) -> AsyncGenerator[dict, None]:
|
||||
"""
|
||||
流式执行 Agent 单轮对话(支持 streamlined 模式)。
|
||||
image_urls: 本轮用户消息附带的图片(多模态 parts 挂到最后一条 user 消息)。
|
||||
|
||||
与 run() 逻辑相同,但在每个关键步骤 yield SSE 事件。
|
||||
当 streamlined=True 时,工具调用会被折叠为累计摘要。
|
||||
"""
|
||||
self._turn_image_urls = image_urls or []
|
||||
if self._streamlined_transformer:
|
||||
self._streamlined_transformer.reset()
|
||||
async for event in self._run_stream_impl(user_input):
|
||||
@@ -964,6 +973,7 @@ class AgentRuntime:
|
||||
)
|
||||
|
||||
messages = self.memory.trim_messages(self.context.messages)
|
||||
messages = self._with_image_parts(messages)
|
||||
|
||||
# 预算检查:LLM 调用次数(在调用 LLM 之前检查,避免浪费额度)
|
||||
budget = self.config.budget
|
||||
@@ -1361,6 +1371,26 @@ class AgentRuntime:
|
||||
"token_usage": token_usage_truncated,
|
||||
}
|
||||
|
||||
def _with_image_parts(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""若本轮带图片,将最后一条 user 消息改为多模态 parts(文本 + image_url)。
|
||||
|
||||
只在发给 LLM 的 messages 副本上修改,不污染 context 历史(历史里仍是纯文本,
|
||||
避免图片 base64 反复进入后续轮次与持久化)。
|
||||
"""
|
||||
if not self._turn_image_urls:
|
||||
return messages
|
||||
msgs = [dict(m) for m in messages]
|
||||
for i in range(len(msgs) - 1, -1, -1):
|
||||
if msgs[i].get("role") == "user":
|
||||
content = msgs[i].get("content")
|
||||
text = content if isinstance(content, str) else ""
|
||||
parts: List[Dict[str, Any]] = [{"type": "text", "text": text}]
|
||||
for url in self._turn_image_urls:
|
||||
parts.append({"type": "image_url", "image_url": {"url": url}})
|
||||
msgs[i]["content"] = parts
|
||||
break
|
||||
return msgs
|
||||
|
||||
async def _compose_system_prompt(self, query: str = "") -> str:
|
||||
"""使用分层装配构建完整系统提示词。
|
||||
|
||||
|
||||
@@ -132,6 +132,65 @@ async def _sse_stream(gen: AsyncGenerator[dict, None]) -> AsyncGenerator[str, No
|
||||
yield f"event: {event_type}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|
||||
|
||||
|
||||
# 支持图像理解的模型名特征(小写包含即视为支持)
|
||||
_VISION_MODEL_HINTS = (
|
||||
"vision", "gpt-4o", "claude", "kimi-k3", "kimi-latest",
|
||||
"qwen-vl", "glm-4v", "gemini", "pixtral",
|
||||
)
|
||||
|
||||
# 单张图片进入多模态的大小上限(base64 前)
|
||||
_IMAGE_MAX_BYTES = 8 * 1024 * 1024
|
||||
|
||||
_IMAGE_EXTS = {"png", "jpg", "jpeg", "gif", "webp", "bmp", "tif", "tiff"}
|
||||
|
||||
|
||||
def _model_supports_vision(model: str) -> bool:
|
||||
m = (model or "").lower()
|
||||
return any(h in m for h in _VISION_MODEL_HINTS)
|
||||
|
||||
|
||||
def _image_data_urls(attachments: Optional[List["ChatAttachment"]], model: str) -> List[str]:
|
||||
"""将图片附件读为 base64 data URL(仅当模型支持视觉时)。
|
||||
|
||||
非图片附件或不支持视觉的模型返回空列表,调用方仍走文本路径(file_read/OCR)。
|
||||
"""
|
||||
if not attachments or not _model_supports_vision(model):
|
||||
return []
|
||||
import base64
|
||||
from app.services.builtin_tools import _resolve_path_under_workspace
|
||||
|
||||
urls: List[str] = []
|
||||
for att in attachments:
|
||||
ct = (att.content_type or "").lower()
|
||||
ext = (att.filename or att.relative_path).rsplit(".", 1)[-1].lower() if "." in (att.filename or att.relative_path) else ""
|
||||
if not (att.is_image or ct.startswith("image/") or ext in _IMAGE_EXTS):
|
||||
continue
|
||||
path, err = _resolve_path_under_workspace(att.relative_path)
|
||||
if err or path is None or not path.is_file():
|
||||
logger.warning("图片附件路径无效: %s (%s)", att.relative_path, err)
|
||||
continue
|
||||
try:
|
||||
size = path.stat().st_size
|
||||
if size <= 0 or size > _IMAGE_MAX_BYTES:
|
||||
logger.warning("图片附件大小超限,跳过: %s (%d bytes)", att.relative_path, size)
|
||||
continue
|
||||
raw = path.read_bytes()
|
||||
except OSError as e:
|
||||
logger.warning("读取图片附件失败: %s (%s)", att.relative_path, e)
|
||||
continue
|
||||
mime = ct if ct.startswith("image/") else f"image/{'jpeg' if ext in ('jpg', 'jpeg') else (ext or 'png')}"
|
||||
urls.append(f"data:{mime};base64,{base64.b64encode(raw).decode('ascii')}")
|
||||
return urls
|
||||
|
||||
|
||||
class ChatAttachment(BaseModel):
|
||||
"""聊天附件(图片可走多模态)"""
|
||||
relative_path: str
|
||||
filename: Optional[str] = None
|
||||
content_type: Optional[str] = None
|
||||
is_image: bool = False
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
message: str
|
||||
session_id: Optional[str] = None
|
||||
@@ -143,6 +202,7 @@ class ChatRequest(BaseModel):
|
||||
streamlined: bool = Field(default=False, description="启用工具结果流式美化")
|
||||
prompt_sections_enabled: bool = Field(default=True, description="启用系统提示词分层装配")
|
||||
system_prompt_override: Optional[str] = Field(default=None, description="覆盖 Agent 的 System Prompt")
|
||||
attachments: Optional[List[ChatAttachment]] = Field(default=None, description="消息附件(图片将走多模态)")
|
||||
|
||||
|
||||
class ChatResponse(BaseModel):
|
||||
@@ -392,7 +452,7 @@ async def chat_bare(
|
||||
on_message = _make_message_saver(db, agent_id=None, user_id=current_user.id, workspace_id=workspace_id)
|
||||
context = AgentContext(session_id=req.session_id)
|
||||
runtime = AgentRuntime(config=config, context=context, on_llm_call=on_llm_call, on_message=on_message, streamlined=req.streamlined)
|
||||
result = await runtime.run(req.message)
|
||||
result = await runtime.run(req.message, image_urls=_image_data_urls(req.attachments, llm_kwargs.get("model", "")))
|
||||
|
||||
# 流式美化:为 steps 生成累计摘要
|
||||
streamlined_summary = None
|
||||
@@ -462,7 +522,7 @@ async def chat_bare_stream(
|
||||
context = AgentContext(session_id=req.session_id)
|
||||
runtime = AgentRuntime(config=config, context=context, on_llm_call=on_llm_call, on_message=on_message, streamlined=req.streamlined)
|
||||
return StreamingResponse(
|
||||
_sse_stream(runtime.run_stream(req.message)),
|
||||
_sse_stream(runtime.run_stream(req.message, image_urls=_image_data_urls(req.attachments, llm_kwargs.get("model", "")))),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
@@ -550,7 +610,7 @@ async def chat_with_agent(
|
||||
on_message = _make_message_saver(db, agent_id=agent_id, user_id=current_user.id, workspace_id=workspace_id)
|
||||
context = AgentContext(session_id=req.session_id)
|
||||
runtime = AgentRuntime(config=config, context=context, on_llm_call=on_llm_call, on_message=on_message, streamlined=req.streamlined)
|
||||
result = await runtime.run(req.message)
|
||||
result = await runtime.run(req.message, image_urls=_image_data_urls(req.attachments, llm_kwargs.get("model", "")))
|
||||
|
||||
# 流式美化:为 steps 生成累计摘要
|
||||
streamlined_summary = None
|
||||
@@ -650,7 +710,7 @@ async def chat_with_agent_stream(
|
||||
context = AgentContext(session_id=req.session_id)
|
||||
runtime = AgentRuntime(config=config, context=context, on_llm_call=on_llm_call, on_message=on_message, streamlined=req.streamlined)
|
||||
return StreamingResponse(
|
||||
_sse_stream(runtime.run_stream(req.message)),
|
||||
_sse_stream(runtime.run_stream(req.message, image_urls=_image_data_urls(req.attachments, llm_kwargs.get("model", "")))),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
@@ -924,10 +984,11 @@ def _resolve_model_config(db: Session, model_config_id: Optional[str], user_id:
|
||||
).first()
|
||||
if not mc:
|
||||
raise HTTPException(status_code=404, detail="模型配置不存在")
|
||||
from app.services.encryption_service import EncryptionService
|
||||
return {
|
||||
"provider": mc.provider,
|
||||
"model": mc.model_name,
|
||||
"api_key": mc.api_key,
|
||||
"api_key": EncryptionService.decrypt(mc.api_key),
|
||||
"base_url": mc.base_url,
|
||||
}
|
||||
|
||||
@@ -944,6 +1005,9 @@ def _apply_model_config(llm_kwargs: Dict[str, Any], mc: Optional[Dict[str, Any]]
|
||||
llm_kwargs["api_key"] = mc["api_key"]
|
||||
if mc.get("base_url"):
|
||||
llm_kwargs["base_url"] = mc["base_url"]
|
||||
# Kimi K3 等推理模型强制要求 temperature=1
|
||||
if mc.get("provider") == "moonshot":
|
||||
llm_kwargs["temperature"] = 1.0
|
||||
return llm_kwargs
|
||||
"""从工作流节点列表中查找第一个 agent 类型或 llm 类型的节点配置。"""
|
||||
if not nodes:
|
||||
@@ -955,6 +1019,17 @@ def _apply_model_config(llm_kwargs: Dict[str, Any], mc: Optional[Dict[str, Any]]
|
||||
return {}
|
||||
|
||||
|
||||
def _find_agent_node_config(nodes: list) -> Dict[str, Any]:
|
||||
"""从工作流节点列表中提取 Agent/LLM 节点的配置。"""
|
||||
if not nodes:
|
||||
return {}
|
||||
for node in nodes:
|
||||
typ = node.get("type", "")
|
||||
if typ in ("agent", "llm", "template"):
|
||||
return node.get("data") or {}
|
||||
return {}
|
||||
|
||||
|
||||
def _build_memory_config_from_node(agent_node_cfg: dict) -> AgentMemoryConfig:
|
||||
"""从 Agent 工作流节点配置中提取记忆配置。"""
|
||||
from app.core.compaction_config import CompactionConfig
|
||||
|
||||
@@ -200,6 +200,7 @@
|
||||
:iterations="item.msg.iterations"
|
||||
:tool-calls-made="item.msg.tool_calls_made"
|
||||
:token-usage="item.msg.token_usage"
|
||||
:attachments="item.msg.attachments"
|
||||
>
|
||||
<template v-if="item.msg.orchestrateResult" #extra>
|
||||
<div class="orchestrate-result">
|
||||
@@ -480,6 +481,7 @@ import type { Agent } from '@/stores/agent'
|
||||
import { useModelConfigStore } from '@/stores/modelConfig'
|
||||
import { useMarkdown } from '@/composables/useMarkdown'
|
||||
import { useFileUpload } from '@/composables/useFileUpload'
|
||||
import type { MessageAttachment } from '@/composables/useFileUpload'
|
||||
import { useTTS } from '@/composables/useTTS'
|
||||
import { useSpeechRecognition } from '@/composables/useSpeechRecognition'
|
||||
|
||||
@@ -503,7 +505,7 @@ interface OrchestrateResult {
|
||||
interface ChatMessage {
|
||||
role: 'user' | 'assistant'; content: string; tool_calls?: any[]; timestamp: number
|
||||
iterations?: number; tool_calls_made?: number; status?: string; steps?: AgentStep[]
|
||||
token_usage?: any
|
||||
token_usage?: any; attachments?: MessageAttachment[]
|
||||
_traceOpen?: boolean; orchestrateResult?: OrchestrateResult
|
||||
}
|
||||
interface OrchestrateAgentForm {
|
||||
@@ -1052,16 +1054,31 @@ async function sendMessage() {
|
||||
const attachCtx = buildAttachmentContext()
|
||||
const fullText = text + attachCtx
|
||||
|
||||
// 快照附件:图片走多模态消息,非图片仍走文本上下文
|
||||
const sentAttachments = pendingAttachments.value.map(a => ({
|
||||
relative_path: a.relative_path,
|
||||
filename: a.filename,
|
||||
content_type: a.content_type,
|
||||
is_image: !!a.thumbUrl || (a.content_type || '').startsWith('image/'),
|
||||
}))
|
||||
const msgAttachments: MessageAttachment[] = pendingAttachments.value.map(a => ({
|
||||
relative_path: a.relative_path,
|
||||
filename: a.filename,
|
||||
thumbUrl: a.thumbUrl,
|
||||
isImage: !!a.thumbUrl || (a.content_type || '').startsWith('image/'),
|
||||
content_type: a.content_type,
|
||||
}))
|
||||
|
||||
const key = currentAgentKey.value
|
||||
if (!messages.value[key]) messages.value[key] = []
|
||||
messages.value[key].push({ role: 'user', content: fullText, timestamp: Date.now() })
|
||||
messages.value[key].push({ role: 'user', content: fullText, timestamp: Date.now(), attachments: msgAttachments.length ? msgAttachments : undefined })
|
||||
inputMessage.value = ''; pendingAttachments.value = []; inputDragOver.value = false
|
||||
loading.value = true; scrollToBottom()
|
||||
|
||||
try {
|
||||
if (chatMode.value === 'orchestrate') {
|
||||
const resp = await api.post('/api/v1/agent-chat/orchestrate', {
|
||||
message: text, mode: orchestrateMode.value,
|
||||
message: fullText, mode: orchestrateMode.value,
|
||||
agents: orchestrateAgents.value.map(a => ({
|
||||
id: a.id, name: a.name, system_prompt: a.system_prompt,
|
||||
model: a.model, temperature: a.temperature, max_iterations: a.max_iterations,
|
||||
@@ -1089,11 +1106,12 @@ async function sendMessage() {
|
||||
try {
|
||||
const authToken = localStorage.getItem('access_token') || localStorage.getItem('token') || ''
|
||||
const body: any = {
|
||||
message: text,
|
||||
message: fullText,
|
||||
session_id: sessId || undefined,
|
||||
temperature: chatTemperature.value,
|
||||
max_iterations: chatMaxIterations.value,
|
||||
}
|
||||
if (sentAttachments.length) body.attachments = sentAttachments
|
||||
if (chatMaxTokens.value) body.max_tokens = chatMaxTokens.value
|
||||
if (chatModelConfigId.value) body.model_config_id = chatModelConfigId.value
|
||||
const resp = await fetch(streamEndpoint, {
|
||||
@@ -1170,7 +1188,8 @@ async function sendMessage() {
|
||||
if (!usedStreaming) {
|
||||
if (placeholderIdx >= 0 && placeholderIdx < messages.value[key].length) messages.value[key].splice(placeholderIdx, 1)
|
||||
const fallbackEndpoint = currentAgentId.value ? `/api/v1/agent-chat/${currentAgentId.value}` : '/api/v1/agent-chat/bare'
|
||||
const body: any = { message: text, session_id: sessId || undefined, temperature: chatTemperature.value, max_iterations: chatMaxIterations.value }
|
||||
const body: any = { message: fullText, session_id: sessId || undefined, temperature: chatTemperature.value, max_iterations: chatMaxIterations.value }
|
||||
if (sentAttachments.length) body.attachments = sentAttachments
|
||||
if (chatMaxTokens.value) body.max_tokens = chatMaxTokens.value
|
||||
if (chatModelConfigId.value) body.model_config_id = chatModelConfigId.value
|
||||
const resp = await api.post(fallbackEndpoint, body)
|
||||
|
||||
Reference in New Issue
Block a user