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:
2026-07-26 23:48:29 +08:00
parent fe37f8be5c
commit 8d9ddd3aa0
3 changed files with 136 additions and 12 deletions

View File

@@ -175,6 +175,9 @@ class AgentRuntime:
self._streamlined_transformer = create_streamlined_transformer(enabled=True) self._streamlined_transformer = create_streamlined_transformer(enabled=True)
logger.info("工具结果流式美化已启用") logger.info("工具结果流式美化已启用")
# 本轮用户消息附带的图片(多模态),run()/run_stream() 调用时注入
self._turn_image_urls: List[str] = []
# 系统提示词分层装配 (P2 — 参考 Claude Code systemPromptSections.ts) # 系统提示词分层装配 (P2 — 参考 Claude Code systemPromptSections.ts)
self._prompt_composer: Optional[PromptComposer] = None self._prompt_composer: Optional[PromptComposer] = None
self._prompt_sections_enabled = self.config.prompt_sections.enabled self._prompt_sections_enabled = self.config.prompt_sections.enabled
@@ -369,12 +372,15 @@ class AgentRuntime:
except Exception: except Exception:
pass # 日志记录失败不影响主流程 pass # 日志记录失败不影响主流程
async def run(self, user_input: str) -> AgentResult: async def run(self, user_input: str, image_urls: Optional[List[str]] = None) -> AgentResult:
""" """
执行 Agent 单轮对话。 执行 Agent 单轮对话。
流程:加载记忆 → 追加用户消息 → ReAct 循环 → 保存记忆 → 返回结果。 流程:加载记忆 → 追加用户消息 → 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) max_iter = max(1, self.config.llm.max_iterations)
self.context.iteration = 0 self.context.iteration = 0
self.context.tool_calls_made = 0 self.context.tool_calls_made = 0
@@ -487,6 +493,7 @@ class AgentRuntime:
# 裁剪过长历史 # 裁剪过长历史
messages = self.memory.trim_messages(self.context.messages) messages = self.memory.trim_messages(self.context.messages)
messages = self._with_image_parts(messages)
# 预算检查:LLM 调用次数(在调用 LLM 之前检查,避免浪费额度) # 预算检查:LLM 调用次数(在调用 LLM 之前检查,避免浪费额度)
budget = self.config.budget budget = self.config.budget
@@ -806,13 +813,15 @@ class AgentRuntime:
self._attach_token_usage(result) self._attach_token_usage(result)
return 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 模式)。 流式执行 Agent 单轮对话(支持 streamlined 模式)。
image_urls: 本轮用户消息附带的图片(多模态 parts 挂到最后一条 user 消息)。
与 run() 逻辑相同,但在每个关键步骤 yield SSE 事件。 与 run() 逻辑相同,但在每个关键步骤 yield SSE 事件。
当 streamlined=True 时,工具调用会被折叠为累计摘要。 当 streamlined=True 时,工具调用会被折叠为累计摘要。
""" """
self._turn_image_urls = image_urls or []
if self._streamlined_transformer: if self._streamlined_transformer:
self._streamlined_transformer.reset() self._streamlined_transformer.reset()
async for event in self._run_stream_impl(user_input): 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.memory.trim_messages(self.context.messages)
messages = self._with_image_parts(messages)
# 预算检查:LLM 调用次数(在调用 LLM 之前检查,避免浪费额度) # 预算检查:LLM 调用次数(在调用 LLM 之前检查,避免浪费额度)
budget = self.config.budget budget = self.config.budget
@@ -1361,6 +1371,26 @@ class AgentRuntime:
"token_usage": token_usage_truncated, "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: async def _compose_system_prompt(self, query: str = "") -> str:
"""使用分层装配构建完整系统提示词。 """使用分层装配构建完整系统提示词。

View File

@@ -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" 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): class ChatRequest(BaseModel):
message: str message: str
session_id: Optional[str] = None session_id: Optional[str] = None
@@ -143,6 +202,7 @@ class ChatRequest(BaseModel):
streamlined: bool = Field(default=False, description="启用工具结果流式美化") streamlined: bool = Field(default=False, description="启用工具结果流式美化")
prompt_sections_enabled: bool = Field(default=True, description="启用系统提示词分层装配") prompt_sections_enabled: bool = Field(default=True, description="启用系统提示词分层装配")
system_prompt_override: Optional[str] = Field(default=None, description="覆盖 Agent 的 System Prompt") system_prompt_override: Optional[str] = Field(default=None, description="覆盖 Agent 的 System Prompt")
attachments: Optional[List[ChatAttachment]] = Field(default=None, description="消息附件(图片将走多模态)")
class ChatResponse(BaseModel): 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) 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) 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) 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 生成累计摘要 # 流式美化:为 steps 生成累计摘要
streamlined_summary = None streamlined_summary = None
@@ -462,7 +522,7 @@ async def chat_bare_stream(
context = AgentContext(session_id=req.session_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) runtime = AgentRuntime(config=config, context=context, on_llm_call=on_llm_call, on_message=on_message, streamlined=req.streamlined)
return StreamingResponse( 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", media_type="text/event-stream",
headers={ headers={
"Cache-Control": "no-cache", "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) 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) 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) 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 生成累计摘要 # 流式美化:为 steps 生成累计摘要
streamlined_summary = None streamlined_summary = None
@@ -650,7 +710,7 @@ async def chat_with_agent_stream(
context = AgentContext(session_id=req.session_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) runtime = AgentRuntime(config=config, context=context, on_llm_call=on_llm_call, on_message=on_message, streamlined=req.streamlined)
return StreamingResponse( 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", media_type="text/event-stream",
headers={ headers={
"Cache-Control": "no-cache", "Cache-Control": "no-cache",
@@ -924,10 +984,11 @@ def _resolve_model_config(db: Session, model_config_id: Optional[str], user_id:
).first() ).first()
if not mc: if not mc:
raise HTTPException(status_code=404, detail="模型配置不存在") raise HTTPException(status_code=404, detail="模型配置不存在")
from app.services.encryption_service import EncryptionService
return { return {
"provider": mc.provider, "provider": mc.provider,
"model": mc.model_name, "model": mc.model_name,
"api_key": mc.api_key, "api_key": EncryptionService.decrypt(mc.api_key),
"base_url": mc.base_url, "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"] llm_kwargs["api_key"] = mc["api_key"]
if mc.get("base_url"): if mc.get("base_url"):
llm_kwargs["base_url"] = mc["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 return llm_kwargs
"""从工作流节点列表中查找第一个 agent 类型或 llm 类型的节点配置。""" """从工作流节点列表中查找第一个 agent 类型或 llm 类型的节点配置。"""
if not nodes: if not nodes:
@@ -955,6 +1019,17 @@ def _apply_model_config(llm_kwargs: Dict[str, Any], mc: Optional[Dict[str, Any]]
return {} 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: def _build_memory_config_from_node(agent_node_cfg: dict) -> AgentMemoryConfig:
"""从 Agent 工作流节点配置中提取记忆配置。""" """从 Agent 工作流节点配置中提取记忆配置。"""
from app.core.compaction_config import CompactionConfig from app.core.compaction_config import CompactionConfig

View File

@@ -200,6 +200,7 @@
:iterations="item.msg.iterations" :iterations="item.msg.iterations"
:tool-calls-made="item.msg.tool_calls_made" :tool-calls-made="item.msg.tool_calls_made"
:token-usage="item.msg.token_usage" :token-usage="item.msg.token_usage"
:attachments="item.msg.attachments"
> >
<template v-if="item.msg.orchestrateResult" #extra> <template v-if="item.msg.orchestrateResult" #extra>
<div class="orchestrate-result"> <div class="orchestrate-result">
@@ -480,6 +481,7 @@ import type { Agent } from '@/stores/agent'
import { useModelConfigStore } from '@/stores/modelConfig' import { useModelConfigStore } from '@/stores/modelConfig'
import { useMarkdown } from '@/composables/useMarkdown' import { useMarkdown } from '@/composables/useMarkdown'
import { useFileUpload } from '@/composables/useFileUpload' import { useFileUpload } from '@/composables/useFileUpload'
import type { MessageAttachment } from '@/composables/useFileUpload'
import { useTTS } from '@/composables/useTTS' import { useTTS } from '@/composables/useTTS'
import { useSpeechRecognition } from '@/composables/useSpeechRecognition' import { useSpeechRecognition } from '@/composables/useSpeechRecognition'
@@ -503,7 +505,7 @@ interface OrchestrateResult {
interface ChatMessage { interface ChatMessage {
role: 'user' | 'assistant'; content: string; tool_calls?: any[]; timestamp: number role: 'user' | 'assistant'; content: string; tool_calls?: any[]; timestamp: number
iterations?: number; tool_calls_made?: number; status?: string; steps?: AgentStep[] iterations?: number; tool_calls_made?: number; status?: string; steps?: AgentStep[]
token_usage?: any token_usage?: any; attachments?: MessageAttachment[]
_traceOpen?: boolean; orchestrateResult?: OrchestrateResult _traceOpen?: boolean; orchestrateResult?: OrchestrateResult
} }
interface OrchestrateAgentForm { interface OrchestrateAgentForm {
@@ -1052,16 +1054,31 @@ async function sendMessage() {
const attachCtx = buildAttachmentContext() const attachCtx = buildAttachmentContext()
const fullText = text + attachCtx 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 const key = currentAgentKey.value
if (!messages.value[key]) messages.value[key] = [] 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 inputMessage.value = ''; pendingAttachments.value = []; inputDragOver.value = false
loading.value = true; scrollToBottom() loading.value = true; scrollToBottom()
try { try {
if (chatMode.value === 'orchestrate') { if (chatMode.value === 'orchestrate') {
const resp = await api.post('/api/v1/agent-chat/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 => ({ agents: orchestrateAgents.value.map(a => ({
id: a.id, name: a.name, system_prompt: a.system_prompt, id: a.id, name: a.name, system_prompt: a.system_prompt,
model: a.model, temperature: a.temperature, max_iterations: a.max_iterations, model: a.model, temperature: a.temperature, max_iterations: a.max_iterations,
@@ -1089,11 +1106,12 @@ async function sendMessage() {
try { try {
const authToken = localStorage.getItem('access_token') || localStorage.getItem('token') || '' const authToken = localStorage.getItem('access_token') || localStorage.getItem('token') || ''
const body: any = { const body: any = {
message: text, message: fullText,
session_id: sessId || undefined, session_id: sessId || undefined,
temperature: chatTemperature.value, temperature: chatTemperature.value,
max_iterations: chatMaxIterations.value, max_iterations: chatMaxIterations.value,
} }
if (sentAttachments.length) body.attachments = sentAttachments
if (chatMaxTokens.value) body.max_tokens = chatMaxTokens.value if (chatMaxTokens.value) body.max_tokens = chatMaxTokens.value
if (chatModelConfigId.value) body.model_config_id = chatModelConfigId.value if (chatModelConfigId.value) body.model_config_id = chatModelConfigId.value
const resp = await fetch(streamEndpoint, { const resp = await fetch(streamEndpoint, {
@@ -1170,7 +1188,8 @@ async function sendMessage() {
if (!usedStreaming) { if (!usedStreaming) {
if (placeholderIdx >= 0 && placeholderIdx < messages.value[key].length) messages.value[key].splice(placeholderIdx, 1) 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 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 (chatMaxTokens.value) body.max_tokens = chatMaxTokens.value
if (chatModelConfigId.value) body.model_config_id = chatModelConfigId.value if (chatModelConfigId.value) body.model_config_id = chatModelConfigId.value
const resp = await api.post(fallbackEndpoint, body) const resp = await api.post(fallbackEndpoint, body)