From 09b07751b6309bf796d15b2c5aaab4eb996f54cf Mon Sep 17 00:00:00 2001
From: Xin Wang
Date: Wed, 12 Aug 2026 17:29:30 +0800
Subject: [PATCH] feat: add configurable vision focus prompts
---
backend/db/models.py | 1 +
.../20260812_0019_add_vision_focus_prompt.py | 32 +++++
backend/models.py | 1 +
backend/routes/assistants.py | 50 ++++++-
backend/schemas.py | 10 ++
backend/services/config_resolver.py | 1 +
backend/services/node_specs.py | 2 +
backend/services/pipecat/pipeline.py | 24 ++--
backend/services/vision.py | 92 ++++++++++++-
backend/services/workflow/agent.py | 8 +-
backend/services/workflow/realtime.py | 11 +-
backend/services/workflow_engine.py | 2 +
backend/tests/test_vision_focus.py | 126 ++++++++++++++++++
.../assistant-editor/prompt-editor.tsx | 6 +
.../src/components/assistant-editor/types.ts | 1 +
.../editor/vision-config-section.tsx | 85 +++++++++++-
.../src/components/pages/AssistantPage.tsx | 5 +
.../components/pages/CreateAssistantPage.tsx | 1 +
.../components/pages/TemplateLibraryPage.tsx | 1 +
.../components/workflow/WorkflowCanvas.tsx | 1 +
.../src/components/workflow/graph-settings.ts | 2 +
.../workflow/panels/AgentNodePanel.tsx | 12 ++
.../workflow/panels/GlobalSettingsPanel.tsx | 10 ++
frontend/src/components/workflow/specs.ts | 3 +
frontend/src/components/workflow/types.ts | 1 +
frontend/src/lib/api.ts | 9 ++
26 files changed, 477 insertions(+), 20 deletions(-)
create mode 100644 backend/migrations/versions/20260812_0019_add_vision_focus_prompt.py
create mode 100644 backend/tests/test_vision_focus.py
diff --git a/backend/db/models.py b/backend/db/models.py
index d832003..2e6baa8 100644
--- a/backend/db/models.py
+++ b/backend/db/models.py
@@ -160,6 +160,7 @@ class Assistant(Base):
ForeignKey("model_resources.id", ondelete="SET NULL"),
nullable=True,
)
+ vision_focus_prompt: Mapped[str] = mapped_column(Text, default="")
# KB 引用:被引用时禁止删 KB(RESTRICT),无默认兜底
knowledge_base_id: Mapped[str | None] = mapped_column(
diff --git a/backend/migrations/versions/20260812_0019_add_vision_focus_prompt.py b/backend/migrations/versions/20260812_0019_add_vision_focus_prompt.py
new file mode 100644
index 0000000..cca2b0d
--- /dev/null
+++ b/backend/migrations/versions/20260812_0019_add_vision_focus_prompt.py
@@ -0,0 +1,32 @@
+"""add assistant vision focus prompt
+
+Revision ID: 20260812_0019
+Revises: 20260810_0018
+"""
+
+from collections.abc import Sequence
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = "20260812_0019"
+down_revision: str | Sequence[str] | None = "20260810_0018"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ op.add_column(
+ "assistants",
+ sa.Column(
+ "vision_focus_prompt",
+ sa.Text(),
+ server_default=sa.text("''"),
+ nullable=False,
+ ),
+ )
+
+
+def downgrade() -> None:
+ op.drop_column("assistants", "vision_focus_prompt")
diff --git a/backend/models.py b/backend/models.py
index 07df225..f47a36e 100644
--- a/backend/models.py
+++ b/backend/models.py
@@ -92,6 +92,7 @@ class AssistantConfig(BaseModel):
llm_support_image_input: bool = False
vision_enabled: bool = False
vision_model_resource_id: str | None = None
+ vision_focus_prompt: str = ""
vision_model: str = ""
vision_llm_interface_type: str = "openai-llm"
vision_llm_values: dict = {}
diff --git a/backend/routes/assistants.py b/backend/routes/assistants.py
index a7ad45f..2e0ca67 100644
--- a/backend/routes/assistants.py
+++ b/backend/routes/assistants.py
@@ -2,7 +2,7 @@
import uuid
-from models import RuntimeTool
+from models import RuntimeModelResource, RuntimeTool
from db.models import (
Assistant,
AssistantModelBinding,
@@ -13,12 +13,18 @@ from db.models import (
)
from db.session import get_session
from fastapi import APIRouter, Depends, HTTPException
-from schemas import AssistantOut, AssistantUpsert
+from schemas import (
+ AssistantOut,
+ AssistantUpsert,
+ VisionFocusPromptGenerateRequest,
+ VisionFocusPromptGenerateResult,
+)
from services.auth import require_admin
from services.masking import mask, resolve_incoming_key
from services.node_specs import graph_references, normalize_graph, validate_graph
from services.system_tools import system_tool_kind
from services.tool_policy import policy_for_tool
+from services.vision import generate_vision_focus_prompt
from services.workflow_engine import WorkflowEngine
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -73,6 +79,7 @@ def _validate_workflow(body: AssistantUpsert) -> None:
# capability summary used by clients to decide whether to open the camera.
body.vision_enabled = WorkflowEngine(body.graph).uses_vision()
body.vision_model_resource_id = None
+ body.vision_focus_prompt = ""
refs = graph_references(body.graph)
body.tool_ids = list(dict.fromkeys([*body.tool_ids, *sorted(refs["tools"])]))
@@ -389,6 +396,7 @@ async def _to_out(session: AsyncSession, assistant: Assistant) -> AssistantOut:
startup=assistant.startup or {},
vision_enabled=assistant.vision_enabled,
vision_model_resource_id=assistant.vision_model_resource_id,
+ vision_focus_prompt=assistant.vision_focus_prompt or "",
model_resource_ids=await _resource_ids(session, assistant.id),
knowledge_base_id=assistant.knowledge_base_id,
knowledge_retrieval_config=assistant.knowledge_retrieval_config or {},
@@ -440,6 +448,43 @@ async def create_assistant(
return await _to_out(session, assistant)
+@router.post(
+ "/vision-focus/generate",
+ response_model=VisionFocusPromptGenerateResult,
+)
+async def generate_assistant_vision_focus(
+ body: VisionFocusPromptGenerateRequest,
+ session: AsyncSession = Depends(get_session),
+):
+ """Generate an editable visual focus draft from an assistant prompt."""
+
+ resource = await session.get(ModelResource, body.model_resource_id)
+ if (
+ resource is None
+ or resource.capability != "LLM"
+ or not resource.enabled
+ ):
+ raise HTTPException(400, "请选择可用的大语言模型生成视觉关注目标")
+ try:
+ focus_prompt = await generate_vision_focus_prompt(
+ RuntimeModelResource(
+ id=resource.id,
+ name=resource.name,
+ capability=resource.capability,
+ interface_type=resource.interface_type,
+ values=resource.values or {},
+ secrets=resource.secrets or {},
+ support_image_input=bool(resource.support_image_input),
+ ),
+ body.source_prompt,
+ )
+ except ValueError as exc:
+ raise HTTPException(400, str(exc)) from exc
+ except Exception as exc:
+ raise HTTPException(502, "生成视觉关注目标失败,请稍后重试") from exc
+ return VisionFocusPromptGenerateResult(focus_prompt=focus_prompt)
+
+
@router.get("/{assistant_id}", response_model=AssistantOut)
async def get_assistant(
assistant_id: str, session: AsyncSession = Depends(get_session)
@@ -468,6 +513,7 @@ async def duplicate_assistant(
startup=dict(source.startup or {}),
vision_enabled=source.vision_enabled,
vision_model_resource_id=source.vision_model_resource_id,
+ vision_focus_prompt=source.vision_focus_prompt or "",
knowledge_base_id=source.knowledge_base_id,
knowledge_retrieval_config=dict(source.knowledge_retrieval_config or {}),
prompt=source.prompt,
diff --git a/backend/schemas.py b/backend/schemas.py
index cc31a69..de8077b 100644
--- a/backend/schemas.py
+++ b/backend/schemas.py
@@ -198,6 +198,7 @@ class AssistantUpsert(CamelModel):
startup: StartupConfig = Field(default_factory=StartupConfig)
vision_enabled: bool = False
vision_model_resource_id: str | None = None
+ vision_focus_prompt: str = Field(default="", max_length=4000)
model_resource_ids: dict[ModelType, str] = Field(default_factory=dict)
knowledge_base_id: str | None = None
knowledge_retrieval_config: KnowledgeRetrievalConfig = Field(
@@ -263,6 +264,15 @@ class AssistantOut(AssistantUpsert):
updated_at: str | None = None
+class VisionFocusPromptGenerateRequest(CamelModel):
+ source_prompt: str = Field(min_length=1, max_length=20000)
+ model_resource_id: str = Field(min_length=1, max_length=40)
+
+
+class VisionFocusPromptGenerateResult(CamelModel):
+ focus_prompt: str
+
+
class DynamicVariableDefinition(CamelModel):
type: DynamicVariableType = "string"
required: bool = False
diff --git a/backend/services/config_resolver.py b/backend/services/config_resolver.py
index 9b64fc9..5028f2c 100644
--- a/backend/services/config_resolver.py
+++ b/backend/services/config_resolver.py
@@ -301,6 +301,7 @@ async def resolve_runtime_config(
),
vision_enabled=assistant.vision_enabled,
vision_model_resource_id=assistant.vision_model_resource_id,
+ vision_focus_prompt=assistant.vision_focus_prompt or "",
vision_model=str(_value(vision_resource, "modelId", "")),
vision_llm_interface_type=(
vision_resource.interface_type if vision_resource else "openai-llm"
diff --git a/backend/services/node_specs.py b/backend/services/node_specs.py
index 3278c4c..1695690 100644
--- a/backend/services/node_specs.py
+++ b/backend/services/node_specs.py
@@ -207,6 +207,7 @@ def _normalize_agent_data(data: dict[str, Any]) -> None:
data.get("ttsResourceId"),
data.get("visionEnabled"),
data.get("visionModelResourceId"),
+ data.get("visionFocusPrompt"),
data.get("knowledgeBaseId"),
data.get("toolIds"),
)
@@ -267,6 +268,7 @@ def _normalize_settings(settings: dict[str, Any], *, global_prompt: str = "") ->
settings.setdefault("defaultTtsResourceId", "")
settings.setdefault("visionEnabled", False)
settings.setdefault("visionModelResourceId", "")
+ settings.setdefault("visionFocusPrompt", "")
settings.setdefault("toolIds", [])
settings.setdefault("knowledgeBaseId", "")
settings.setdefault("knowledgeMode", "automatic")
diff --git a/backend/services/pipecat/pipeline.py b/backend/services/pipecat/pipeline.py
index e5596f7..b4490be 100644
--- a/backend/services/pipecat/pipeline.py
+++ b/backend/services/pipecat/pipeline.py
@@ -31,7 +31,6 @@ from services.input_assets import consume_input_image
from services.client_tools import ClientToolBroker
from services.tool_policy import policy_for_tool
from services.vision import (
- VISION_SYSTEM_HINT,
VISION_TOOL_NAME,
analyze_image_with_vision_model,
config_with_main_llm_as_vision,
@@ -39,6 +38,8 @@ from services.vision import (
image_data_uri,
image_frame_from_jpeg,
image_jpeg_bytes,
+ vision_system_hint,
+ vision_tool_description,
)
from services.workflow_engine import WorkflowEngine
@@ -348,7 +349,7 @@ async def run_pipeline(
def with_vision_hint(text: str) -> str:
hints = []
if vision_enabled and cfg.type != "workflow":
- hints.append(VISION_SYSTEM_HINT)
+ hints.append(vision_system_hint(cfg.vision_focus_prompt))
if cfg.knowledge_base_id:
hints.append(
AUTOMATIC_KNOWLEDGE_SYSTEM_HINT
@@ -449,6 +450,7 @@ async def run_pipeline(
workflow_vision_scope: dict[str, Any] = {
"enabled": False,
"vision_model_resource_id": None,
+ "vision_focus_prompt": "",
"llm_resource_id": None,
}
@@ -458,10 +460,7 @@ async def run_pipeline(
return vision_native_mode
vision_schema = FunctionSchema(
name=VISION_TOOL_NAME,
- description=(
- "获取用户当前摄像头画面。当用户询问当前画面、看到了什么、"
- "人/物品/环境状态或需要视觉判断时调用。"
- ),
+ description=vision_tool_description(cfg.vision_focus_prompt),
properties={
"question": {
"type": "string",
@@ -572,10 +571,15 @@ async def run_pipeline(
resource_id = vision_resource_id or llm_resource_id
resource = cfg.workflow_model_resources.get(resource_id)
if resource:
- return config_with_vision_resource(cfg, resource)
- if not vision_resource_id:
- return config_with_main_llm_as_vision(cfg)
- raise ValueError(f"视觉模型资源未加载:{vision_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_focus_prompt = str(
+ workflow_vision_scope.get("vision_focus_prompt") or ""
+ ).strip()
+ return result
if cfg.type == "workflow" and vision_enabled:
async def native_flow_fetch_user_image(params: FunctionCallParams) -> None:
diff --git a/backend/services/vision.py b/backend/services/vision.py
index a819ca3..0a56224 100644
--- a/backend/services/vision.py
+++ b/backend/services/vision.py
@@ -22,6 +22,12 @@ VISION_ANALYSIS_SYSTEM_PROMPT = (
"你是一个视觉理解模型。请只根据图片内容和用户问题给出准确、简洁的中文观察结果。"
"如果画面不足以判断,请明确说明不确定。"
)
+VISION_FOCUS_GENERATION_SYSTEM_PROMPT = (
+ "你负责把对话助手提示词整理成一段可编辑的视觉关注目标。"
+ "只提取图片中能够直接观察的对象、属性、状态、输出要求和不确定性边界;"
+ "不要复制对话流程、工具调用规则或结束条件,不要要求根据画面推断身份、责任、"
+ "意图等不可直接观察的信息。使用简洁中文,控制在 100 至 500 字,只输出关注目标正文。"
+)
def _require(value: str, label: str) -> str:
@@ -30,6 +36,85 @@ def _require(value: str, label: str) -> str:
raise ValueError(f"缺少模型资源配置: {label}")
+def vision_system_hint(focus_prompt: str = "") -> str:
+ """Describe when to use vision and keep a configured business focus visible."""
+
+ focus = focus_prompt.strip()
+ if not focus:
+ return VISION_SYSTEM_HINT
+ return (
+ f"{VISION_SYSTEM_HINT}\n"
+ f"视觉关注目标(用于补充本次用户问题;冲突时以本次问题为准):{focus}"
+ )
+
+
+def vision_analysis_system_prompt(focus_prompt: str = "") -> str:
+ """Keep the safety baseline fixed while appending an editable business focus."""
+
+ focus = focus_prompt.strip()
+ if not focus:
+ return VISION_ANALYSIS_SYSTEM_PROMPT
+ return (
+ f"{VISION_ANALYSIS_SYSTEM_PROMPT}\n\n"
+ f"[业务视觉关注目标]\n{focus}\n"
+ "该目标用于补充当前用户问题;如果二者范围不同,优先回答当前用户问题。"
+ )
+
+
+def vision_tool_description(focus_prompt: str = "") -> str:
+ base = (
+ "获取用户当前摄像头画面。当用户询问当前画面、看到了什么、"
+ "人/物品/环境状态或需要视觉判断时调用。"
+ )
+ focus = " ".join(focus_prompt.split())
+ return f"{base} 当前业务重点:{focus[:600]}" if focus else base
+
+
+async def generate_vision_focus_prompt(
+ resource: RuntimeModelResource,
+ source_prompt: str,
+) -> str:
+ """Generate one editable focus draft with an existing LLM resource."""
+
+ if resource.interface_type not in {"openai-llm", "dashscope-llm"}:
+ raise ValueError(f"当前模型接口不支持提示词生成: {resource.interface_type}")
+ source = source_prompt.strip()
+ if not source:
+ raise ValueError("助手提示词不能为空")
+ values = resource.values or {}
+ secrets = resource.secrets or {}
+ extra_body = values.get("extraBody")
+ extra = {"extra_body": extra_body} if isinstance(extra_body, dict) else {}
+ client = AsyncOpenAI(
+ api_key=_require(str(secrets.get("apiKey") or ""), "LLM apiKey"),
+ base_url=_require(str(values.get("apiUrl") or ""), "LLM apiUrl"),
+ )
+ try:
+ response = await client.chat.completions.create(
+ model=_require(str(values.get("modelId") or ""), "LLM modelId"),
+ messages=[
+ {"role": "system", "content": VISION_FOCUS_GENERATION_SYSTEM_PROMPT},
+ {"role": "user", "content": source},
+ ],
+ **extra,
+ )
+ finally:
+ await client.close()
+ content = response.choices[0].message.content if response.choices else ""
+ result = (
+ content.strip()
+ if isinstance(content, str)
+ else str(content or "").strip()
+ )
+ if not result:
+ raise ValueError("模型没有生成有效的视觉关注目标")
+ if result.startswith("```") and result.endswith("```"):
+ result = result[3:-3].strip()
+ if result.startswith("text"):
+ result = result[4:].strip()
+ return result[:4000]
+
+
def image_jpeg_bytes(frame: UserImageRawFrame) -> bytes:
"""Encode one Pipecat camera frame as a storage- and model-ready JPEG."""
if not frame.format:
@@ -86,7 +171,12 @@ async def analyze_image_with_vision_model(
response = await client.chat.completions.create(
model=_require(cfg.vision_model, "Vision LLM modelId"),
messages=[
- {"role": "system", "content": VISION_ANALYSIS_SYSTEM_PROMPT},
+ {
+ "role": "system",
+ "content": vision_analysis_system_prompt(
+ cfg.vision_focus_prompt
+ ),
+ },
{
"role": "user",
"content": [
diff --git a/backend/services/workflow/agent.py b/backend/services/workflow/agent.py
index ff1dd17..b12358d 100644
--- a/backend/services/workflow/agent.py
+++ b/backend/services/workflow/agent.py
@@ -11,7 +11,7 @@ from pipecat.services.settings import LLMSettings
from services.brains.base import BrainRuntime
from services.runtime_variables import DynamicVariableStore
-from services.vision import VISION_SYSTEM_HINT
+from services.vision import vision_system_hint
from services.workflow_engine import WorkflowEngine
from services.workflow_router import WorkflowLLMRouter
@@ -47,7 +47,10 @@ class WorkflowAgentStage:
stage_prompt = self._engine.prompt_for(node_id, self._store)
stage = self._engine.agent_stage_config(node_id)
if stage.vision_enabled:
- stage_prompt = f"{stage_prompt}\n\n[视觉能力]\n{VISION_SYSTEM_HINT}"
+ stage_prompt = (
+ f"{stage_prompt}\n\n[视觉能力]\n"
+ f"{vision_system_hint(stage.vision_focus_prompt)}"
+ )
instruction = (
EDGE_TOOL_STAGE_INSTRUCTION
if self._engine.llm_routing_mode() == "edge_tool"
@@ -109,6 +112,7 @@ class WorkflowAgentStage:
{
"enabled": stage.vision_enabled,
"vision_model_resource_id": stage.vision_model_resource_id,
+ "vision_focus_prompt": stage.vision_focus_prompt,
"llm_resource_id": stage.llm_resource_id,
}
)
diff --git a/backend/services/workflow/realtime.py b/backend/services/workflow/realtime.py
index fe50051..78944de 100644
--- a/backend/services/workflow/realtime.py
+++ b/backend/services/workflow/realtime.py
@@ -31,10 +31,11 @@ from services.system_tools import state_update_properties, system_tool_kind
from services.tool_executor import ToolExecutionError, ToolExecutor
from services.tool_policy import policy_for_tool
from services.vision import (
- VISION_SYSTEM_HINT,
VISION_TOOL_NAME,
analyze_image_with_vision_model,
config_with_vision_resource,
+ vision_system_hint,
+ vision_tool_description,
)
from services.workflow.agent import EDGE_TOOL_STAGE_INSTRUCTION
from services.workflow.models import WorkflowRuntimeState, WorkflowStatus
@@ -407,12 +408,13 @@ class WorkflowRealtimeController:
return RealtimeActivation(continue_response=generate)
def _agent_prompt(self, node_id: str) -> str:
+ stage = self._engine.agent_stage_config(node_id)
prompt = (
f"{self._engine.prompt_for(node_id, self._store)}\n\n"
f"[工作流执行规则]\n{EDGE_TOOL_STAGE_INSTRUCTION}"
)
- if self._engine.agent_stage_config(node_id).vision_enabled:
- prompt = f"{prompt}\n{VISION_SYSTEM_HINT}"
+ if stage.vision_enabled:
+ prompt = f"{prompt}\n{vision_system_hint(stage.vision_focus_prompt)}"
return prompt
def _build_agent_tools(self, node_id: str) -> list[RealtimeTool]:
@@ -603,6 +605,7 @@ class WorkflowRealtimeController:
try:
if vision_cfg is not None:
+ vision_cfg.vision_focus_prompt = stage.vision_focus_prompt
observation = await analyze_image_with_vision_model(
vision_cfg,
frame,
@@ -634,7 +637,7 @@ class WorkflowRealtimeController:
return (
RealtimeTool(
name=VISION_TOOL_NAME,
- description="获取用户摄像头的当前画面,并回答一个视觉问题。",
+ description=vision_tool_description(stage.vision_focus_prompt),
properties={
"question": {
"type": "string",
diff --git a/backend/services/workflow_engine.py b/backend/services/workflow_engine.py
index 3672117..8e12316 100644
--- a/backend/services/workflow_engine.py
+++ b/backend/services/workflow_engine.py
@@ -23,6 +23,7 @@ class AgentStageConfig:
tts_resource_id: str
vision_enabled: bool
vision_model_resource_id: str | None
+ vision_focus_prompt: str
tool_ids: tuple[str, ...]
state_variable_names: tuple[str, ...]
knowledge_base_id: str | None
@@ -151,6 +152,7 @@ class WorkflowEngine:
vision_model_resource_id=(
str(source.get("visionModelResourceId") or "") or None
),
+ vision_focus_prompt=str(source.get("visionFocusPrompt") or "").strip(),
tool_ids=tuple(str(tool_id) for tool_id in source.get("toolIds") or []),
# Variable write scope always belongs to the Agent node, even when
# its selected tool resources come from Workflow defaults.
diff --git a/backend/tests/test_vision_focus.py b/backend/tests/test_vision_focus.py
new file mode 100644
index 0000000..fdb5a4c
--- /dev/null
+++ b/backend/tests/test_vision_focus.py
@@ -0,0 +1,126 @@
+from __future__ import annotations
+
+import unittest
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+from models import RuntimeModelResource
+from services.vision import (
+ VISION_ANALYSIS_SYSTEM_PROMPT,
+ VISION_SYSTEM_HINT,
+ generate_vision_focus_prompt,
+ vision_analysis_system_prompt,
+ vision_system_hint,
+ vision_tool_description,
+)
+from services.workflow_engine import WorkflowEngine
+
+
+class VisionFocusPromptTest(unittest.TestCase):
+ def test_focus_extends_but_does_not_replace_safety_prompts(self):
+ focus = "优先识别车辆数量和人员状态,只报告可确认的信息。"
+
+ system_hint = vision_system_hint(focus)
+ analysis_prompt = vision_analysis_system_prompt(focus)
+
+ self.assertIn(VISION_SYSTEM_HINT, system_hint)
+ self.assertIn(focus, system_hint)
+ self.assertIn(VISION_ANALYSIS_SYSTEM_PROMPT, analysis_prompt)
+ self.assertIn(focus, analysis_prompt)
+ self.assertIn("优先回答当前用户问题", analysis_prompt)
+
+ def test_empty_focus_preserves_previous_runtime_prompts(self):
+ self.assertEqual(vision_system_hint(), VISION_SYSTEM_HINT)
+ self.assertEqual(
+ vision_analysis_system_prompt(),
+ VISION_ANALYSIS_SYSTEM_PROMPT,
+ )
+
+ def test_tool_description_compacts_and_limits_focus(self):
+ description = vision_tool_description("车辆\n 数量 " * 300)
+
+ self.assertIn("当前业务重点", description)
+ self.assertNotIn("\n", description)
+ self.assertLess(len(description), 700)
+
+ def test_workflow_resolves_global_and_node_focus(self):
+ graph = {
+ "specVersion": 3,
+ "settings": {
+ "visionEnabled": True,
+ "visionFocusPrompt": "全局关注目标",
+ },
+ "nodes": [
+ {
+ "id": "global-agent",
+ "type": "agent",
+ "data": {"name": "全局 Agent", "prompt": "处理任务"},
+ },
+ {
+ "id": "custom-agent",
+ "type": "agent",
+ "data": {
+ "name": "独立 Agent",
+ "prompt": "检查车辆",
+ "inheritGlobalConfig": False,
+ "visionEnabled": True,
+ "visionFocusPrompt": "节点关注目标",
+ },
+ },
+ ],
+ "edges": [],
+ }
+ engine = WorkflowEngine(graph)
+
+ self.assertEqual(
+ engine.agent_stage_config("global-agent").vision_focus_prompt,
+ "全局关注目标",
+ )
+ self.assertEqual(
+ engine.agent_stage_config("custom-agent").vision_focus_prompt,
+ "节点关注目标",
+ )
+
+
+class VisionFocusGenerationTest(unittest.IsolatedAsyncioTestCase):
+ async def test_generates_editable_focus_with_selected_resource(self):
+ resource = RuntimeModelResource(
+ id="llm_1",
+ name="生成模型",
+ capability="LLM",
+ interface_type="openai-llm",
+ values={
+ "apiUrl": "https://llm.example/v1",
+ "modelId": "focus-model",
+ },
+ secrets={"apiKey": "secret"},
+ )
+ client = MagicMock()
+ client.chat.completions.create = AsyncMock(
+ return_value=SimpleNamespace(
+ choices=[
+ SimpleNamespace(
+ message=SimpleNamespace(
+ content="优先识别车辆数量、损伤位置和人员状态。"
+ )
+ )
+ ]
+ )
+ )
+ client.close = AsyncMock()
+
+ with patch("services.vision.AsyncOpenAI", return_value=client):
+ result = await generate_vision_focus_prompt(
+ resource,
+ "你是交通事故处理助手,需要了解车辆和人员情况。",
+ )
+
+ self.assertEqual(result, "优先识别车辆数量、损伤位置和人员状态。")
+ request = client.chat.completions.create.await_args.kwargs
+ self.assertEqual(request["model"], "focus-model")
+ self.assertIn("交通事故处理助手", request["messages"][1]["content"])
+ client.close.assert_awaited_once()
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/frontend/src/components/assistant-editor/prompt-editor.tsx b/frontend/src/components/assistant-editor/prompt-editor.tsx
index f04f5f5..b6bfb5c 100644
--- a/frontend/src/components/assistant-editor/prompt-editor.tsx
+++ b/frontend/src/components/assistant-editor/prompt-editor.tsx
@@ -648,12 +648,18 @@ export function PromptEditor({
hint="开启后,助手会获得读取当前视频画面的工具。选择「模型自己」时,大语言模型必须支持图片输入。"
enabled={form.visionEnabled}
modelResourceId={form.visionModelResourceId}
+ focusPrompt={form.visionFocusPrompt}
+ sourcePrompt={form.prompt}
+ generatorModelResourceId={form.model}
mainModelResourceId={form.model}
modelOptions={visionModelOptions}
onEnabledChange={handlePromptVisionEnabledChange}
onModelResourceIdChange={(value) =>
updateForm("visionModelResourceId", value)
}
+ onFocusPromptChange={(value) =>
+ updateForm("visionFocusPrompt", value)
+ }
/>
)}
diff --git a/frontend/src/components/assistant-editor/types.ts b/frontend/src/components/assistant-editor/types.ts
index d4f8b7a..ae9084e 100644
--- a/frontend/src/components/assistant-editor/types.ts
+++ b/frontend/src/components/assistant-editor/types.ts
@@ -26,5 +26,6 @@ export type AssistantForm = {
startup: StartupConfig;
visionEnabled: boolean;
visionModelResourceId: string;
+ visionFocusPrompt: string;
toolIds: string[];
};
diff --git a/frontend/src/components/editor/vision-config-section.tsx b/frontend/src/components/editor/vision-config-section.tsx
index 40ae4c0..6ddd66a 100644
--- a/frontend/src/components/editor/vision-config-section.tsx
+++ b/frontend/src/components/editor/vision-config-section.tsx
@@ -1,12 +1,16 @@
"use client";
-import { Camera } from "lucide-react";
+import { useState } from "react";
+import { Camera, Loader2, Sparkles } from "lucide-react";
import {
ResourceSelectField,
ToggleRow,
} from "@/components/assistant-editor/editor-controls";
import { SectionCard } from "@/components/editor/section-card";
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+import { assistantsApi } from "@/lib/api";
type VisionModelOption = {
value: string;
@@ -18,22 +22,32 @@ export function VisionConfigSection({
hint,
enabled,
modelResourceId,
+ focusPrompt,
+ sourcePrompt,
+ generatorModelResourceId,
mainModelResourceId,
mainModelSupportsImageInput,
modelOptions,
onEnabledChange,
onModelResourceIdChange,
+ onFocusPromptChange,
}: {
description: string;
hint: string;
enabled: boolean;
modelResourceId: string;
+ focusPrompt: string;
+ sourcePrompt: string;
+ generatorModelResourceId: string;
mainModelResourceId: string;
mainModelSupportsImageInput?: boolean;
modelOptions: VisionModelOption[];
onEnabledChange: (enabled: boolean) => void;
onModelResourceIdChange: (modelResourceId: string) => void;
+ onFocusPromptChange: (focusPrompt: string) => void;
}) {
+ const [generating, setGenerating] = useState(false);
+ const [generationError, setGenerationError] = useState("");
const mainModelSupportsVision =
mainModelSupportsImageInput ??
modelOptions.some((option) => option.value === mainModelResourceId);
@@ -41,6 +55,25 @@ export function VisionConfigSection({
(option) => option.value !== mainModelResourceId,
);
+ async function generateFocusPrompt() {
+ if (!sourcePrompt.trim() || !generatorModelResourceId || generating) return;
+ setGenerating(true);
+ setGenerationError("");
+ try {
+ const result = await assistantsApi.generateVisionFocus({
+ sourcePrompt,
+ modelResourceId: generatorModelResourceId,
+ });
+ onFocusPromptChange(result.focusPrompt);
+ } catch (error) {
+ setGenerationError(
+ error instanceof Error ? error.message : "生成视觉关注目标失败",
+ );
+ } finally {
+ setGenerating(false);
+ }
+ }
+
return (
}
@@ -67,6 +100,56 @@ export function VisionConfigSection({
当前模型未标记支持图片输入,请选择独立视觉模型。
)}
+
+
+
+
+
+
>
)}
diff --git a/frontend/src/components/pages/AssistantPage.tsx b/frontend/src/components/pages/AssistantPage.tsx
index aae2a1e..6be9971 100644
--- a/frontend/src/components/pages/AssistantPage.tsx
+++ b/frontend/src/components/pages/AssistantPage.tsx
@@ -180,6 +180,7 @@ function blankPromptForm(name: string): AssistantForm {
},
visionEnabled: false,
visionModelResourceId: "",
+ visionFocusPrompt: "",
toolIds: [],
};
}
@@ -435,6 +436,7 @@ export function AssistantPage(props: AssistantPageProps) {
},
visionEnabled: a.visionEnabled,
visionModelResourceId: a.visionModelResourceId ?? "",
+ visionFocusPrompt: a.visionFocusPrompt ?? "",
toolIds: a.toolIds ?? [],
};
setForm(next);
@@ -485,6 +487,7 @@ export function AssistantPage(props: AssistantPageProps) {
},
visionEnabled: false,
visionModelResourceId: null,
+ visionFocusPrompt: "",
modelResourceIds: {},
knowledgeBaseId: null,
knowledgeRetrievalConfig: defaultKnowledgeRetrievalConfig(),
@@ -549,6 +552,7 @@ export function AssistantPage(props: AssistantPageProps) {
startup: form.startup,
visionEnabled: form.visionEnabled,
visionModelResourceId: form.visionModelResourceId || null,
+ visionFocusPrompt: form.visionFocusPrompt,
modelResourceIds: {
...(form.model ? { LLM: form.model } : {}),
...(form.asr ? { ASR: form.asr } : {}),
@@ -676,6 +680,7 @@ export function AssistantPage(props: AssistantPageProps) {
graph.settings?.visionModelResourceId ??
assistant.visionModelResourceId ??
"",
+ visionFocusPrompt: graph.settings?.visionFocusPrompt ?? "",
toolIds: graph.settings?.toolIds ?? [],
knowledgeBaseId:
graph.settings?.knowledgeBaseId || assistant.knowledgeBaseId || "",
diff --git a/frontend/src/components/pages/CreateAssistantPage.tsx b/frontend/src/components/pages/CreateAssistantPage.tsx
index f1f69d9..d95ed9f 100644
--- a/frontend/src/components/pages/CreateAssistantPage.tsx
+++ b/frontend/src/components/pages/CreateAssistantPage.tsx
@@ -108,6 +108,7 @@ function baseUpsert(over: Partial): AssistantUpsert {
},
visionEnabled: false,
visionModelResourceId: null,
+ visionFocusPrompt: "",
modelResourceIds: {},
knowledgeBaseId: null,
knowledgeRetrievalConfig: defaultKnowledgeRetrievalConfig(),
diff --git a/frontend/src/components/pages/TemplateLibraryPage.tsx b/frontend/src/components/pages/TemplateLibraryPage.tsx
index d2f1d2b..2aad7b5 100644
--- a/frontend/src/components/pages/TemplateLibraryPage.tsx
+++ b/frontend/src/components/pages/TemplateLibraryPage.tsx
@@ -96,6 +96,7 @@ function baseUpsertFromTemplate(
},
visionEnabled: false,
visionModelResourceId: null,
+ visionFocusPrompt: "",
modelResourceIds: {},
knowledgeBaseId: null,
knowledgeRetrievalConfig: defaultKnowledgeRetrievalConfig(),
diff --git a/frontend/src/components/workflow/WorkflowCanvas.tsx b/frontend/src/components/workflow/WorkflowCanvas.tsx
index 02ac41f..cdf1a84 100644
--- a/frontend/src/components/workflow/WorkflowCanvas.tsx
+++ b/frontend/src/components/workflow/WorkflowCanvas.tsx
@@ -129,6 +129,7 @@ function fromFlow(nodes: Node[], edges: Edge[]): WorkflowGraph {
defaultTtsResourceId: "",
visionEnabled: false,
visionModelResourceId: "",
+ visionFocusPrompt: "",
toolIds: [],
knowledgeBaseId: "",
knowledgeMode: "automatic",
diff --git a/frontend/src/components/workflow/graph-settings.ts b/frontend/src/components/workflow/graph-settings.ts
index 8cda4b7..f14298e 100644
--- a/frontend/src/components/workflow/graph-settings.ts
+++ b/frontend/src/components/workflow/graph-settings.ts
@@ -15,6 +15,7 @@ export function settingsFromWorkflowGraph(
tts: graph.settings.defaultTtsResourceId,
visionEnabled: graph.settings.visionEnabled,
visionModelResourceId: graph.settings.visionModelResourceId,
+ visionFocusPrompt: graph.settings.visionFocusPrompt,
toolIds: graph.settings.toolIds,
knowledgeBaseId: graph.settings.knowledgeBaseId,
knowledgeRetrievalConfig: {
@@ -44,6 +45,7 @@ export function workflowGraphWithSettings(
defaultTtsResourceId: settings.tts ?? "",
visionEnabled: settings.visionEnabled,
visionModelResourceId: settings.visionModelResourceId,
+ visionFocusPrompt: settings.visionFocusPrompt,
toolIds: settings.toolIds,
knowledgeBaseId: settings.knowledgeBaseId,
knowledgeMode: settings.knowledgeRetrievalConfig.mode,
diff --git a/frontend/src/components/workflow/panels/AgentNodePanel.tsx b/frontend/src/components/workflow/panels/AgentNodePanel.tsx
index 66cdd3f..8e46b64 100644
--- a/frontend/src/components/workflow/panels/AgentNodePanel.tsx
+++ b/frontend/src/components/workflow/panels/AgentNodePanel.tsx
@@ -78,6 +78,8 @@ export function AgentNodePanel({
visionModelResourceId:
(draft.visionModelResourceId as string) ||
workflowSettings.visionModelResourceId,
+ visionFocusPrompt:
+ draft.visionFocusPrompt ?? workflowSettings.visionFocusPrompt,
...(!isRealtime
? {
llmResourceId:
@@ -306,6 +308,13 @@ export function AgentNodePanel({
}
enabled={Boolean(draft.visionEnabled)}
modelResourceId={draft.visionModelResourceId ?? ""}
+ focusPrompt={draft.visionFocusPrompt ?? ""}
+ sourcePrompt={draft.prompt ?? ""}
+ generatorModelResourceId={
+ isRealtime
+ ? draft.visionModelResourceId ?? ""
+ : (draft.llmResourceId as string) || ""
+ }
mainModelResourceId={
isRealtime
? workflowSettings.realtime ?? ""
@@ -330,6 +339,9 @@ export function AgentNodePanel({
onModelResourceIdChange={(visionModelResourceId) =>
set("visionModelResourceId", visionModelResourceId)
}
+ onFocusPromptChange={(visionFocusPrompt) =>
+ set("visionFocusPrompt", visionFocusPrompt)
+ }
/>
onSettingsChange({ ...settings, visionModelResourceId })
}
+ onFocusPromptChange={(visionFocusPrompt) =>
+ onSettingsChange({ ...settings, visionFocusPrompt })
+ }
/>
>;
knowledgeBaseId: string | null;
knowledgeRetrievalConfig: KnowledgeRetrievalConfig;
@@ -371,6 +372,14 @@ export const assistantsApi = {
// 服务端整行复制(含真 key,密钥不经浏览器)
duplicate: (id: string) =>
request(`/api/assistants/${id}/duplicate`, { method: "POST" }),
+ generateVisionFocus: (body: {
+ sourcePrompt: string;
+ modelResourceId: string;
+ }) =>
+ request<{ focusPrompt: string }>("/api/assistants/vision-focus/generate", {
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
remove: (id: string) =>
request<{ ok: boolean }>(`/api/assistants/${id}`, { method: "DELETE" }),
};