zcbot/core/attachments.py

144 lines
5.2 KiB
Python

"""Structured user-message attachments and model-context compatibility rendering."""
from __future__ import annotations
import base64
import mimetypes
from copy import deepcopy
from collections.abc import Iterable
from pathlib import Path
from typing import Any
from .artifacts import ArtifactPathError, resolve_artifact_path
ATTACHMENT_REF_VERSION = 1
MAX_ATTACHMENTS_PER_MESSAGE = 10
MAX_NATIVE_IMAGE_BYTES = 10 * 1024 * 1024
_NATIVE_IMAGE_MIME = {
".gif": "image/gif",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
}
_IMAGE_EXTENSIONS = {
".avif", ".bmp", ".gif", ".heic", ".heif", ".jpeg", ".jpg",
".png", ".svg", ".tif", ".tiff", ".webp",
}
def _attachment_kind(requested: str, path: Path, media_type: str) -> str:
detected_image = media_type.startswith("image/") or path.suffix.lower() in _IMAGE_EXTENSIONS
if requested == "image" and detected_image:
return "image"
return "file"
def normalize_attachment_refs(
refs: Iterable[Any], *, working_dir: Path, user_root: Path,
) -> list[dict]:
"""Validate client refs and return task-relative, display-ready metadata."""
output: list[dict] = []
seen: set[str] = set()
for raw in refs:
if len(output) >= MAX_ATTACHMENTS_PER_MESSAGE:
raise ArtifactPathError(
f"at most {MAX_ATTACHMENTS_PER_MESSAGE} attachments are allowed"
)
data = raw.model_dump() if hasattr(raw, "model_dump") else dict(raw or {})
absolute, rel = resolve_artifact_path(
str(data.get("path") or ""),
working_dir=working_dir,
user_root=user_root,
require_file=True,
)
if rel in seen:
continue
seen.add(rel)
media_type = mimetypes.guess_type(absolute.name)[0] or "application/octet-stream"
label = str(data.get("label") or absolute.name).strip() or absolute.name
output.append({
"version": ATTACHMENT_REF_VERSION,
"scope": "working_dir",
"path": rel,
"label": label,
"kind": _attachment_kind(str(data.get("kind") or "file"), absolute, media_type),
"media_type": media_type,
"size_bytes": absolute.stat().st_size,
})
return output
def content_for_model(content: str, refs: Iterable[dict], working_dir_name: str) -> str:
"""Append the legacy-readable file hints only to the provider-bound message."""
lines: list[str] = []
wd = str(working_dir_name or "").strip().strip("/\\")
for ref in refs or ():
path = str((ref or {}).get("path") or "").strip().replace("\\", "/")
if not path:
continue
full_path = f"{wd}/{path}" if wd else path
marker = "[用户上传的参考图]" if (ref or {}).get("kind") == "image" else "[用户上传的文件]"
lines.append(f"{marker} {full_path}")
text = str(content or "").strip()
if not lines:
return text
suffix = "\n".join(lines)
return f"{text}\n\n{suffix}" if text else suffix
def materialize_native_images(
messages: Iterable[dict],
*,
enabled: bool,
working_dir: Path,
user_root: Path,
) -> tuple[list[dict], int]:
"""把内存附件引用按需物化为 OpenAI-compatible image_url blocks。
Base64 只存在于本次 provider-bound 副本,不写数据库。历史文件已删除、越界、
格式不受支持或超过上限时保留原有文字路径提示并跳过图片块,不能让旧附件阻断续聊。
所有 ``_`` 内部字段在返回前剥离。
"""
output: list[dict] = []
image_count = 0
for message in messages:
new_msg = deepcopy(message)
refs = list(new_msg.pop("_attachment_refs", []) or [])
new_msg.pop("_model_profile", None)
if not enabled or new_msg.get("role") != "user" or not refs:
output.append(new_msg)
continue
blocks: list[dict] = []
text = new_msg.get("content")
if isinstance(text, str) and text:
blocks.append({"type": "text", "text": text})
message_image_count = 0
for ref in refs:
if not isinstance(ref, dict) or ref.get("kind") != "image":
continue
raw_path = str(ref.get("path") or "")
try:
path, _ = resolve_artifact_path(
raw_path,
working_dir=working_dir,
user_root=user_root,
require_file=True,
)
mime = _NATIVE_IMAGE_MIME.get(path.suffix.lower())
if mime is None or path.stat().st_size > MAX_NATIVE_IMAGE_BYTES:
continue
data = base64.b64encode(path.read_bytes()).decode("ascii")
except (ArtifactPathError, OSError):
continue
blocks.append({
"type": "image_url",
"image_url": {"url": f"data:{mime};base64,{data}"},
})
image_count += 1
message_image_count += 1
if message_image_count:
new_msg["content"] = blocks
output.append(new_msg)
return output, image_count