From 9e4dfbe596e0d932ed4e427c9b59a2e6d8219787 Mon Sep 17 00:00:00 2001
From: Xin Wang
Date: Sun, 23 Aug 2026 23:46:18 +0800
Subject: [PATCH] feat: support image inputs in assistant tests
---
backend/app.py | 2 +
backend/db/models.py | 17 ++
.../versions/20260818_0023_add_test_assets.py | 53 ++++++
backend/routes/test_assets.py | 172 ++++++++++++++++++
backend/routes/test_cases.py | 25 ++-
backend/routes/test_runs.py | 19 +-
backend/services/image_processing.py | 41 +++++
backend/services/input_assets.py | 36 +---
backend/services/pipecat/pipeline.py | 60 +-----
backend/services/test_assets.py | 44 +++++
backend/services/test_case_assets.py | 72 ++++++++
backend/services/test_runs/evaluator.py | 2 +
backend/services/test_runs/orchestrator.py | 20 ++
backend/services/test_runs/text_runner.py | 98 ++++++++--
backend/services/vision.py | 89 +++++++++
backend/test_schemas.py | 33 +++-
backend/tests/test_pipeline_knowledge.py | 21 ++-
backend/tests/test_test_image_inputs.py | 118 ++++++++++++
.../assistant-editor/debug-auto-test.tsx | 13 +-
.../batch-test/batch-case-detail-drawer.tsx | 19 +-
.../src/components/pages/TestCasesPage.tsx | 6 +-
.../test-cases/input-mode-picker.tsx | 4 +-
.../test-cases/next-reply-editor.tsx | 135 +++++++++++++-
frontend/src/data/batch-run.ts | 7 +
frontend/src/data/test-suites.ts | 23 ++-
frontend/src/lib/api.ts | 27 +++
26 files changed, 1010 insertions(+), 146 deletions(-)
create mode 100644 backend/migrations/versions/20260818_0023_add_test_assets.py
create mode 100644 backend/routes/test_assets.py
create mode 100644 backend/services/image_processing.py
create mode 100644 backend/services/test_assets.py
create mode 100644 backend/services/test_case_assets.py
create mode 100644 backend/tests/test_test_image_inputs.py
diff --git a/backend/app.py b/backend/app.py
index 50789f0..2ddfa66 100644
--- a/backend/app.py
+++ b/backend/app.py
@@ -48,6 +48,7 @@ from routes import (
realtime_api_keys,
site_settings,
test_cases,
+ test_assets,
test_runs,
tools,
voice_webrtc,
@@ -104,6 +105,7 @@ app.include_router(openai_realtime.router)
app.include_router(realtime_api_keys.router)
app.include_router(site_settings.router)
app.include_router(test_cases.router)
+app.include_router(test_assets.router)
app.include_router(test_runs.router)
app.include_router(tools.router)
app.include_router(voice_webrtc.router)
diff --git a/backend/db/models.py b/backend/db/models.py
index 478b0aa..f83313d 100644
--- a/backend/db/models.py
+++ b/backend/db/models.py
@@ -475,6 +475,23 @@ class TestSuite(Base):
)
+class TestAsset(Base):
+ """Immutable image fixture referenced by one or more test definitions."""
+
+ __tablename__ = "test_assets"
+
+ id: Mapped[str] = mapped_column(String(40), primary_key=True)
+ original_name: Mapped[str] = mapped_column(String(255), default="image.jpg")
+ storage_key: Mapped[str] = mapped_column(String(1024), unique=True)
+ mime_type: Mapped[str] = mapped_column(String(128), default="image/jpeg")
+ width: Mapped[int] = mapped_column(Integer)
+ height: Mapped[int] = mapped_column(Integer)
+ size_bytes: Mapped[int] = mapped_column(Integer)
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), server_default=func.now()
+ )
+
+
class TestCase(Base):
"""One editable test definition; nested turns remain one JSON document."""
diff --git a/backend/migrations/versions/20260818_0023_add_test_assets.py b/backend/migrations/versions/20260818_0023_add_test_assets.py
new file mode 100644
index 0000000..11a4072
--- /dev/null
+++ b/backend/migrations/versions/20260818_0023_add_test_assets.py
@@ -0,0 +1,53 @@
+"""add persistent image fixtures for test cases
+
+Revision ID: 20260818_0023
+Revises: 20260813_0022
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = "20260818_0023"
+down_revision: str | Sequence[str] | None = "20260813_0022"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "test_assets",
+ sa.Column("id", sa.String(length=40), nullable=False),
+ sa.Column(
+ "original_name",
+ sa.String(length=255),
+ server_default="image.jpg",
+ nullable=False,
+ ),
+ sa.Column("storage_key", sa.String(length=1024), nullable=False),
+ sa.Column(
+ "mime_type",
+ sa.String(length=128),
+ server_default="image/jpeg",
+ nullable=False,
+ ),
+ sa.Column("width", sa.Integer(), nullable=False),
+ sa.Column("height", sa.Integer(), nullable=False),
+ sa.Column("size_bytes", sa.Integer(), nullable=False),
+ sa.Column(
+ "created_at",
+ sa.DateTime(timezone=True),
+ server_default=sa.text("now()"),
+ nullable=False,
+ ),
+ sa.PrimaryKeyConstraint("id"),
+ sa.UniqueConstraint("storage_key"),
+ )
+
+
+def downgrade() -> None:
+ op.drop_table("test_assets")
diff --git a/backend/routes/test_assets.py b/backend/routes/test_assets.py
new file mode 100644
index 0000000..4093aea
--- /dev/null
+++ b/backend/routes/test_assets.py
@@ -0,0 +1,172 @@
+"""Persistent image fixtures for repeatable assistant tests."""
+
+from __future__ import annotations
+
+import asyncio
+from pathlib import Path
+from uuid import uuid4
+
+from db.models import TestAsset, TestCase, TestRunCase
+from db.session import get_session
+from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
+from fastapi.responses import Response
+from loguru import logger
+from pydantic import BaseModel
+from services.auth import require_admin
+from services.test_assets import delete_test_image, read_test_image, store_test_image
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+import settings
+
+
+router = APIRouter(
+ prefix="/api/test-assets",
+ tags=["test-assets"],
+ dependencies=[Depends(require_admin)],
+)
+
+
+class TestImageAssetOut(BaseModel):
+ id: str
+ filename: str
+ width: int
+ height: int
+ sizeBytes: int
+ contentUrl: str
+
+
+def _new_id() -> str:
+ return f"ta_{uuid4().hex}"
+
+
+def _safe_filename(value: str | None) -> str:
+ name = Path(value or "image.jpg").name.strip()
+ return (name or "image.jpg")[:255]
+
+
+def _out(row: TestAsset) -> TestImageAssetOut:
+ return TestImageAssetOut(
+ id=row.id,
+ filename=row.original_name,
+ width=row.width,
+ height=row.height,
+ sizeBytes=row.size_bytes,
+ contentUrl=f"/api/test-assets/{row.id}/content",
+ )
+
+
+def _contains_asset_id(value: object, asset_id: str) -> bool:
+ if isinstance(value, dict):
+ if value.get("assetId") == asset_id or value.get("asset_id") == asset_id:
+ return True
+ return any(_contains_asset_id(item, asset_id) for item in value.values())
+ if isinstance(value, list):
+ return any(_contains_asset_id(item, asset_id) for item in value)
+ return False
+
+
+async def _is_referenced(session: AsyncSession, asset_id: str) -> bool:
+ definitions = (
+ await session.execute(select(TestCase.definition))
+ ).scalars().all()
+ if any(_contains_asset_id(value, asset_id) for value in definitions):
+ return True
+ snapshots = (
+ await session.execute(select(TestRunCase.case_snapshot))
+ ).scalars().all()
+ return any(_contains_asset_id(value, asset_id) for value in snapshots)
+
+
+@router.post("/images", response_model=TestImageAssetOut)
+async def upload_test_image(
+ file: UploadFile = File(...),
+ session: AsyncSession = Depends(get_session),
+):
+ data = await file.read(settings.INPUT_IMAGE_MAX_BYTES + 1)
+ if len(data) > settings.INPUT_IMAGE_MAX_BYTES:
+ limit_mb = settings.INPUT_IMAGE_MAX_BYTES // 1024 // 1024
+ raise HTTPException(413, f"图片不能超过 {limit_mb} MB")
+ if not data:
+ raise HTTPException(400, "图片文件不能为空")
+
+ asset_id = _new_id()
+ try:
+ stored = await asyncio.to_thread(store_test_image, asset_id, data)
+ except ValueError as exc:
+ raise HTTPException(400, str(exc)) from exc
+ except Exception as exc:
+ raise HTTPException(503, "图片暂时无法上传,请确认对象存储可用") from exc
+
+ row = TestAsset(
+ id=asset_id,
+ original_name=_safe_filename(file.filename),
+ storage_key=stored.storage_key,
+ mime_type=stored.mime_type,
+ width=stored.width,
+ height=stored.height,
+ size_bytes=stored.size_bytes,
+ )
+ session.add(row)
+ try:
+ await session.commit()
+ except Exception:
+ await session.rollback()
+ try:
+ await asyncio.to_thread(delete_test_image, stored.storage_key)
+ except Exception:
+ logger.warning(f"回滚测试图片上传时清理对象失败: {stored.storage_key}")
+ raise
+ await session.refresh(row)
+ return _out(row)
+
+
+@router.get("/{asset_id}", response_model=TestImageAssetOut)
+async def get_test_asset(
+ asset_id: str,
+ session: AsyncSession = Depends(get_session),
+):
+ row = await session.get(TestAsset, asset_id)
+ if row is None:
+ raise HTTPException(404, "测试图片不存在")
+ return _out(row)
+
+
+@router.get("/{asset_id}/content")
+async def get_test_asset_content(
+ asset_id: str,
+ session: AsyncSession = Depends(get_session),
+):
+ row = await session.get(TestAsset, asset_id)
+ if row is None:
+ raise HTTPException(404, "测试图片不存在")
+ try:
+ data = await asyncio.to_thread(read_test_image, row.storage_key)
+ except Exception as exc:
+ raise HTTPException(503, "测试图片暂时无法读取") from exc
+ return Response(
+ content=data,
+ media_type=row.mime_type,
+ headers={"Cache-Control": "private, max-age=3600"},
+ )
+
+
+@router.delete("/{asset_id}")
+async def delete_test_asset(
+ asset_id: str,
+ session: AsyncSession = Depends(get_session),
+):
+ row = await session.get(TestAsset, asset_id)
+ if row is None:
+ raise HTTPException(404, "测试图片不存在")
+ if await _is_referenced(session, asset_id):
+ raise HTTPException(409, "测试图片仍被用例或运行记录引用")
+
+ storage_key = row.storage_key
+ await session.delete(row)
+ await session.commit()
+ try:
+ await asyncio.to_thread(delete_test_image, storage_key)
+ except Exception:
+ logger.warning(f"测试图片数据已删除,但对象存储清理失败: {storage_key}")
+ return {"ok": True}
diff --git a/backend/routes/test_cases.py b/backend/routes/test_cases.py
index 1a48b1c..b957101 100644
--- a/backend/routes/test_cases.py
+++ b/backend/routes/test_cases.py
@@ -9,6 +9,7 @@ from db.models import TestCase, TestRunCase, TestSuite
from db.session import get_session
from fastapi import APIRouter, Depends, HTTPException, Query
from services.auth import require_admin
+from services.test_case_assets import canonicalize_test_assets
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from test_schemas import (
@@ -79,13 +80,6 @@ def _case_out(row: TestCase) -> TestCaseOut:
)
-def _apply_case_write(row: TestCase, body: TestCaseWrite) -> None:
- row.name = body.name.strip()
- row.description = body.description.strip()
- row.input_mode = body.input_mode
- row.definition = body.definition().model_dump(mode="json", by_alias=True)
-
-
async def _renumber_cases(session: AsyncSession, suite_id: str) -> None:
rows = (
await session.execute(
@@ -232,13 +226,18 @@ async def create_test_case(
max_order = await session.scalar(
select(func.max(TestCase.sort_order)).where(TestCase.suite_id == suite_id)
)
+ definition = body.definition()
+ try:
+ await canonicalize_test_assets(session, definition)
+ except ValueError as exc:
+ raise HTTPException(422, str(exc)) from exc
row = TestCase(
id=_new_id("tc"),
suite_id=suite_id,
name=body.name.strip(),
description=body.description.strip(),
input_mode=body.input_mode,
- definition=body.definition().model_dump(mode="json", by_alias=True),
+ definition=definition.model_dump(mode="json", by_alias=True),
sort_order=int(max_order if max_order is not None else -1) + 1,
last_result="not_run",
)
@@ -315,7 +314,15 @@ async def update_test_case(
row = await session.get(TestCase, case_id)
if row is None:
raise HTTPException(404, "测试用例不存在")
- _apply_case_write(row, body)
+ definition = body.definition()
+ try:
+ await canonicalize_test_assets(session, definition)
+ except ValueError as exc:
+ raise HTTPException(422, str(exc)) from exc
+ row.name = body.name.strip()
+ row.description = body.description.strip()
+ row.input_mode = body.input_mode
+ row.definition = definition.model_dump(mode="json", by_alias=True)
await _touch_suite(session, row.suite_id)
await session.commit()
await session.refresh(row)
diff --git a/backend/routes/test_runs.py b/backend/routes/test_runs.py
index 80a3eea..64caa3b 100644
--- a/backend/routes/test_runs.py
+++ b/backend/routes/test_runs.py
@@ -18,6 +18,8 @@ from fastapi import APIRouter, Depends, HTTPException
from services.auth import require_admin
from services.config_resolver import resolve_runtime_config
from services.runtime_variables import DynamicVariableError, prepare_dynamic_config
+from services.realtime.launcher import validate_visual_runtime
+from services.test_case_assets import canonicalize_test_assets, image_asset_ids
from services.test_runs.orchestrator import test_run_orchestrator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -140,13 +142,18 @@ async def create_test_run(
raise HTTPException(422, "测试用例不存在: " + "、".join(missing))
ordered_cases = [by_id[case_id] for case_id in body.case_ids]
tools_by_id = {tool.id: tool for tool in runtime_config.tools}
+ definitions_by_id: dict[str, TestCaseDefinition] = {}
+ has_image_input = False
for row in ordered_cases:
if row.input_mode != "fixed_script_text":
- raise HTTPException(422, f"用例“{row.name}”不是可运行的固定文字模式")
+ raise HTTPException(422, f"用例“{row.name}”不是可运行的固定多模态模式")
try:
definition = TestCaseDefinition.model_validate(row.definition or {})
+ await canonicalize_test_assets(session, definition)
except ValueError as exc:
raise HTTPException(422, f"用例“{row.name}”校验失败: {exc}") from exc
+ definitions_by_id[row.id] = definition
+ has_image_input = has_image_input or bool(image_asset_ids(definition))
for turn in definition.turns:
for behavior in turn.behaviors:
if behavior.type != "tool_call":
@@ -159,6 +166,14 @@ async def create_test_run(
"不属于当前被测助手",
)
+ if has_image_input:
+ try:
+ vision_enabled = validate_visual_runtime(runtime_config)
+ except ValueError as exc:
+ raise HTTPException(422, str(exc)) from exc
+ if not vision_enabled:
+ raise HTTPException(422, "测试用例包含图片,但当前助手未开启视觉能力")
+
suite_ids = list(dict.fromkeys(row.suite_id for row in ordered_cases))
suites = (
await session.execute(select(TestSuite).where(TestSuite.id.in_(suite_ids)))
@@ -192,7 +207,7 @@ async def create_test_run(
)
session.add(run)
for position, row in enumerate(ordered_cases):
- definition = TestCaseDefinition.model_validate(row.definition or {})
+ definition = definitions_by_id[row.id]
snapshot = {
"id": row.id,
"suiteId": row.suite_id,
diff --git a/backend/services/image_processing.py b/backend/services/image_processing.py
new file mode 100644
index 0000000..c2ba975
--- /dev/null
+++ b/backend/services/image_processing.py
@@ -0,0 +1,41 @@
+"""Shared validation and normalization for user-supplied images."""
+
+from __future__ import annotations
+
+from io import BytesIO
+
+from PIL import Image, ImageOps, UnidentifiedImageError
+
+import settings
+
+
+def normalize_image(data: bytes) -> tuple[bytes, int, int]:
+ """Return a bounded, orientation-corrected JPEG safe for model input."""
+
+ try:
+ source = Image.open(BytesIO(data))
+ width, height = source.size
+ if width <= 0 or height <= 0:
+ raise ValueError("图片尺寸无效")
+ if width * height > settings.INPUT_IMAGE_MAX_PIXELS:
+ raise ValueError("图片像素尺寸过大")
+ source.load()
+ except (UnidentifiedImageError, OSError) as exc:
+ raise ValueError("文件不是可识别的图片") from exc
+
+ image = ImageOps.exif_transpose(source)
+ image.thumbnail(
+ (settings.INPUT_IMAGE_MAX_EDGE, settings.INPUT_IMAGE_MAX_EDGE),
+ Image.Resampling.LANCZOS,
+ )
+ if image.mode in {"RGBA", "LA"} or "transparency" in image.info:
+ rgba = image.convert("RGBA")
+ background = Image.new("RGB", rgba.size, "white")
+ background.paste(rgba, mask=rgba.getchannel("A"))
+ image = background
+ else:
+ image = image.convert("RGB")
+
+ buffer = BytesIO()
+ image.save(buffer, format="JPEG", quality=85, optimize=True)
+ return buffer.getvalue(), image.width, image.height
diff --git a/backend/services/input_assets.py b/backend/services/input_assets.py
index bbba40b..6c4480a 100644
--- a/backend/services/input_assets.py
+++ b/backend/services/input_assets.py
@@ -8,12 +8,10 @@ import hmac
import json
import time
from dataclasses import dataclass
-from io import BytesIO
from uuid import uuid4
-from PIL import Image, ImageOps, UnidentifiedImageError
-
import settings
+from services.image_processing import normalize_image
from services.object_storage import delete_object, get_object, put_object
@@ -46,38 +44,8 @@ def _signature(payload: str) -> str:
return _b64encode(digest)
-def _normalize_image(data: bytes) -> tuple[bytes, int, int]:
- try:
- source = Image.open(BytesIO(data))
- width, height = source.size
- if width <= 0 or height <= 0:
- raise ValueError("图片尺寸无效")
- if width * height > settings.INPUT_IMAGE_MAX_PIXELS:
- raise ValueError("图片像素尺寸过大")
- source.load()
- except (UnidentifiedImageError, OSError) as exc:
- raise ValueError("文件不是可识别的图片") from exc
-
- image = ImageOps.exif_transpose(source)
- image.thumbnail(
- (settings.INPUT_IMAGE_MAX_EDGE, settings.INPUT_IMAGE_MAX_EDGE),
- Image.Resampling.LANCZOS,
- )
- if image.mode in {"RGBA", "LA"} or "transparency" in image.info:
- rgba = image.convert("RGBA")
- background = Image.new("RGB", rgba.size, "white")
- background.paste(rgba, mask=rgba.getchannel("A"))
- image = background
- else:
- image = image.convert("RGB")
-
- buffer = BytesIO()
- image.save(buffer, format="JPEG", quality=85, optimize=True)
- return buffer.getvalue(), image.width, image.height
-
-
def store_input_image(data: bytes) -> StoredInputImage:
- normalized, width, height = _normalize_image(data)
+ normalized, width, height = normalize_image(data)
key = f"{INPUT_ASSET_PREFIX}{uuid4().hex}.jpg"
put_object(key, normalized, "image/jpeg")
expires_at = int(time.time()) + settings.INPUT_IMAGE_TOKEN_TTL_SECONDS
diff --git a/backend/services/pipecat/pipeline.py b/backend/services/pipecat/pipeline.py
index 2227bb4..645b7e1 100644
--- a/backend/services/pipecat/pipeline.py
+++ b/backend/services/pipecat/pipeline.py
@@ -34,12 +34,11 @@ from services.tool_policy import policy_for_tool
from services.vision import (
VISION_TOOL_NAME,
analyze_image_with_vision_model,
- config_with_main_llm_as_vision,
- config_with_vision_resource,
image_data_uri,
image_frame_from_jpeg,
image_jpeg_bytes,
resolve_vision_question,
+ resolve_vision_input,
vision_system_hint,
vision_tool_schema_parts,
)
@@ -151,30 +150,6 @@ def _require(value: str, label: str) -> str:
raise ValueError(f"缺少模型资源配置: {label}")
-def _vision_uses_main_llm(cfg: AssistantConfig) -> bool:
- """模型自己支持图片时,沿用 Pipecat 的同上下文视觉工具路径。"""
- return not cfg.vision_model_resource_id and cfg.llm_support_image_input
-
-
-def _workflow_vision_uses_main_llm(
- cfg: AssistantConfig,
- scope: dict[str, Any],
-) -> bool:
- """Resolve native versus auxiliary vision for the active Workflow Agent."""
- if not scope.get("enabled"):
- raise ValueError("当前 Workflow Agent 节点未启用视觉能力")
- if scope.get("vision_model_resource_id"):
- return False
-
- llm_resource_id = str(scope.get("llm_resource_id") or "")
- resource = cfg.workflow_model_resources.get(llm_resource_id)
- if not resource:
- raise ValueError(f"当前 Workflow Agent 的 LLM 资源未加载:{llm_resource_id}")
- if not resource.support_image_input:
- raise ValueError("当前 Workflow Agent 的 LLM 不支持图片输入")
- return True
-
-
def _multimodal_user_input_frame(
image_frame: UserImageRawFrame,
prompt_text: str,
@@ -485,7 +460,7 @@ async def run_pipeline(
vision_native_mode = (
vision_enabled
and cfg.type != "workflow"
- and _vision_uses_main_llm(cfg)
+ and resolve_vision_input(cfg).native
)
vision_state: dict[str, str | None] = {"client_id": None}
workflow_vision_scope: dict[str, Any] = {
@@ -497,9 +472,9 @@ async def run_pipeline(
}
def active_vision_uses_main_llm() -> bool:
- if cfg.type == "workflow":
- return _workflow_vision_uses_main_llm(cfg, workflow_vision_scope)
- return vision_native_mode
+ if cfg.type != "workflow":
+ return vision_native_mode
+ return resolve_vision_input(cfg, workflow_vision_scope).native
vision_description, vision_properties, vision_required = vision_tool_schema_parts(
cfg.vision_prompt_mode,
cfg.vision_prompt,
@@ -609,29 +584,8 @@ async def run_pipeline(
workflow_vision_function = None
def active_vision_config() -> AssistantConfig:
- if cfg.type != "workflow":
- return cfg
- if not workflow_vision_scope.get("enabled"):
- raise ValueError("当前 Workflow Agent 节点未启用视觉能力")
- vision_resource_id = str(
- workflow_vision_scope.get("vision_model_resource_id") or ""
- )
- llm_resource_id = str(workflow_vision_scope.get("llm_resource_id") or "")
- resource_id = vision_resource_id or llm_resource_id
- resource = cfg.workflow_model_resources.get(resource_id)
- if resource:
- result = config_with_vision_resource(cfg, resource)
- elif not vision_resource_id:
- result = config_with_main_llm_as_vision(cfg)
- else:
- raise ValueError(f"视觉模型资源未加载:{vision_resource_id}")
- result.vision_prompt_mode = str(
- workflow_vision_scope.get("vision_prompt_mode") or "runtime"
- )
- result.vision_prompt = str(
- workflow_vision_scope.get("vision_prompt") or ""
- ).strip()
- return result
+ scope = workflow_vision_scope if cfg.type == "workflow" else None
+ return resolve_vision_input(cfg, scope).config
if cfg.type == "workflow" and vision_enabled:
async def native_flow_fetch_user_image(params: FunctionCallParams) -> None:
diff --git a/backend/services/test_assets.py b/backend/services/test_assets.py
new file mode 100644
index 0000000..f859e08
--- /dev/null
+++ b/backend/services/test_assets.py
@@ -0,0 +1,44 @@
+"""Persistent image fixtures used by repeatable assistant test cases."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from services.image_processing import normalize_image
+from services.object_storage import delete_object, get_object, put_object
+
+
+TEST_ASSET_PREFIX = "test-assets/"
+
+
+@dataclass(frozen=True)
+class StoredTestImage:
+ storage_key: str
+ width: int
+ height: int
+ size_bytes: int
+ mime_type: str = "image/jpeg"
+
+
+def store_test_image(asset_id: str, data: bytes) -> StoredTestImage:
+ normalized, width, height = normalize_image(data)
+ key = f"{TEST_ASSET_PREFIX}{asset_id}.jpg"
+ put_object(key, normalized, "image/jpeg")
+ return StoredTestImage(
+ storage_key=key,
+ width=width,
+ height=height,
+ size_bytes=len(normalized),
+ )
+
+
+def read_test_image(storage_key: str) -> bytes:
+ if not storage_key.startswith(TEST_ASSET_PREFIX):
+ raise ValueError("测试图片存储位置无效")
+ return get_object(storage_key)
+
+
+def delete_test_image(storage_key: str) -> None:
+ if not storage_key.startswith(TEST_ASSET_PREFIX):
+ raise ValueError("测试图片存储位置无效")
+ delete_object(storage_key)
diff --git a/backend/services/test_case_assets.py b/backend/services/test_case_assets.py
new file mode 100644
index 0000000..30544d8
--- /dev/null
+++ b/backend/services/test_case_assets.py
@@ -0,0 +1,72 @@
+"""Validation and loading helpers for image fixtures in test definitions."""
+
+from __future__ import annotations
+
+import asyncio
+
+from db.models import TestAsset
+from services.test_assets import read_test_image
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+from test_schemas import TestCaseDefinition
+
+
+def image_asset_ids(definition: TestCaseDefinition) -> set[str]:
+ return {
+ turn.image.asset_id
+ for turn in definition.turns
+ if turn.image is not None
+ }
+
+
+async def canonicalize_test_assets(
+ session: AsyncSession,
+ definition: TestCaseDefinition,
+) -> None:
+ """Validate references and replace client metadata with authoritative values."""
+
+ asset_ids = image_asset_ids(definition)
+ if not asset_ids:
+ return
+ rows = (
+ await session.execute(select(TestAsset).where(TestAsset.id.in_(asset_ids)))
+ ).scalars().all()
+ by_id = {row.id: row for row in rows}
+ missing = sorted(asset_ids - by_id.keys())
+ if missing:
+ raise ValueError("测试图片不存在: " + "、".join(missing))
+
+ for turn in definition.turns:
+ if turn.image is None:
+ continue
+ row = by_id[turn.image.asset_id]
+ turn.image.filename = row.original_name
+ turn.image.width = row.width
+ turn.image.height = row.height
+ turn.image.size_bytes = row.size_bytes
+
+
+async def load_test_asset_bytes(
+ session: AsyncSession,
+ definition: TestCaseDefinition,
+) -> dict[str, bytes]:
+ """Load immutable image fixtures once before a transportless test attempt."""
+
+ asset_ids = image_asset_ids(definition)
+ if not asset_ids:
+ return {}
+ rows = (
+ await session.execute(select(TestAsset).where(TestAsset.id.in_(asset_ids)))
+ ).scalars().all()
+ by_id = {row.id: row for row in rows}
+ missing = sorted(asset_ids - by_id.keys())
+ if missing:
+ raise ValueError("测试图片不存在: " + "、".join(missing))
+
+ pairs = await asyncio.gather(
+ *(
+ asyncio.to_thread(read_test_image, row.storage_key)
+ for row in rows
+ )
+ )
+ return {row.id: data for row, data in zip(rows, pairs, strict=True)}
diff --git a/backend/services/test_runs/evaluator.py b/backend/services/test_runs/evaluator.py
index 55e0d15..156d3ae 100644
--- a/backend/services/test_runs/evaluator.py
+++ b/backend/services/test_runs/evaluator.py
@@ -161,6 +161,7 @@ class EvaluationEngine:
id=expected_turn.id,
index=index,
user_input=expected_turn.user_input,
+ image=expected_turn.image,
assistant_reply="",
tool_calls=[],
runtime_records=[],
@@ -174,6 +175,7 @@ class EvaluationEngine:
id=raw.id,
index=index,
user_input=raw.user_input,
+ image=raw.image,
assistant_reply=raw.assistant_reply,
tool_calls=[item.as_result() for item in raw.tool_calls],
runtime_records=list(raw.runtime_records),
diff --git a/backend/services/test_runs/orchestrator.py b/backend/services/test_runs/orchestrator.py
index ee78b1d..63d52bc 100644
--- a/backend/services/test_runs/orchestrator.py
+++ b/backend/services/test_runs/orchestrator.py
@@ -14,6 +14,7 @@ from db.session import SessionLocal
from models import RuntimeModelResource
from services.config_resolver import resolve_runtime_config
from services.runtime_variables import prepare_dynamic_config
+from services.test_case_assets import load_test_asset_bytes
from services.test_runs.errors import TestExecutionError
from services.test_runs.evaluator import EvaluationEngine
from services.test_runs.text_runner import (
@@ -178,6 +179,19 @@ class TestRunOrchestrator:
snapshot = dict(row.case_snapshot or {})
definition = TestCaseDefinition.model_validate(snapshot)
max_attempts = row.max_attempts
+ try:
+ image_assets = await load_test_asset_bytes(session, definition)
+ except Exception as exc:
+ await self._mark_error(
+ run_case_id,
+ TestExecutionError(
+ code="TEST_IMAGE_READ_FAILED",
+ message=str(exc) or "测试图片无法读取",
+ stage="pipeline",
+ retryable=True,
+ ),
+ )
+ return
for attempt in range(1, max_attempts + 1):
if control.stop_event.is_set():
@@ -212,6 +226,7 @@ class TestRunOrchestrator:
attempt_cfg,
definition,
timeout_seconds=timeout_seconds,
+ image_assets=image_assets,
on_turn_complete=save_turn_progress,
on_startup_complete=save_startup_progress,
)
@@ -346,6 +361,11 @@ class TestRunOrchestrator:
"id": turn.id,
"index": turn.index,
"userInput": turn.user_input,
+ "image": (
+ turn.image.model_dump(mode="json", by_alias=True)
+ if turn.image is not None
+ else None
+ ),
"assistantReply": turn.assistant_reply,
"toolCalls": [item.as_result() for item in turn.tool_calls],
"runtimeRecords": list(turn.runtime_records),
diff --git a/backend/services/test_runs/text_runner.py b/backend/services/test_runs/text_runner.py
index 5555692..45c232d 100644
--- a/backend/services/test_runs/text_runner.py
+++ b/backend/services/test_runs/text_runner.py
@@ -55,7 +55,8 @@ from services.pipecat.service_factory import config_with_resource
from services.pipecat.workflow_services import build_workflow_llm_switcher
from services.test_runs.errors import TestExecutionError
from services.test_runs.mock_tools import MockToolExecutor, TextClientToolPort
-from test_schemas import ContextTurn, FixedInputTurn, TestCaseDefinition
+from services.vision import build_image_user_message
+from test_schemas import ContextTurn, FixedInputTurn, TestCaseDefinition, TestImageRef
IDLE_SETTLE_SECONDS = 0.2
@@ -104,6 +105,7 @@ class RawTurnResult:
user_input: str
assistant_reply: str
tool_calls: list[RawToolCall]
+ image: TestImageRef | None = None
runtime_records: list[dict[str, Any]] = field(default_factory=list)
@@ -359,6 +361,31 @@ def _context_messages(context_turns: list[ContextTurn]) -> list[dict[str, Any]]:
return messages
+def _transcript_content(message: dict[str, Any]) -> str:
+ """Keep evaluator transcripts readable without persisting image data URIs."""
+
+ content = message.get("content")
+ if isinstance(content, str):
+ return content.strip()
+ if not isinstance(content, list):
+ return ""
+ text_parts = [
+ str(part.get("text") or "").strip()
+ for part in content
+ if isinstance(part, dict)
+ and part.get("type") in {"text", "input_text"}
+ and str(part.get("text") or "").strip()
+ ]
+ has_image = any(
+ isinstance(part, dict)
+ and part.get("type") in {"image_url", "input_image"}
+ for part in content
+ )
+ if has_image:
+ text_parts.append("[用户提交了一张测试图片]")
+ return "\n".join(text_parts).strip()
+
+
async def _wait_for_quiescence(
capture: _TextCaptureProcessor,
runner_task: asyncio.Task[None],
@@ -423,6 +450,7 @@ class TextPipelineRunner:
definition: TestCaseDefinition,
*,
timeout_seconds: int,
+ image_assets: dict[str, bytes] | None = None,
on_turn_complete: TurnCompleteCallback | None = None,
on_startup_complete: StartupCompleteCallback | None = None,
) -> TextCaseResult:
@@ -431,6 +459,7 @@ class TextPipelineRunner:
return await self._run(
cfg,
definition,
+ image_assets=image_assets or {},
on_turn_complete=on_turn_complete,
on_startup_complete=on_startup_complete,
)
@@ -447,6 +476,7 @@ class TextPipelineRunner:
cfg: AssistantConfig,
definition: TestCaseDefinition,
*,
+ image_assets: dict[str, bytes],
on_turn_complete: TurnCompleteCallback | None,
on_startup_complete: StartupCompleteCallback | None,
) -> TextCaseResult:
@@ -545,6 +575,7 @@ class TextPipelineRunner:
executor_holder: dict[str, MockToolExecutor] = {}
text_client_tools = TextClientToolPort()
+ workflow_vision_scope: dict[str, Any] = {"enabled": False}
def create_test_executor(store) -> MockToolExecutor:
executor = MockToolExecutor(store)
@@ -699,7 +730,7 @@ class TextPipelineRunner:
transport=None,
switch_services=switch_services,
set_knowledge_scope=knowledge.set_scope,
- set_vision_scope=lambda _scope: None,
+ set_vision_scope=lambda scope: workflow_vision_scope.update(scope),
vision_function=None,
set_input_enabled=lambda _enabled: None,
apply_turn_config=lambda _enabled, _config: asyncio.sleep(0),
@@ -809,26 +840,32 @@ class TextPipelineRunner:
retryable=False,
)
result = await self._run_turn(
+ cfg,
worker,
runner_task,
capture,
executor,
turn,
index,
+ image_assets=image_assets,
+ workflow_vision_scope=workflow_vision_scope,
)
results.append(result)
if on_turn_complete is not None:
await on_turn_complete(result)
- transcript = [
- {
- "role": str(message.get("role") or ""),
- "content": str(message.get("content") or ""),
- }
- for message in context.get_messages()
- if message.get("role") in {"user", "assistant"}
- and str(message.get("content") or "").strip()
- ]
+ transcript = []
+ for message in context.get_messages():
+ if message.get("role") not in {"user", "assistant"}:
+ continue
+ content = _transcript_content(message)
+ if content:
+ transcript.append(
+ {
+ "role": str(message.get("role") or ""),
+ "content": content,
+ }
+ )
return TextCaseResult(
turns=results,
transcript=transcript,
@@ -853,12 +890,16 @@ class TextPipelineRunner:
async def _run_turn(
self,
+ cfg: AssistantConfig,
worker: PipelineWorker,
runner_task: asyncio.Task[None],
capture: _TextCaptureProcessor,
executor: MockToolExecutor,
turn: FixedInputTurn,
index: int,
+ *,
+ image_assets: dict[str, bytes],
+ workflow_vision_scope: dict[str, Any],
) -> RawTurnResult:
tool_behaviors = [
behavior for behavior in turn.behaviors if behavior.type == "tool_call"
@@ -868,11 +909,37 @@ class TextPipelineRunner:
output_marker = len(capture.outputs)
tool_marker = len(capture.tool_calls)
runtime_record_marker = len(capture.runtime_records)
+ user_message: dict[str, Any] = {
+ "role": "user",
+ "content": turn.user_input,
+ }
+ if turn.image is not None:
+ image_bytes = image_assets.get(turn.image.asset_id)
+ if image_bytes is None:
+ raise TestExecutionError(
+ code="TEST_IMAGE_MISSING",
+ message=f"测试图片不存在: {turn.image.asset_id}",
+ stage="pipeline",
+ retryable=False,
+ )
+ try:
+ user_message = await build_image_user_message(
+ cfg,
+ image_bytes,
+ turn.user_input,
+ workflow_scope=(
+ workflow_vision_scope if cfg.type == "workflow" else None
+ ),
+ )
+ except ValueError as exc:
+ raise TestExecutionError(
+ code="TEST_IMAGE_UNSUPPORTED",
+ message=str(exc),
+ stage="model",
+ retryable=False,
+ ) from exc
await worker.queue_frame(
- LLMMessagesAppendFrame(
- messages=[{"role": "user", "content": turn.user_input}],
- run_llm=True,
- )
+ LLMMessagesAppendFrame(messages=[user_message], run_llm=True)
)
await _wait_for_quiescence(capture, runner_task, activity_marker)
if executor.unmocked_calls:
@@ -886,6 +953,7 @@ class TextPipelineRunner:
id=turn.id,
index=index,
user_input=turn.user_input,
+ image=turn.image,
assistant_reply="\n\n".join(capture.outputs[output_marker:]).strip(),
tool_calls=list(capture.tool_calls[tool_marker:]),
runtime_records=list(
diff --git a/backend/services/vision.py b/backend/services/vision.py
index d038c6b..08ac1aa 100644
--- a/backend/services/vision.py
+++ b/backend/services/vision.py
@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
import base64
+from dataclasses import dataclass
from io import BytesIO
from typing import Any
@@ -30,6 +31,14 @@ VISION_ANALYSIS_SYSTEM_PROMPT = (
)
+@dataclass(frozen=True)
+class VisionInputResolution:
+ """The active model path for one explicit user image turn."""
+
+ native: bool
+ config: AssistantConfig
+
+
def _require(value: str, label: str) -> str:
if value:
return value
@@ -246,3 +255,83 @@ def config_with_main_llm_as_vision(cfg: AssistantConfig) -> AssistantConfig:
result.vision_llm_api_key = cfg.llm_api_key
result.vision_llm_base_url = cfg.llm_base_url
return result
+
+
+def resolve_vision_input(
+ cfg: AssistantConfig,
+ workflow_scope: dict[str, Any] | None = None,
+) -> VisionInputResolution:
+ """Resolve native versus auxiliary vision using the active runtime scope."""
+
+ if cfg.type != "workflow":
+ if not cfg.vision_enabled:
+ raise ValueError("当前助手未开启视觉能力")
+ if cfg.vision_model_resource_id:
+ if not cfg.vision_llm_support_image_input:
+ raise ValueError("当前视觉模型不支持图片输入")
+ return VisionInputResolution(native=False, config=cfg)
+ if not cfg.llm_support_image_input:
+ raise ValueError("当前大语言模型不支持图片输入")
+ return VisionInputResolution(native=True, config=cfg)
+
+ scope = workflow_scope or {}
+ if not scope.get("enabled"):
+ raise ValueError("当前 Workflow Agent 节点未启用视觉能力")
+ vision_resource_id = str(scope.get("vision_model_resource_id") or "")
+ llm_resource_id = str(scope.get("llm_resource_id") or "")
+ resource_id = vision_resource_id or llm_resource_id
+ resource = cfg.workflow_model_resources.get(resource_id)
+ if resource is not None:
+ if not resource.support_image_input:
+ raise ValueError(f"当前视觉模型不支持图片输入: {resource.id}")
+ result = config_with_vision_resource(cfg, resource)
+ elif not vision_resource_id:
+ result = config_with_main_llm_as_vision(cfg)
+ else:
+ raise ValueError(f"视觉模型资源未加载: {vision_resource_id}")
+
+ result.vision_prompt_mode = normalize_vision_prompt_mode(
+ scope.get("vision_prompt_mode")
+ )
+ result.vision_prompt = str(scope.get("vision_prompt") or "").strip()
+ return VisionInputResolution(
+ native=not bool(vision_resource_id),
+ config=result,
+ )
+
+
+async def build_image_user_message(
+ cfg: AssistantConfig,
+ image_bytes: bytes,
+ prompt_text: str,
+ *,
+ workflow_scope: dict[str, Any] | None = None,
+) -> dict[str, Any]:
+ """Build the same model-facing user message used by an explicit photo turn."""
+
+ question = prompt_text.strip() or "请根据用户刚提交的图片进行回复。"
+ frame = await asyncio.to_thread(image_frame_from_jpeg, image_bytes)
+ resolution = resolve_vision_input(cfg, workflow_scope)
+ if resolution.native:
+ data_uri = await asyncio.to_thread(image_data_uri, frame)
+ return {
+ "role": "user",
+ "content": [
+ {"type": "text", "text": question},
+ {"type": "image_url", "image_url": {"url": data_uri}},
+ ],
+ }
+
+ observation = await analyze_image_with_vision_model(
+ resolution.config,
+ frame,
+ question,
+ )
+ return {
+ "role": "user",
+ "content": (
+ f"{question}\n\n"
+ "[视觉模型对用户刚提交图片的观察]\n"
+ f"{observation or '视觉模型没有返回有效观察结果。'}"
+ ),
+ }
diff --git a/backend/test_schemas.py b/backend/test_schemas.py
index 60a7880..1fb1c77 100644
--- a/backend/test_schemas.py
+++ b/backend/test_schemas.py
@@ -143,20 +143,36 @@ ExpectedBehavior = Annotated[
]
-class FixedInputTurn(CamelModel):
- id: str = Field(min_length=1, max_length=128)
- user_input: str = Field(min_length=1, max_length=20_000)
- behaviors: list[ExpectedBehavior] = Field(default_factory=list, max_length=100)
+class TestImageRef(CamelModel):
+ asset_id: str = Field(min_length=1, max_length=40)
+ filename: str = Field(default="image.jpg", max_length=255)
+ width: int = Field(ge=1)
+ height: int = Field(ge=1)
+ size_bytes: int = Field(default=0, ge=0)
- @field_validator("user_input")
+ @field_validator("asset_id", "filename")
@classmethod
- def strip_user_input(cls, value: str) -> str:
+ def strip_image_text(cls, value: str) -> str:
value = value.strip()
if not value:
- raise ValueError("用户输入不能为空")
+ raise ValueError("图片资源信息不完整")
return value
+class FixedInputTurn(CamelModel):
+ id: str = Field(min_length=1, max_length=128)
+ user_input: str = Field(default="", max_length=20_000)
+ image: TestImageRef | None = None
+ behaviors: list[ExpectedBehavior] = Field(default_factory=list, max_length=100)
+
+ @model_validator(mode="after")
+ def validate_input(self):
+ self.user_input = self.user_input.strip()
+ if not self.user_input and self.image is None:
+ raise ValueError("用户输入和图片至少填写一项")
+ return self
+
+
class OverallCriterion(CamelModel):
id: str = Field(min_length=1, max_length=128)
type: Literal["llm"] = "llm"
@@ -272,7 +288,7 @@ class TestCaseWrite(CamelModel):
@model_validator(mode="after")
def validate_mvp_definition(self):
if self.input_mode != "fixed_script_text":
- raise ValueError("第一版只支持固定脚本 · 文字")
+ raise ValueError("第一版只支持固定脚本 · 多模态")
TestCaseDefinition(
context_turns=self.context_turns,
turns=self.turns,
@@ -362,6 +378,7 @@ class BatchTurnResult(CamelModel):
id: str
index: int
user_input: str
+ image: TestImageRef | None = None
assistant_reply: str
tool_calls: list[BatchToolCallRecord] = Field(default_factory=list)
runtime_records: list[dict[str, Any]] = Field(default_factory=list)
diff --git a/backend/tests/test_pipeline_knowledge.py b/backend/tests/test_pipeline_knowledge.py
index de32af2..f5cbdfb 100644
--- a/backend/tests/test_pipeline_knowledge.py
+++ b/backend/tests/test_pipeline_knowledge.py
@@ -9,8 +9,8 @@ from services.pipecat.pipeline import (
KnowledgeRetrievalProcessor,
UserTurnRoutingProcessor,
_knowledge_tool_description,
- _workflow_vision_uses_main_llm,
)
+from services.vision import resolve_vision_input
class KnowledgeToolDescriptionTest(unittest.TestCase):
@@ -159,29 +159,36 @@ class WorkflowVisionModeTest(unittest.TestCase):
capability="LLM",
interface_type="openai-llm",
support_image_input=True,
- )
+ ),
+ "auxiliary_vision": RuntimeModelResource(
+ id="auxiliary_vision",
+ name="独立视觉模型",
+ capability="LLM",
+ interface_type="openai-llm",
+ support_image_input=True,
+ ),
},
)
self.assertTrue(
- _workflow_vision_uses_main_llm(
+ resolve_vision_input(
cfg,
{
"enabled": True,
"llm_resource_id": "agent_llm",
"vision_model_resource_id": None,
},
- )
+ ).native
)
self.assertFalse(
- _workflow_vision_uses_main_llm(
+ resolve_vision_input(
cfg,
{
"enabled": True,
"llm_resource_id": "agent_llm",
"vision_model_resource_id": "auxiliary_vision",
},
- )
+ ).native
)
def test_rejects_a_non_visual_active_agent_llm(self):
@@ -199,7 +206,7 @@ class WorkflowVisionModeTest(unittest.TestCase):
)
with self.assertRaisesRegex(ValueError, "不支持图片输入"):
- _workflow_vision_uses_main_llm(
+ resolve_vision_input(
cfg,
{
"enabled": True,
diff --git a/backend/tests/test_test_image_inputs.py b/backend/tests/test_test_image_inputs.py
new file mode 100644
index 0000000..1fe7fbe
--- /dev/null
+++ b/backend/tests/test_test_image_inputs.py
@@ -0,0 +1,118 @@
+from io import BytesIO
+import unittest
+from unittest.mock import AsyncMock, patch
+
+from PIL import Image
+from pydantic import ValidationError
+
+from models import AssistantConfig
+from services.test_assets import read_test_image, store_test_image
+from services.test_runs.text_runner import _transcript_content
+from services.vision import build_image_user_message
+from test_schemas import FixedInputTurn, TestImageRef
+
+
+def _jpeg_bytes() -> bytes:
+ buffer = BytesIO()
+ Image.new("RGB", (12, 8), (220, 40, 40)).save(buffer, format="JPEG")
+ return buffer.getvalue()
+
+
+def _image_ref() -> TestImageRef:
+ return TestImageRef(
+ asset_id="ta_fixture",
+ filename="fixture.jpg",
+ width=12,
+ height=8,
+ size_bytes=128,
+ )
+
+
+class TestImageSchemaTests(unittest.TestCase):
+ def test_allows_image_only_turn(self):
+ turn = FixedInputTurn(id="turn_1", image=_image_ref())
+
+ self.assertEqual(turn.user_input, "")
+ self.assertEqual(turn.image.asset_id, "ta_fixture")
+
+ def test_rejects_turn_without_text_or_image(self):
+ with self.assertRaisesRegex(ValidationError, "至少填写一项"):
+ FixedInputTurn(id="turn_1")
+
+
+class PersistentTestAssetTests(unittest.TestCase):
+ def test_normalizes_and_stores_under_test_prefix(self):
+ with patch("services.test_assets.put_object") as put_object:
+ stored = store_test_image("ta_fixture", _jpeg_bytes())
+
+ self.assertEqual(stored.storage_key, "test-assets/ta_fixture.jpg")
+ self.assertEqual((stored.width, stored.height), (12, 8))
+ self.assertEqual(put_object.call_args.args[0], stored.storage_key)
+ self.assertEqual(put_object.call_args.args[2], "image/jpeg")
+
+ def test_reads_only_test_asset_keys(self):
+ with patch("services.test_assets.get_object", return_value=b"jpeg"):
+ self.assertEqual(read_test_image("test-assets/ta_fixture.jpg"), b"jpeg")
+ with self.assertRaisesRegex(ValueError, "存储位置无效"):
+ read_test_image("conversation-inputs/unsafe.jpg")
+
+
+class ImageUserMessageTests(unittest.IsolatedAsyncioTestCase):
+ async def test_native_model_receives_multimodal_message(self):
+ message = await build_image_user_message(
+ AssistantConfig(
+ type="prompt",
+ vision_enabled=True,
+ llm_support_image_input=True,
+ ),
+ _jpeg_bytes(),
+ "这是什么颜色?",
+ )
+
+ self.assertEqual(message["role"], "user")
+ self.assertEqual(message["content"][0]["text"], "这是什么颜色?")
+ self.assertTrue(
+ message["content"][1]["image_url"]["url"].startswith(
+ "data:image/jpeg;base64,"
+ )
+ )
+
+ async def test_auxiliary_model_observation_becomes_text_context(self):
+ with patch(
+ "services.vision.analyze_image_with_vision_model",
+ new=AsyncMock(return_value="画面中是一个红色方块。"),
+ ):
+ message = await build_image_user_message(
+ AssistantConfig(
+ type="prompt",
+ vision_enabled=True,
+ vision_model_resource_id="vision_model",
+ vision_llm_support_image_input=True,
+ ),
+ _jpeg_bytes(),
+ "画面里有什么?",
+ )
+
+ self.assertIsInstance(message["content"], str)
+ self.assertIn("红色方块", message["content"])
+
+ def test_transcript_does_not_persist_image_data(self):
+ content = _transcript_content(
+ {
+ "role": "user",
+ "content": [
+ {"type": "text", "text": "请看图"},
+ {
+ "type": "image_url",
+ "image_url": {"url": "data:image/jpeg;base64,secret"},
+ },
+ ],
+ }
+ )
+
+ self.assertEqual(content, "请看图\n[用户提交了一张测试图片]")
+ self.assertNotIn("base64", content)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/frontend/src/components/assistant-editor/debug-auto-test.tsx b/frontend/src/components/assistant-editor/debug-auto-test.tsx
index 193933f..c769528 100644
--- a/frontend/src/components/assistant-editor/debug-auto-test.tsx
+++ b/frontend/src/components/assistant-editor/debug-auto-test.tsx
@@ -51,6 +51,7 @@ import {
import {
batchRunsApi,
modelResourcesApi,
+ testAssetsApi,
testCasesApi,
testSuitesApi,
type ModelResource,
@@ -101,7 +102,7 @@ function isRunFinished(run: BatchRunSnapshot): boolean {
function runnableMessage(item: TestCase): string {
if (item.inputMode !== "fixed_script_text") {
- return "当前自动测试仅支持固定脚本 · 文字";
+ return "当前自动测试仅支持固定脚本 · 多模态";
}
return getTestCaseValidationMessage(item) ?? "";
}
@@ -653,6 +654,16 @@ function AutoTestTimeline({
role: "user",
label: `固定输入 · ${turn.index + 1} / ${maxTurns}`,
content: turn.userInput,
+ attachments: turn.image
+ ? [
+ {
+ id: turn.image.assetId,
+ type: "image",
+ url: testAssetsApi.contentUrl(turn.image.assetId),
+ alt: turn.image.filename,
+ },
+ ]
+ : undefined,
},
});
items.push({
diff --git a/frontend/src/components/batch-test/batch-case-detail-drawer.tsx b/frontend/src/components/batch-test/batch-case-detail-drawer.tsx
index ef8f62f..ddd3ed5 100644
--- a/frontend/src/components/batch-test/batch-case-detail-drawer.tsx
+++ b/frontend/src/components/batch-test/batch-case-detail-drawer.tsx
@@ -28,6 +28,7 @@ import {
type BatchToolCallRecord,
type BatchTurnResult,
} from "@/data/batch-run";
+import { testAssetsApi } from "@/lib/api";
import { cn } from "@/lib/utils";
type BatchCaseDetailDrawerProps = {
@@ -98,7 +99,7 @@ export function BatchCaseDetailDrawer({
执行契约
- 正在执行固定文字脚本;完成后将展示每一轮回复、工具调用和全部评估标准。
+ 正在执行固定多模态脚本;完成后将展示每轮回复、工具调用和评估标准。
@@ -235,9 +236,19 @@ function TurnResultCard({ turn }: { turn: BatchTurnResult }) {
用户输入
-
- {turn.userInput}
-
+
+ {turn.image ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
})
+ ) : null}
+ {turn.userInput ? (
+
{turn.userInput}
+ ) : null}
+
Agent 回复
{turn.assistantReply}
diff --git a/frontend/src/components/pages/TestCasesPage.tsx b/frontend/src/components/pages/TestCasesPage.tsx
index 4f8ef69..5fe9b36 100644
--- a/frontend/src/components/pages/TestCasesPage.tsx
+++ b/frontend/src/components/pages/TestCasesPage.tsx
@@ -112,6 +112,8 @@ function SuiteListView() {
}
useEffect(() => {
+ // Initial API hydration is intentionally owned by this mount effect.
+ // eslint-disable-next-line react-hooks/set-state-in-effect
void reloadSuites();
}, []);
@@ -507,6 +509,8 @@ function SuiteDetailView({
}
useEffect(() => {
+ // Suite changes remount this view; hydrate the selected case once.
+ // eslint-disable-next-line react-hooks/set-state-in-effect
void reload(initialCaseId ?? null);
// suiteId changes by remount; the initial request only runs once per view.
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -931,7 +935,7 @@ function SuiteDetailView({
{TEST_CASE_INPUT_MODE_LABEL[draft.inputMode]} · 开发中
- 当前仅支持「固定脚本 · 文字」编辑器,其它输入模式稍后接入。
+ 当前仅支持「固定脚本 · 多模态」编辑器,其它输入模式稍后接入。
)}
diff --git a/frontend/src/components/test-cases/input-mode-picker.tsx b/frontend/src/components/test-cases/input-mode-picker.tsx
index 6f2de90..beff4d9 100644
--- a/frontend/src/components/test-cases/input-mode-picker.tsx
+++ b/frontend/src/components/test-cases/input-mode-picker.tsx
@@ -37,8 +37,8 @@ const FIXED_SCRIPT_OPTIONS: {
}[] = [
{
value: "fixed_script_text",
- title: "文字",
- description: "使用固定文字逐轮测试",
+ title: "文字与图片",
+ description: "使用固定文字或图片逐轮测试",
icon: Type,
available: true,
},
diff --git a/frontend/src/components/test-cases/next-reply-editor.tsx b/frontend/src/components/test-cases/next-reply-editor.tsx
index fe600a0..2585f60 100644
--- a/frontend/src/components/test-cases/next-reply-editor.tsx
+++ b/frontend/src/components/test-cases/next-reply-editor.tsx
@@ -2,7 +2,7 @@
/**
* 测试用例编辑器主体:
- * 对话上下文 → 固定文字脚本轮次 → 整体评估标准。
+ * 对话上下文 → 固定多模态脚本轮次 → 整体评估标准。
*/
import {
@@ -26,6 +26,8 @@ import {
ChevronDown,
ChevronUp,
GripVertical,
+ ImagePlus,
+ Loader2,
MessageSquareText,
MessagesSquare,
Plus,
@@ -71,12 +73,13 @@ import {
type KeywordMatchMode,
type OverallCriterion,
type ReplyExpectedBehavior,
+ type TestImageRef,
type TestCaseInputMode,
type ToolCallExpectedBehavior,
type ToolParamAssertion,
type ToolParamMatchMode,
} from "@/data/test-suites";
-import { toolsApi, type Tool } from "@/lib/api";
+import { testAssetsApi, toolsApi, type Tool } from "@/lib/api";
import { cn } from "@/lib/utils";
export type CaseEditorDraft = {
@@ -637,6 +640,9 @@ export function NextReplyEditorBody({
onChangeUserInput={(userInput) =>
updateTurn(turn.id, { userInput })
}
+ onChangeImage={(image) =>
+ updateTurn(turn.id, { image })
+ }
onAddReply={() =>
addBehavior(turn.id, createReplyBehavior("llm"))
}
@@ -812,6 +818,7 @@ function SortableFixedInputTurn({
expandedBehaviorIds,
onToggleBehavior,
onChangeUserInput,
+ onChangeImage,
onAddReply,
onAddToolCall,
onUpdateBehavior,
@@ -825,6 +832,7 @@ function SortableFixedInputTurn({
expandedBehaviorIds: Set;
onToggleBehavior: (behaviorId: string) => void;
onChangeUserInput: (value: string) => void;
+ onChangeImage: (value: TestImageRef | null) => void;
onAddReply: () => void;
onAddToolCall: () => void;
onUpdateBehavior: (behavior: ExpectedBehavior) => void;
@@ -840,6 +848,34 @@ function SortableFixedInputTurn({
isDragging,
} = useSortable({ id: turn.id });
const [addOpen, setAddOpen] = useState(false);
+ const fileInputRef = useRef(null);
+ const [uploadingImage, setUploadingImage] = useState(false);
+ const [imageError, setImageError] = useState("");
+
+ async function uploadImage(file: File | undefined) {
+ if (!file || uploadingImage) return;
+ if (!file.type.startsWith("image/")) {
+ setImageError("请选择图片文件");
+ return;
+ }
+ setUploadingImage(true);
+ setImageError("");
+ try {
+ const asset = await testAssetsApi.uploadImage(file);
+ onChangeImage({
+ assetId: asset.id,
+ filename: asset.filename,
+ width: asset.width,
+ height: asset.height,
+ sizeBytes: asset.sizeBytes,
+ });
+ } catch (error) {
+ setImageError(error instanceof Error ? error.message : "图片上传失败");
+ } finally {
+ setUploadingImage(false);
+ if (fileInputRef.current) fileInputRef.current.value = "";
+ }
+ }
const style = {
transform: CSS.Transform.toString(transform),
@@ -888,17 +924,108 @@ function SortableFixedInputTurn({
-
+
{
+ if (event.dataTransfer.types.includes("Files")) event.preventDefault();
+ }}
+ onDrop={(event) => {
+ const file = Array.from(event.dataTransfer.files).find((item) =>
+ item.type.startsWith("image/"),
+ );
+ if (!file) return;
+ event.preventDefault();
+ void uploadImage(file);
+ }}
+ >
用户输入
diff --git a/frontend/src/data/batch-run.ts b/frontend/src/data/batch-run.ts
index d5a66ba..5520483 100644
--- a/frontend/src/data/batch-run.ts
+++ b/frontend/src/data/batch-run.ts
@@ -54,6 +54,13 @@ export type BatchTurnResult = {
id: string;
index: number;
userInput: string;
+ image: {
+ assetId: string;
+ filename: string;
+ width: number;
+ height: number;
+ sizeBytes: number;
+ } | null;
assistantReply: string;
toolCalls: BatchToolCallRecord[];
runtimeRecords?: Array>;
diff --git a/frontend/src/data/test-suites.ts b/frontend/src/data/test-suites.ts
index 3c6a22a..90c6a6f 100644
--- a/frontend/src/data/test-suites.ts
+++ b/frontend/src/data/test-suites.ts
@@ -1,7 +1,7 @@
/**
* 测试集 / 测试用例的前后端共享契约与编辑器辅助函数。
* 两层:Test Suite → Test Case。
- * MVP 仅运行固定文字脚本;其它输入模式保留为产品路线提示。
+ * MVP 运行固定文字/图片脚本;其它输入模式保留为产品路线提示。
*/
/** 顶部模式选择器的唯一来源;不再单独维护「测试类型」字段 */
@@ -80,6 +80,14 @@ export type ToolCallExpectedBehavior = {
export type ExpectedBehavior = ReplyExpectedBehavior | ToolCallExpectedBehavior;
+export type TestImageRef = {
+ assetId: string;
+ filename: string;
+ width: number;
+ height: number;
+ sizeBytes: number;
+};
+
/**
* 固定输入的一轮:只编辑 User 输入;Agent 回复 / Tool Call 由运行时生成。
* behaviors 为 0~N 个预期行为(AND);空数组表示仅推动对话、不做断言。
@@ -87,6 +95,7 @@ export type ExpectedBehavior = ReplyExpectedBehavior | ToolCallExpectedBehavior;
export type FixedInputTurn = {
id: string;
userInput: string;
+ image: TestImageRef | null;
behaviors: ExpectedBehavior[];
};
@@ -134,7 +143,7 @@ export const DEFAULT_INPUT_MODE: TestCaseInputMode = "fixed_script_text";
/**
* MVP 可运行性规则。
- * 目前执行器只支持固定文字;每一轮都需要用户输入,并至少配置一项断言。
+ * 目前执行器支持固定文字和单张图片;每轮至少有一种输入,并配置断言。
*/
export function getTestCaseValidationMessage(
item: Pick<
@@ -219,10 +228,10 @@ export function getTestCaseValidationMessage(
.join("、")}`;
}
const emptyTurnIndex = item.turns.findIndex(
- (turn) => !turn.userInput.trim(),
+ (turn) => !turn.userInput.trim() && !turn.image,
);
if (emptyTurnIndex >= 0) {
- return `第 ${emptyTurnIndex + 1} 轮用户输入不能为空`;
+ return `第 ${emptyTurnIndex + 1} 轮需要填写用户文字或添加图片`;
}
for (let turnIndex = 0; turnIndex < item.turns.length; turnIndex += 1) {
const turn = item.turns[turnIndex];
@@ -267,7 +276,7 @@ export function isTestCaseRunnable(
}
export const TEST_CASE_INPUT_MODE_LABEL: Record = {
- fixed_script_text: "固定脚本 · 文字",
+ fixed_script_text: "固定脚本 · 多模态",
fixed_script_turn_voice: "固定脚本 · 逐轮语音",
fixed_script_continuous_voice: "固定脚本 · 连续语音",
user_sim_text: "用户模拟 · 文字",
@@ -279,7 +288,7 @@ export const TEST_CASE_INPUT_MODE_SHORT_LABEL: Record<
TestCaseInputMode,
string
> = {
- fixed_script_text: "固定文字",
+ fixed_script_text: "固定输入",
fixed_script_turn_voice: "逐轮语音",
fixed_script_continuous_voice: "连续语音",
user_sim_text: "模拟文字",
@@ -412,6 +421,7 @@ export function createEmptyFixedInputTurn(
return {
id: newEditorId("turn"),
userInput,
+ image: null,
behaviors: [],
};
}
@@ -484,6 +494,7 @@ export function cloneTurns(turns: FixedInputTurn[]): FixedInputTurn[] {
return turns.map((turn) => ({
id: turn.id,
userInput: turn.userInput,
+ image: turn.image ? { ...turn.image } : null,
behaviors: turn.behaviors.map(cloneBehavior),
}));
}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 93848a2..6015d75 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -854,6 +854,33 @@ export type TestCaseWrite = {
overallCriteria: OverallCriterion[];
};
+export type TestImageAsset = {
+ id: string;
+ filename: string;
+ width: number;
+ height: number;
+ sizeBytes: number;
+ contentUrl: string;
+};
+
+export const testAssetsApi = {
+ uploadImage: (file: File) => {
+ const body = new FormData();
+ body.append("file", file);
+ return request("/api/test-assets/images", {
+ method: "POST",
+ body,
+ });
+ },
+ contentUrl: (assetId: string) =>
+ `${API_BASE}/api/test-assets/${encodeURIComponent(assetId)}/content`,
+ remove: (assetId: string) =>
+ request<{ ok: boolean }>(
+ `/api/test-assets/${encodeURIComponent(assetId)}`,
+ { method: "DELETE" },
+ ),
+};
+
export const testSuitesApi = {
list: () => request("/api/test-suites"),
get: (id: string) => request(`/api/test-suites/${id}`),