fix: support vision prompt modes

This commit is contained in:
Xin Wang
2026-08-12 19:13:04 +08:00
parent 09b07751b6
commit 94a02d7e2b
29 changed files with 676 additions and 439 deletions
+2 -1
View File
@@ -160,7 +160,8 @@ class Assistant(Base):
ForeignKey("model_resources.id", ondelete="SET NULL"), ForeignKey("model_resources.id", ondelete="SET NULL"),
nullable=True, nullable=True,
) )
vision_focus_prompt: Mapped[str] = mapped_column(Text, default="") vision_prompt_mode: Mapped[str] = mapped_column(String(16), default="runtime")
vision_prompt: Mapped[str] = mapped_column(Text, default="")
# KB 引用:被引用时禁止删 KB(RESTRICT),无默认兜底 # KB 引用:被引用时禁止删 KB(RESTRICT),无默认兜底
knowledge_base_id: Mapped[str | None] = mapped_column( knowledge_base_id: Mapped[str | None] = mapped_column(
@@ -0,0 +1,47 @@
"""replace vision focus with explicit prompt modes
Revision ID: 20260812_0020
Revises: 20260812_0019
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
revision: str = "20260812_0020"
down_revision: str | Sequence[str] | None = "20260812_0019"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.alter_column(
"assistants",
"vision_focus_prompt",
new_column_name="vision_prompt",
)
op.add_column(
"assistants",
sa.Column(
"vision_prompt_mode",
sa.String(length=16),
server_default="runtime",
nullable=False,
),
)
# The previous single-field implementation behaved most like guided mode.
op.execute(
"UPDATE assistants SET vision_prompt_mode = 'guided' "
"WHERE TRIM(vision_prompt) <> ''"
)
def downgrade() -> None:
op.drop_column("assistants", "vision_prompt_mode")
op.alter_column(
"assistants",
"vision_prompt",
new_column_name="vision_focus_prompt",
)
+4 -1
View File
@@ -13,6 +13,8 @@ from typing import Literal
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
RuntimeMode = Literal["pipeline", "realtime"] RuntimeMode = Literal["pipeline", "realtime"]
VisionPromptMode = Literal["fixed", "runtime", "guided"]
VISION_PROMPT_MODES = frozenset({"fixed", "runtime", "guided"})
class RuntimeMcpServer(BaseModel): class RuntimeMcpServer(BaseModel):
@@ -92,7 +94,8 @@ class AssistantConfig(BaseModel):
llm_support_image_input: bool = False llm_support_image_input: bool = False
vision_enabled: bool = False vision_enabled: bool = False
vision_model_resource_id: str | None = None vision_model_resource_id: str | None = None
vision_focus_prompt: str = "" vision_prompt_mode: VisionPromptMode = "runtime"
vision_prompt: str = ""
vision_model: str = "" vision_model: str = ""
vision_llm_interface_type: str = "openai-llm" vision_llm_interface_type: str = "openai-llm"
vision_llm_values: dict = {} vision_llm_values: dict = {}
+14 -44
View File
@@ -2,7 +2,7 @@
import uuid import uuid
from models import RuntimeModelResource, RuntimeTool from models import VISION_PROMPT_MODES, RuntimeTool
from db.models import ( from db.models import (
Assistant, Assistant,
AssistantModelBinding, AssistantModelBinding,
@@ -16,15 +16,12 @@ from fastapi import APIRouter, Depends, HTTPException
from schemas import ( from schemas import (
AssistantOut, AssistantOut,
AssistantUpsert, AssistantUpsert,
VisionFocusPromptGenerateRequest,
VisionFocusPromptGenerateResult,
) )
from services.auth import require_admin from services.auth import require_admin
from services.masking import mask, resolve_incoming_key from services.masking import mask, resolve_incoming_key
from services.node_specs import graph_references, normalize_graph, validate_graph from services.node_specs import graph_references, normalize_graph, validate_graph
from services.system_tools import system_tool_kind from services.system_tools import system_tool_kind
from services.tool_policy import policy_for_tool from services.tool_policy import policy_for_tool
from services.vision import generate_vision_focus_prompt
from services.workflow_engine import WorkflowEngine from services.workflow_engine import WorkflowEngine
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -79,7 +76,8 @@ def _validate_workflow(body: AssistantUpsert) -> None:
# capability summary used by clients to decide whether to open the camera. # capability summary used by clients to decide whether to open the camera.
body.vision_enabled = WorkflowEngine(body.graph).uses_vision() body.vision_enabled = WorkflowEngine(body.graph).uses_vision()
body.vision_model_resource_id = None body.vision_model_resource_id = None
body.vision_focus_prompt = "" body.vision_prompt_mode = "runtime"
body.vision_prompt = ""
refs = graph_references(body.graph) refs = graph_references(body.graph)
body.tool_ids = list(dict.fromkeys([*body.tool_ids, *sorted(refs["tools"])])) body.tool_ids = list(dict.fromkeys([*body.tool_ids, *sorted(refs["tools"])]))
@@ -271,6 +269,13 @@ async def _validate_vision_model(
if body.type == "workflow": if body.type == "workflow":
return return
if body.vision_enabled: if body.vision_enabled:
if body.vision_prompt_mode not in VISION_PROMPT_MODES:
raise HTTPException(400, "视觉提示词模式无效")
if (
body.vision_prompt_mode in {"fixed", "guided"}
and not body.vision_prompt.strip()
):
raise HTTPException(400, "固定或引导模式必须填写视觉提示词")
if body.vision_model_resource_id: if body.vision_model_resource_id:
resource = await session.get(ModelResource, body.vision_model_resource_id) resource = await session.get(ModelResource, body.vision_model_resource_id)
else: else:
@@ -396,7 +401,8 @@ async def _to_out(session: AsyncSession, assistant: Assistant) -> AssistantOut:
startup=assistant.startup or {}, startup=assistant.startup or {},
vision_enabled=assistant.vision_enabled, vision_enabled=assistant.vision_enabled,
vision_model_resource_id=assistant.vision_model_resource_id, vision_model_resource_id=assistant.vision_model_resource_id,
vision_focus_prompt=assistant.vision_focus_prompt or "", vision_prompt_mode=assistant.vision_prompt_mode or "runtime",
vision_prompt=assistant.vision_prompt or "",
model_resource_ids=await _resource_ids(session, assistant.id), model_resource_ids=await _resource_ids(session, assistant.id),
knowledge_base_id=assistant.knowledge_base_id, knowledge_base_id=assistant.knowledge_base_id,
knowledge_retrieval_config=assistant.knowledge_retrieval_config or {}, knowledge_retrieval_config=assistant.knowledge_retrieval_config or {},
@@ -448,43 +454,6 @@ async def create_assistant(
return await _to_out(session, 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) @router.get("/{assistant_id}", response_model=AssistantOut)
async def get_assistant( async def get_assistant(
assistant_id: str, session: AsyncSession = Depends(get_session) assistant_id: str, session: AsyncSession = Depends(get_session)
@@ -513,7 +482,8 @@ async def duplicate_assistant(
startup=dict(source.startup or {}), startup=dict(source.startup or {}),
vision_enabled=source.vision_enabled, vision_enabled=source.vision_enabled,
vision_model_resource_id=source.vision_model_resource_id, vision_model_resource_id=source.vision_model_resource_id,
vision_focus_prompt=source.vision_focus_prompt or "", vision_prompt_mode=source.vision_prompt_mode or "runtime",
vision_prompt=source.vision_prompt or "",
knowledge_base_id=source.knowledge_base_id, knowledge_base_id=source.knowledge_base_id,
knowledge_retrieval_config=dict(source.knowledge_retrieval_config or {}), knowledge_retrieval_config=dict(source.knowledge_retrieval_config or {}),
prompt=source.prompt, prompt=source.prompt,
+3 -10
View File
@@ -15,6 +15,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_valida
from pydantic.alias_generators import to_camel from pydantic.alias_generators import to_camel
RuntimeMode = Literal["pipeline", "realtime"] RuntimeMode = Literal["pipeline", "realtime"]
VisionPromptMode = Literal["fixed", "runtime", "guided"]
ModelType = Literal["LLM", "ASR", "TTS", "Realtime", "Embedding", "Agent"] ModelType = Literal["LLM", "ASR", "TTS", "Realtime", "Embedding", "Agent"]
AssistantType = Literal["prompt", "workflow", "dify", "fastgpt", "opencode"] AssistantType = Literal["prompt", "workflow", "dify", "fastgpt", "opencode"]
TurnEndStrategy = Literal["silence", "smart_turn"] TurnEndStrategy = Literal["silence", "smart_turn"]
@@ -198,7 +199,8 @@ class AssistantUpsert(CamelModel):
startup: StartupConfig = Field(default_factory=StartupConfig) startup: StartupConfig = Field(default_factory=StartupConfig)
vision_enabled: bool = False vision_enabled: bool = False
vision_model_resource_id: str | None = None vision_model_resource_id: str | None = None
vision_focus_prompt: str = Field(default="", max_length=4000) vision_prompt_mode: VisionPromptMode = "runtime"
vision_prompt: str = Field(default="", max_length=4000)
model_resource_ids: dict[ModelType, str] = Field(default_factory=dict) model_resource_ids: dict[ModelType, str] = Field(default_factory=dict)
knowledge_base_id: str | None = None knowledge_base_id: str | None = None
knowledge_retrieval_config: KnowledgeRetrievalConfig = Field( knowledge_retrieval_config: KnowledgeRetrievalConfig = Field(
@@ -264,15 +266,6 @@ class AssistantOut(AssistantUpsert):
updated_at: str | None = None 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): class DynamicVariableDefinition(CamelModel):
type: DynamicVariableType = "string" type: DynamicVariableType = "string"
required: bool = False required: bool = False
+4 -1
View File
@@ -670,7 +670,10 @@ class WorkflowBrain(BaseBrain):
append_function(self._flow_tool(tool, node_id)) append_function(self._flow_tool(tool, node_id))
append_function(self._knowledge_function(node_id)) append_function(self._knowledge_function(node_id))
if stage.vision_enabled and self._require_runtime().vision_function: if stage.vision_enabled and self._require_runtime().vision_function:
append_function(self._require_runtime().vision_function) vision_function = self._require_runtime().vision_function
append_function(
vision_function(stage) if callable(vision_function) else vision_function
)
if self._engine.llm_routing_mode() == "edge_tool": if self._engine.llm_routing_mode() == "edge_tool":
for edge in self._engine.edge_tool_edges(node_id): for edge in self._engine.edge_tool_edges(node_id):
append_function(self._edge_tool(edge, node_id)) append_function(self._edge_tool(edge, node_id))
+2 -1
View File
@@ -301,7 +301,8 @@ async def resolve_runtime_config(
), ),
vision_enabled=assistant.vision_enabled, vision_enabled=assistant.vision_enabled,
vision_model_resource_id=assistant.vision_model_resource_id, vision_model_resource_id=assistant.vision_model_resource_id,
vision_focus_prompt=assistant.vision_focus_prompt or "", vision_prompt_mode=assistant.vision_prompt_mode or "runtime",
vision_prompt=assistant.vision_prompt or "",
vision_model=str(_value(vision_resource, "modelId", "")), vision_model=str(_value(vision_resource, "modelId", "")),
vision_llm_interface_type=( vision_llm_interface_type=(
vision_resource.interface_type if vision_resource else "openai-llm" vision_resource.interface_type if vision_resource else "openai-llm"
+25 -3
View File
@@ -6,6 +6,7 @@ from collections import defaultdict, deque
from copy import deepcopy from copy import deepcopy
from typing import Any from typing import Any
from models import VISION_PROMPT_MODES
from services.message_policy import ( from services.message_policy import (
MESSAGE_COMPLETION_POLICIES, MESSAGE_COMPLETION_POLICIES,
MESSAGE_CONFIRMATION, MESSAGE_CONFIRMATION,
@@ -199,6 +200,11 @@ def _normalize_agent_data(data: dict[str, Any]) -> None:
data["stateVariableNames"] = list( data["stateVariableNames"] = list(
dict.fromkeys(str(name) for name in state_names or [] if str(name)) dict.fromkeys(str(name) for name in state_names or [] if str(name))
) )
legacy_prompt = str(data.pop("visionFocusPrompt", "") or "").strip()
if "visionPrompt" not in data:
data["visionPrompt"] = legacy_prompt
if "visionPromptMode" not in data:
data["visionPromptMode"] = "guided" if legacy_prompt else "runtime"
if "inheritGlobalConfig" not in data: if "inheritGlobalConfig" not in data:
has_node_overrides = any( has_node_overrides = any(
( (
@@ -207,7 +213,7 @@ def _normalize_agent_data(data: dict[str, Any]) -> None:
data.get("ttsResourceId"), data.get("ttsResourceId"),
data.get("visionEnabled"), data.get("visionEnabled"),
data.get("visionModelResourceId"), data.get("visionModelResourceId"),
data.get("visionFocusPrompt"), data.get("visionPrompt"),
data.get("knowledgeBaseId"), data.get("knowledgeBaseId"),
data.get("toolIds"), data.get("toolIds"),
) )
@@ -268,7 +274,10 @@ def _normalize_settings(settings: dict[str, Any], *, global_prompt: str = "") ->
settings.setdefault("defaultTtsResourceId", "") settings.setdefault("defaultTtsResourceId", "")
settings.setdefault("visionEnabled", False) settings.setdefault("visionEnabled", False)
settings.setdefault("visionModelResourceId", "") settings.setdefault("visionModelResourceId", "")
settings.setdefault("visionFocusPrompt", "") legacy_prompt = str(settings.pop("visionFocusPrompt", "") or "").strip()
settings.setdefault("visionPrompt", legacy_prompt)
if "visionPromptMode" not in settings:
settings["visionPromptMode"] = "guided" if legacy_prompt else "runtime"
settings.setdefault("toolIds", []) settings.setdefault("toolIds", [])
settings.setdefault("knowledgeBaseId", "") settings.setdefault("knowledgeBaseId", "")
settings.setdefault("knowledgeMode", "automatic") settings.setdefault("knowledgeMode", "automatic")
@@ -471,6 +480,20 @@ def validate_graph(graph: dict[str, Any]) -> list[str]:
not isinstance(name, str) for name in state_names not isinstance(name, str) for name in state_names
): ):
errors.append(f"Agent 节点 {node_id} 的状态变量授权必须是列表") errors.append(f"Agent 节点 {node_id} 的状态变量授权必须是列表")
source = settings if data.get("inheritGlobalConfig", True) else data
vision_prompt_mode = str(source.get("visionPromptMode") or "runtime")
if vision_prompt_mode not in VISION_PROMPT_MODES:
errors.append(
f"Agent 节点 {node_id} 的视觉提示词模式无效:{vision_prompt_mode}"
)
if (
source.get("visionEnabled")
and vision_prompt_mode in {"fixed", "guided"}
and not str(source.get("visionPrompt") or "").strip()
):
errors.append(
f"Agent 节点 {node_id} 的固定或引导模式必须填写视觉提示词"
)
if runtime_mode == "realtime": if runtime_mode == "realtime":
if data.get("contextPolicy", "inherit") != "inherit": if data.get("contextPolicy", "inherit") != "inherit":
errors.append( errors.append(
@@ -487,7 +510,6 @@ def validate_graph(graph: dict[str, Any]) -> list[str]:
errors.append( errors.append(
f"Realtime Agent 节点 {node_id} 不支持节点级模型、语音或交互策略覆盖" f"Realtime Agent 节点 {node_id} 不支持节点级模型、语音或交互策略覆盖"
) )
source = settings if data.get("inheritGlobalConfig", True) else data
if ( if (
source.get("knowledgeBaseId") source.get("knowledgeBaseId")
and source.get("knowledgeMode") != "on_demand" and source.get("knowledgeMode") != "on_demand"
+57 -21
View File
@@ -38,8 +38,9 @@ from services.vision import (
image_data_uri, image_data_uri,
image_frame_from_jpeg, image_frame_from_jpeg,
image_jpeg_bytes, image_jpeg_bytes,
resolve_vision_question,
vision_system_hint, vision_system_hint,
vision_tool_description, vision_tool_schema_parts,
) )
from services.workflow_engine import WorkflowEngine from services.workflow_engine import WorkflowEngine
@@ -349,7 +350,9 @@ async def run_pipeline(
def with_vision_hint(text: str) -> str: def with_vision_hint(text: str) -> str:
hints = [] hints = []
if vision_enabled and cfg.type != "workflow": if vision_enabled and cfg.type != "workflow":
hints.append(vision_system_hint(cfg.vision_focus_prompt)) hints.append(
vision_system_hint(cfg.vision_prompt_mode, cfg.vision_prompt)
)
if cfg.knowledge_base_id: if cfg.knowledge_base_id:
hints.append( hints.append(
AUTOMATIC_KNOWLEDGE_SYSTEM_HINT AUTOMATIC_KNOWLEDGE_SYSTEM_HINT
@@ -450,7 +453,8 @@ async def run_pipeline(
workflow_vision_scope: dict[str, Any] = { workflow_vision_scope: dict[str, Any] = {
"enabled": False, "enabled": False,
"vision_model_resource_id": None, "vision_model_resource_id": None,
"vision_focus_prompt": "", "vision_prompt_mode": "runtime",
"vision_prompt": "",
"llm_resource_id": None, "llm_resource_id": None,
} }
@@ -458,16 +462,15 @@ async def run_pipeline(
if cfg.type == "workflow": if cfg.type == "workflow":
return _workflow_vision_uses_main_llm(cfg, workflow_vision_scope) return _workflow_vision_uses_main_llm(cfg, workflow_vision_scope)
return vision_native_mode return vision_native_mode
vision_description, vision_properties, vision_required = vision_tool_schema_parts(
cfg.vision_prompt_mode,
cfg.vision_prompt,
)
vision_schema = FunctionSchema( vision_schema = FunctionSchema(
name=VISION_TOOL_NAME, name=VISION_TOOL_NAME,
description=vision_tool_description(cfg.vision_focus_prompt), description=vision_description,
properties={ properties=vision_properties,
"question": { required=vision_required,
"type": "string",
"description": "用户关于当前视频画面的具体问题。",
}
},
required=["question"],
) )
knowledge_schema = FunctionSchema( knowledge_schema = FunctionSchema(
name=KNOWLEDGE_TOOL_NAME, name=KNOWLEDGE_TOOL_NAME,
@@ -498,7 +501,15 @@ async def run_pipeline(
await params.result_callback({"status": "error", "message": "知识库检索暂时不可用"}) await params.result_callback({"status": "error", "message": "知识库检索暂时不可用"})
async def fetch_user_image(params: FunctionCallParams): async def fetch_user_image(params: FunctionCallParams):
question = str(params.arguments.get("question") or "请描述当前画面。") try:
question = resolve_vision_question(
cfg.vision_prompt_mode,
cfg.vision_prompt,
params.arguments,
)
except ValueError as exc:
await params.result_callback({"status": "error", "message": str(exc)})
return
user_id = vision_state.get("client_id") user_id = vision_state.get("client_id")
if not user_id: if not user_id:
await params.result_callback( await params.result_callback(
@@ -576,14 +587,26 @@ async def run_pipeline(
result = config_with_main_llm_as_vision(cfg) result = config_with_main_llm_as_vision(cfg)
else: else:
raise ValueError(f"视觉模型资源未加载:{vision_resource_id}") raise ValueError(f"视觉模型资源未加载:{vision_resource_id}")
result.vision_focus_prompt = str( result.vision_prompt_mode = str(
workflow_vision_scope.get("vision_focus_prompt") or "" workflow_vision_scope.get("vision_prompt_mode") or "runtime"
)
result.vision_prompt = str(
workflow_vision_scope.get("vision_prompt") or ""
).strip() ).strip()
return result return result
if cfg.type == "workflow" and vision_enabled: if cfg.type == "workflow" and vision_enabled:
async def native_flow_fetch_user_image(params: FunctionCallParams) -> None: async def native_flow_fetch_user_image(params: FunctionCallParams) -> None:
question = str(params.arguments.get("question") or "请描述当前画面。") try:
vision_cfg = active_vision_config()
question = resolve_vision_question(
vision_cfg.vision_prompt_mode,
vision_cfg.vision_prompt,
params.arguments,
)
except ValueError as exc:
await params.result_callback({"status": "error", "message": str(exc)})
return
user_id = vision_state.get("client_id") user_id = vision_state.get("client_id")
if not user_id: if not user_id:
await params.result_callback( await params.result_callback(
@@ -609,7 +632,15 @@ async def run_pipeline(
"status": "disabled", "status": "disabled",
"message": "当前 Agent 节点未开启视觉理解。", "message": "当前 Agent 节点未开启视觉理解。",
} }
question = str((args or {}).get("question") or "请描述当前画面。") vision_cfg = active_vision_config()
try:
question = resolve_vision_question(
vision_cfg.vision_prompt_mode,
vision_cfg.vision_prompt,
args,
)
except ValueError as exc:
return {"status": "error", "message": str(exc)}
user_id = vision_state.get("client_id") user_id = vision_state.get("client_id")
if not user_id: if not user_id:
return { return {
@@ -625,7 +656,7 @@ async def run_pipeline(
try: try:
frame = await vision_capture.request_image(llm, request) frame = await vision_capture.request_image(llm, request)
observation = await analyze_image_with_vision_model( observation = await analyze_image_with_vision_model(
active_vision_config(), vision_cfg,
frame, frame,
question, question,
) )
@@ -654,11 +685,16 @@ async def run_pipeline(
active_vision_uses_main_llm, active_vision_uses_main_llm,
) )
workflow_vision_function = FlowsFunctionSchema( def workflow_vision_function(stage) -> FlowsFunctionSchema:
description, properties, required = vision_tool_schema_parts(
stage.vision_prompt_mode,
stage.vision_prompt,
)
return FlowsFunctionSchema(
name=VISION_TOOL_NAME, name=VISION_TOOL_NAME,
description=vision_schema.description, description=description,
properties=vision_schema.properties, properties=properties,
required=vision_schema.required, required=required,
handler=flow_fetch_user_image, handler=flow_fetch_user_image,
cancel_on_interruption=True, cancel_on_interruption=True,
) )
+81 -70
View File
@@ -5,8 +5,14 @@ from __future__ import annotations
import asyncio import asyncio
import base64 import base64
from io import BytesIO from io import BytesIO
from typing import Any
from models import AssistantConfig, RuntimeModelResource from models import (
VISION_PROMPT_MODES,
AssistantConfig,
RuntimeModelResource,
VisionPromptMode,
)
from openai import AsyncOpenAI from openai import AsyncOpenAI
from PIL import Image from PIL import Image
from pipecat.frames.frames import UserImageRawFrame from pipecat.frames.frames import UserImageRawFrame
@@ -22,12 +28,6 @@ VISION_ANALYSIS_SYSTEM_PROMPT = (
"你是一个视觉理解模型。请只根据图片内容和用户问题给出准确、简洁的中文观察结果。" "你是一个视觉理解模型。请只根据图片内容和用户问题给出准确、简洁的中文观察结果。"
"如果画面不足以判断,请明确说明不确定。" "如果画面不足以判断,请明确说明不确定。"
) )
VISION_FOCUS_GENERATION_SYSTEM_PROMPT = (
"你负责把对话助手提示词整理成一段可编辑的视觉关注目标。"
"只提取图片中能够直接观察的对象、属性、状态、输出要求和不确定性边界;"
"不要复制对话流程、工具调用规则或结束条件,不要要求根据画面推断身份、责任、"
"意图等不可直接观察的信息。使用简洁中文,控制在 100 至 500 字,只输出关注目标正文。"
)
def _require(value: str, label: str) -> str: def _require(value: str, label: str) -> str:
@@ -36,83 +36,96 @@ def _require(value: str, label: str) -> str:
raise ValueError(f"缺少模型资源配置: {label}") raise ValueError(f"缺少模型资源配置: {label}")
def vision_system_hint(focus_prompt: str = "") -> str: def normalize_vision_prompt_mode(value: object) -> VisionPromptMode:
"""Describe when to use vision and keep a configured business focus visible.""" """Return a supported mode while keeping older saved configs compatible."""
focus = focus_prompt.strip() mode = str(value or "runtime")
if not focus: if mode == "fixed":
return VISION_SYSTEM_HINT return "fixed"
if mode == "guided":
return "guided"
return "runtime"
def _compact_prompt(prompt: str, *, limit: int = 4000) -> str:
return " ".join(prompt.split())[:limit]
def vision_system_hint(
mode: VisionPromptMode = "runtime",
configured_prompt: str = "",
) -> str:
"""Tell the conversation model how the camera tool forms its question."""
prompt_mode = normalize_vision_prompt_mode(mode)
prompt = _compact_prompt(configured_prompt)
if prompt_mode == "fixed":
return ( return (
f"{VISION_SYSTEM_HINT}\n" f"{VISION_SYSTEM_HINT}\n"
f"视觉关注目标(用于补充本次用户问题;冲突时以本次问题为准):{focus}" f"该工具始终使用固定视觉问题“{prompt}”,调用时不需要生成视觉问题。"
) )
if prompt_mode == "guided":
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 ( return (
f"{VISION_ANALYSIS_SYSTEM_PROMPT}\n\n" f"{VISION_SYSTEM_HINT}\n"
f"[业务视觉关注目标]\n{focus}\n" "调用时请结合当前对话生成具体视觉问题,并遵守以下固定引导:"
"该目标用于补充当前用户问题;如果二者范围不同,优先回答当前用户问题。" f"{prompt}"
)
return (
f"{VISION_SYSTEM_HINT}\n"
"调用时请结合当前对话,生成一个具体、可由当前画面回答的视觉问题。"
) )
def vision_tool_description(focus_prompt: str = "") -> str: def vision_tool_schema_parts(
mode: VisionPromptMode = "runtime",
configured_prompt: str = "",
) -> tuple[str, dict[str, Any], list[str]]:
"""Build the tool contract for one configured visual-question mode."""
base = ( base = (
"获取用户当前摄像头画面。当用户询问当前画面、看到了什么、" "获取用户当前摄像头画面。当用户询问当前画面、看到了什么、"
"人/物品/环境状态或需要视觉判断时调用。" "人/物品/环境状态或需要视觉判断时调用。"
) )
focus = " ".join(focus_prompt.split()) prompt_mode = normalize_vision_prompt_mode(mode)
return f"{base} 当前业务重点:{focus[:600]}" if focus else base prompt = _compact_prompt(configured_prompt, limit=600)
if prompt_mode == "fixed":
return (
f"{base} 每次都检查同一个固定问题:{prompt}",
{},
[],
)
question_description = "结合当前对话生成的、关于当前视频画面的具体问题。"
description = base
if prompt_mode == "guided":
description = f"{base} 生成问题时必须遵守固定引导:{prompt}"
question_description = f"结合当前对话生成具体视觉问题,并遵守固定引导:{prompt}"
return (
description,
{"question": {"type": "string", "description": question_description}},
["question"],
)
async def generate_vision_focus_prompt( def resolve_vision_question(
resource: RuntimeModelResource, mode: VisionPromptMode,
source_prompt: str, configured_prompt: str,
arguments: dict[str, Any] | None,
) -> str: ) -> str:
"""Generate one editable focus draft with an existing LLM resource.""" """Resolve the exact question sent with the captured image."""
if resource.interface_type not in {"openai-llm", "dashscope-llm"}: prompt_mode = normalize_vision_prompt_mode(mode)
raise ValueError(f"当前模型接口不支持提示词生成: {resource.interface_type}") if prompt_mode == "fixed":
source = source_prompt.strip() question = configured_prompt.strip()
if not source: else:
raise ValueError("助手提示词不能为空") question = str((arguments or {}).get("question") or "").strip()
values = resource.values or {} if not question:
secrets = resource.secrets or {} raise ValueError(
extra_body = values.get("extraBody") "固定视觉提示词为空"
extra = {"extra_body": extra_body} if isinstance(extra_body, dict) else {} if prompt_mode == "fixed"
client = AsyncOpenAI( else "视觉问题为空"
api_key=_require(str(secrets.get("apiKey") or ""), "LLM apiKey"),
base_url=_require(str(values.get("apiUrl") or ""), "LLM apiUrl"),
) )
try: return question
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: def image_jpeg_bytes(frame: UserImageRawFrame) -> bytes:
@@ -173,9 +186,7 @@ async def analyze_image_with_vision_model(
messages=[ messages=[
{ {
"role": "system", "role": "system",
"content": vision_analysis_system_prompt( "content": VISION_ANALYSIS_SYSTEM_PROMPT,
cfg.vision_focus_prompt
),
}, },
{ {
"role": "user", "role": "user",
+3 -2
View File
@@ -49,7 +49,7 @@ class WorkflowAgentStage:
if stage.vision_enabled: if stage.vision_enabled:
stage_prompt = ( stage_prompt = (
f"{stage_prompt}\n\n[视觉能力]\n" f"{stage_prompt}\n\n[视觉能力]\n"
f"{vision_system_hint(stage.vision_focus_prompt)}" f"{vision_system_hint(stage.vision_prompt_mode, stage.vision_prompt)}"
) )
instruction = ( instruction = (
EDGE_TOOL_STAGE_INSTRUCTION EDGE_TOOL_STAGE_INSTRUCTION
@@ -112,7 +112,8 @@ class WorkflowAgentStage:
{ {
"enabled": stage.vision_enabled, "enabled": stage.vision_enabled,
"vision_model_resource_id": stage.vision_model_resource_id, "vision_model_resource_id": stage.vision_model_resource_id,
"vision_focus_prompt": stage.vision_focus_prompt, "vision_prompt_mode": stage.vision_prompt_mode,
"vision_prompt": stage.vision_prompt,
"llm_resource_id": stage.llm_resource_id, "llm_resource_id": stage.llm_resource_id,
} }
) )
+21 -14
View File
@@ -34,8 +34,9 @@ from services.vision import (
VISION_TOOL_NAME, VISION_TOOL_NAME,
analyze_image_with_vision_model, analyze_image_with_vision_model,
config_with_vision_resource, config_with_vision_resource,
resolve_vision_question,
vision_system_hint, vision_system_hint,
vision_tool_description, vision_tool_schema_parts,
) )
from services.workflow.agent import EDGE_TOOL_STAGE_INSTRUCTION from services.workflow.agent import EDGE_TOOL_STAGE_INSTRUCTION
from services.workflow.models import WorkflowRuntimeState, WorkflowStatus from services.workflow.models import WorkflowRuntimeState, WorkflowStatus
@@ -414,7 +415,10 @@ class WorkflowRealtimeController:
f"[工作流执行规则]\n{EDGE_TOOL_STAGE_INSTRUCTION}" f"[工作流执行规则]\n{EDGE_TOOL_STAGE_INSTRUCTION}"
) )
if stage.vision_enabled: if stage.vision_enabled:
prompt = f"{prompt}\n{vision_system_hint(stage.vision_focus_prompt)}" prompt = (
f"{prompt}\n"
f"{vision_system_hint(stage.vision_prompt_mode, stage.vision_prompt)}"
)
return prompt return prompt
def _build_agent_tools(self, node_id: str) -> list[RealtimeTool]: def _build_agent_tools(self, node_id: str) -> list[RealtimeTool]:
@@ -573,10 +577,15 @@ class WorkflowRealtimeController:
{"status": "stale", "message": "当前 Agent 已经切换。"}, {"status": "stale", "message": "当前 Agent 已经切换。"},
continue_response=False, continue_response=False,
) )
question = str(arguments.get("question") or "").strip() try:
if not question: question = resolve_vision_question(
stage.vision_prompt_mode,
stage.vision_prompt,
arguments,
)
except ValueError as exc:
return RealtimeToolResult( return RealtimeToolResult(
{"status": "error", "message": "视觉问题为空"} {"status": "error", "message": str(exc)}
) )
if self._runtime.capture_image is None: if self._runtime.capture_image is None:
return RealtimeToolResult( return RealtimeToolResult(
@@ -605,7 +614,6 @@ class WorkflowRealtimeController:
try: try:
if vision_cfg is not None: if vision_cfg is not None:
vision_cfg.vision_focus_prompt = stage.vision_focus_prompt
observation = await analyze_image_with_vision_model( observation = await analyze_image_with_vision_model(
vision_cfg, vision_cfg,
frame, frame,
@@ -634,17 +642,16 @@ class WorkflowRealtimeController:
} }
) )
description, properties, required = vision_tool_schema_parts(
stage.vision_prompt_mode,
stage.vision_prompt,
)
return ( return (
RealtimeTool( RealtimeTool(
name=VISION_TOOL_NAME, name=VISION_TOOL_NAME,
description=vision_tool_description(stage.vision_focus_prompt), description=description,
properties={ properties=properties,
"question": { required=tuple(required),
"type": "string",
"description": "需要根据当前画面判断的具体问题",
}
},
required=("question",),
), ),
handler, handler,
) )
+4 -2
View File
@@ -23,7 +23,8 @@ class AgentStageConfig:
tts_resource_id: str tts_resource_id: str
vision_enabled: bool vision_enabled: bool
vision_model_resource_id: str | None vision_model_resource_id: str | None
vision_focus_prompt: str vision_prompt_mode: str
vision_prompt: str
tool_ids: tuple[str, ...] tool_ids: tuple[str, ...]
state_variable_names: tuple[str, ...] state_variable_names: tuple[str, ...]
knowledge_base_id: str | None knowledge_base_id: str | None
@@ -152,7 +153,8 @@ class WorkflowEngine:
vision_model_resource_id=( vision_model_resource_id=(
str(source.get("visionModelResourceId") or "") or None str(source.get("visionModelResourceId") or "") or None
), ),
vision_focus_prompt=str(source.get("visionFocusPrompt") or "").strip(), vision_prompt_mode=str(source.get("visionPromptMode") or "runtime"),
vision_prompt=str(source.get("visionPrompt") or "").strip(),
tool_ids=tuple(str(tool_id) for tool_id in source.get("toolIds") or []), 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 # Variable write scope always belongs to the Agent node, even when
# its selected tool resources come from Workflow defaults. # its selected tool resources come from Workflow defaults.
+9 -4
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import asyncio import asyncio
import unittest import unittest
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, MagicMock, patch
from models import AssistantConfig, RuntimeTool from models import AssistantConfig, RuntimeTool
from pipecat.flows import FlowManager from pipecat.flows import FlowManager
@@ -1782,7 +1782,7 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
} }
) )
scopes = [] scopes = []
vision_function = object() vision_function = MagicMock(side_effect=lambda _stage: object())
async def queue_frame(_frame): async def queue_frame(_frame):
pass pass
@@ -1800,13 +1800,18 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
await brain._apply_agent_stage("agent") await brain._apply_agent_stage("agent")
inherited_config = brain._agent_config("agent") inherited_config = brain._agent_config("agent")
self.assertIn(vision_function, inherited_config["functions"]) self.assertEqual(len(inherited_config["functions"]), 1)
vision_function.assert_called_once()
selected_stage = vision_function.call_args.args[0]
self.assertEqual(selected_stage.vision_prompt_mode, "runtime")
self.assertIn("fetch_user_image", inherited_config["role_message"]) self.assertIn("fetch_user_image", inherited_config["role_message"])
self.assertEqual( self.assertEqual(
scopes[-1], scopes[-1],
{ {
"enabled": True, "enabled": True,
"vision_model_resource_id": "vision_global", "vision_model_resource_id": "vision_global",
"vision_prompt_mode": "runtime",
"vision_prompt": "",
"llm_resource_id": "llm_global", "llm_resource_id": "llm_global",
}, },
) )
@@ -1821,7 +1826,7 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
) )
await brain._apply_agent_stage("agent") await brain._apply_agent_stage("agent")
custom_config = brain._agent_config("agent") custom_config = brain._agent_config("agent")
self.assertNotIn(vision_function, custom_config["functions"]) self.assertEqual(custom_config["functions"], [])
self.assertNotIn("fetch_user_image", custom_config["role_message"]) self.assertNotIn("fetch_user_image", custom_config["role_message"])
self.assertFalse(scopes[-1]["enabled"]) self.assertFalse(scopes[-1]["enabled"])
-126
View File
@@ -1,126 +0,0 @@
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()
+227
View File
@@ -0,0 +1,227 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from models import AssistantConfig
from services.brains.base import RealtimeBrainRuntime
from services.node_specs import normalize_graph, validate_graph
from services.vision import (
VISION_SYSTEM_HINT,
resolve_vision_question,
vision_system_hint,
vision_tool_schema_parts,
)
from services.workflow_engine import WorkflowEngine
from services.workflow.realtime import WorkflowRealtimeController
class VisionPromptModeTest(unittest.TestCase):
def test_fixed_mode_has_no_runtime_question_parameter(self):
description, properties, required = vision_tool_schema_parts(
"fixed",
"识别车辆数量和人员状态",
)
self.assertIn("识别车辆数量和人员状态", description)
self.assertEqual(properties, {})
self.assertEqual(required, [])
self.assertEqual(
resolve_vision_question("fixed", " 识别车辆数量 ", {}),
"识别车辆数量",
)
def test_runtime_mode_requires_model_generated_question(self):
description, properties, required = vision_tool_schema_parts("runtime")
self.assertNotIn("固定引导", description)
self.assertIn("question", properties)
self.assertEqual(required, ["question"])
self.assertEqual(
resolve_vision_question(
"runtime",
"不会使用这个配置",
{"question": "用户手里拿着什么?"},
),
"用户手里拿着什么?",
)
def test_guided_mode_exposes_guide_but_uses_runtime_question(self):
guide = "只询问画面中可直接确认的车辆损伤"
description, properties, required = vision_tool_schema_parts(
"guided",
guide,
)
self.assertIn(guide, description)
self.assertIn(guide, properties["question"]["description"])
self.assertEqual(required, ["question"])
self.assertEqual(
resolve_vision_question(
"guided",
guide,
{"question": "右侧车门是否有明显凹陷?"},
),
"右侧车门是否有明显凹陷?",
)
def test_mode_specific_system_hints(self):
self.assertIn(VISION_SYSTEM_HINT, vision_system_hint("runtime"))
self.assertIn("不需要生成", vision_system_hint("fixed", "固定问题"))
self.assertIn("固定引导", vision_system_hint("guided", "业务引导"))
def test_missing_required_config_or_question_is_rejected(self):
with self.assertRaisesRegex(ValueError, "固定视觉提示词为空"):
resolve_vision_question("fixed", "", {})
with self.assertRaisesRegex(ValueError, "视觉问题为空"):
resolve_vision_question("runtime", "", {})
class WorkflowVisionPromptModeTest(unittest.TestCase):
def test_workflow_resolves_global_and_node_modes(self):
graph = {
"specVersion": 3,
"settings": {
"visionEnabled": True,
"visionPromptMode": "fixed",
"visionPrompt": "全局固定问题",
},
"nodes": [
{
"id": "global-agent",
"type": "agent",
"data": {"name": "全局 Agent", "prompt": "处理任务"},
},
{
"id": "custom-agent",
"type": "agent",
"data": {
"name": "独立 Agent",
"prompt": "检查车辆",
"inheritGlobalConfig": False,
"visionEnabled": True,
"visionPromptMode": "guided",
"visionPrompt": "节点引导",
},
},
],
"edges": [],
}
engine = WorkflowEngine(graph)
global_stage = engine.agent_stage_config("global-agent")
self.assertEqual(global_stage.vision_prompt_mode, "fixed")
self.assertEqual(global_stage.vision_prompt, "全局固定问题")
custom_stage = engine.agent_stage_config("custom-agent")
self.assertEqual(custom_stage.vision_prompt_mode, "guided")
self.assertEqual(custom_stage.vision_prompt, "节点引导")
def test_legacy_focus_is_migrated_to_guided_mode(self):
normalized = normalize_graph(
{
"specVersion": 3,
"settings": {"visionFocusPrompt": "旧全局关注目标"},
"nodes": [],
"edges": [],
}
)
self.assertEqual(normalized["settings"]["visionPromptMode"], "guided")
self.assertEqual(normalized["settings"]["visionPrompt"], "旧全局关注目标")
self.assertNotIn("visionFocusPrompt", normalized["settings"])
def test_fixed_or_guided_mode_requires_prompt_when_vision_is_enabled(self):
graph = {
"specVersion": 3,
"settings": {
"visionEnabled": True,
"visionPromptMode": "fixed",
"visionPrompt": "",
},
"nodes": [
{
"id": "agent",
"type": "agent",
"data": {"name": "Agent", "prompt": "处理任务"},
}
],
"edges": [],
}
self.assertTrue(
any("必须填写视觉提示词" in error for error in validate_graph(graph))
)
def test_invalid_mode_is_reported_instead_of_silently_rewritten(self):
graph = {
"specVersion": 3,
"settings": {"visionPromptMode": "unexpected"},
"nodes": [
{
"id": "agent",
"type": "agent",
"data": {"name": "Agent", "prompt": "处理任务"},
}
],
"edges": [],
}
self.assertTrue(
any("视觉提示词模式无效" in error for error in validate_graph(graph))
)
def test_realtime_tool_contract_changes_with_mode(self):
graph = {
"specVersion": 3,
"settings": {},
"nodes": [
{
"id": "fixed-agent",
"type": "agent",
"data": {
"name": "固定",
"prompt": "处理任务",
"inheritGlobalConfig": False,
"visionEnabled": True,
"visionPromptMode": "fixed",
"visionPrompt": "检查安全帽",
},
},
{
"id": "guided-agent",
"type": "agent",
"data": {
"name": "引导",
"prompt": "处理任务",
"inheritGlobalConfig": False,
"visionEnabled": True,
"visionPromptMode": "guided",
"visionPrompt": "只询问安全设备",
},
},
],
"edges": [],
}
engine = WorkflowEngine(graph)
controller = WorkflowRealtimeController(
cfg=AssistantConfig(type="workflow"),
engine=engine,
store=SimpleNamespace(),
runtime=RealtimeBrainRuntime(
realtime=SimpleNamespace(),
queue_frame=lambda _frame: None,
call_end=SimpleNamespace(ending=False),
),
)
fixed_tool, _fixed_handler = controller._vision_tool("fixed-agent", 0)
guided_tool, _guided_handler = controller._vision_tool("guided-agent", 0)
self.assertEqual(fixed_tool.properties, {})
self.assertEqual(fixed_tool.required, ())
self.assertIn("只询问安全设备", guided_tool.description)
self.assertEqual(guided_tool.required, ("question",))
if __name__ == "__main__":
unittest.main()
@@ -648,17 +648,19 @@ export function PromptEditor({
hint="开启后,助手会获得读取当前视频画面的工具。选择「模型自己」时,大语言模型必须支持图片输入。" hint="开启后,助手会获得读取当前视频画面的工具。选择「模型自己」时,大语言模型必须支持图片输入。"
enabled={form.visionEnabled} enabled={form.visionEnabled}
modelResourceId={form.visionModelResourceId} modelResourceId={form.visionModelResourceId}
focusPrompt={form.visionFocusPrompt} promptMode={form.visionPromptMode}
sourcePrompt={form.prompt} prompt={form.visionPrompt}
generatorModelResourceId={form.model}
mainModelResourceId={form.model} mainModelResourceId={form.model}
modelOptions={visionModelOptions} modelOptions={visionModelOptions}
onEnabledChange={handlePromptVisionEnabledChange} onEnabledChange={handlePromptVisionEnabledChange}
onModelResourceIdChange={(value) => onModelResourceIdChange={(value) =>
updateForm("visionModelResourceId", value) updateForm("visionModelResourceId", value)
} }
onFocusPromptChange={(value) => onPromptModeChange={(value) =>
updateForm("visionFocusPrompt", value) updateForm("visionPromptMode", value)
}
onPromptChange={(value) =>
updateForm("visionPrompt", value)
} }
/> />
)} )}
@@ -4,6 +4,7 @@ import type {
KnowledgeRetrievalConfig, KnowledgeRetrievalConfig,
StartupConfig, StartupConfig,
TurnConfig, TurnConfig,
VisionPromptMode,
} from "@/lib/api"; } from "@/lib/api";
export type RuntimeMode = "pipeline" | "realtime"; export type RuntimeMode = "pipeline" | "realtime";
@@ -26,6 +27,7 @@ export type AssistantForm = {
startup: StartupConfig; startup: StartupConfig;
visionEnabled: boolean; visionEnabled: boolean;
visionModelResourceId: string; visionModelResourceId: string;
visionFocusPrompt: string; visionPromptMode: VisionPromptMode;
visionPrompt: string;
toolIds: string[]; toolIds: string[];
}; };
@@ -1,53 +1,71 @@
"use client"; "use client";
import { useState } from "react"; import { Camera, Check } from "lucide-react";
import { Camera, Loader2, Sparkles } from "lucide-react";
import { import {
ResourceSelectField, ResourceSelectField,
ToggleRow, ToggleRow,
} from "@/components/assistant-editor/editor-controls"; } from "@/components/assistant-editor/editor-controls";
import { SectionCard } from "@/components/editor/section-card"; import { SectionCard } from "@/components/editor/section-card";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { assistantsApi } from "@/lib/api"; import type { VisionPromptMode } from "@/lib/api";
type VisionModelOption = { type VisionModelOption = {
value: string; value: string;
label: string; label: string;
}; };
const promptModes: Array<{
value: VisionPromptMode;
title: string;
description: string;
}> = [
{
value: "fixed",
title: "固定提示词",
description: "每次获取画面都发送同一个问题。",
},
{
value: "runtime",
title: "运行时自动生成",
description: "模型结合当前对话,临时生成看图问题。",
},
{
value: "guided",
title: "固定提示词引导",
description: "模型动态生成问题,同时遵守固定引导。",
},
];
export function VisionConfigSection({ export function VisionConfigSection({
description, description,
hint, hint,
enabled, enabled,
modelResourceId, modelResourceId,
focusPrompt, promptMode,
sourcePrompt, prompt,
generatorModelResourceId,
mainModelResourceId, mainModelResourceId,
mainModelSupportsImageInput, mainModelSupportsImageInput,
modelOptions, modelOptions,
onEnabledChange, onEnabledChange,
onModelResourceIdChange, onModelResourceIdChange,
onFocusPromptChange, onPromptModeChange,
onPromptChange,
}: { }: {
description: string; description: string;
hint: string; hint: string;
enabled: boolean; enabled: boolean;
modelResourceId: string; modelResourceId: string;
focusPrompt: string; promptMode: VisionPromptMode;
sourcePrompt: string; prompt: string;
generatorModelResourceId: string;
mainModelResourceId: string; mainModelResourceId: string;
mainModelSupportsImageInput?: boolean; mainModelSupportsImageInput?: boolean;
modelOptions: VisionModelOption[]; modelOptions: VisionModelOption[];
onEnabledChange: (enabled: boolean) => void; onEnabledChange: (enabled: boolean) => void;
onModelResourceIdChange: (modelResourceId: string) => void; onModelResourceIdChange: (modelResourceId: string) => void;
onFocusPromptChange: (focusPrompt: string) => void; onPromptModeChange: (promptMode: VisionPromptMode) => void;
onPromptChange: (prompt: string) => void;
}) { }) {
const [generating, setGenerating] = useState(false);
const [generationError, setGenerationError] = useState("");
const mainModelSupportsVision = const mainModelSupportsVision =
mainModelSupportsImageInput ?? mainModelSupportsImageInput ??
modelOptions.some((option) => option.value === mainModelResourceId); modelOptions.some((option) => option.value === mainModelResourceId);
@@ -55,25 +73,6 @@ export function VisionConfigSection({
(option) => option.value !== mainModelResourceId, (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 ( return (
<SectionCard <SectionCard
icon={<Camera size={15} />} icon={<Camera size={15} />}
@@ -100,56 +99,79 @@ export function VisionConfigSection({
当前模型未标记支持图片输入,请选择独立视觉模型。 当前模型未标记支持图片输入,请选择独立视觉模型。
</p> </p>
)} )}
<div className="space-y-1.5"> <div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<label className="text-sm font-medium text-foreground"> <label className="text-sm font-medium text-foreground">
视觉关注目标 看图问题生成方式
</label> </label>
<Button <div role="radiogroup" aria-label="看图问题生成方式" className="space-y-2">
{promptModes.map((option) => {
const selected = promptMode === option.value;
return (
<button
key={option.value}
type="button" type="button"
variant="ghost" role="radio"
size="sm" aria-checked={selected}
className="h-7 gap-1.5 rounded-full px-2.5 text-xs" onClick={() => onPromptModeChange(option.value)}
disabled={ className={[
generating || !sourcePrompt.trim() || !generatorModelResourceId "flex w-full items-start gap-3 rounded-xl border px-3.5 py-3 text-left transition-colors",
} selected
onClick={() => void generateFocusPrompt()} ? "border-primary bg-surface-strong/70 ring-1 ring-primary"
title={ : "border-hairline bg-background hover:border-hairline-strong",
!sourcePrompt.trim() ].join(" ")}
? "请先填写助手提示词"
: !generatorModelResourceId
? "请先选择大语言模型"
: "根据当前助手提示词生成可编辑草稿"
}
> >
{generating ? ( <span
<Loader2 size={12} className="animate-spin" /> className={[
) : ( "mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border",
<Sparkles size={12} /> selected
)} ? "border-primary bg-primary text-primary-foreground"
{generating ? "生成中" : "根据提示词生成"} : "border-hairline-strong text-transparent",
</Button> ].join(" ")}
>
<Check size={12} />
</span>
<span className="min-w-0">
<span className="block text-sm font-medium text-foreground">
{option.title}
</span>
<span className="mt-0.5 block text-xs leading-5 text-muted-foreground">
{option.description}
</span>
</span>
</button>
);
})}
</div> </div>
</div>
{promptMode !== "runtime" && (
<div className="space-y-1.5">
<label className="text-sm font-medium text-foreground">
{promptMode === "fixed" ? "固定视觉提示词" : "运行时提示词引导"}
</label>
<Textarea <Textarea
value={focusPrompt} value={prompt}
maxLength={4000} maxLength={4000}
rows={5} rows={5}
onChange={(event) => { onChange={(event) => onPromptChange(event.target.value)}
onFocusPromptChange(event.target.value); placeholder={
if (generationError) setGenerationError(""); promptMode === "fixed"
}} ? "例如:请识别画面中的车辆数量、碰撞位置、明显损伤和人员状态。"
placeholder="例如:优先识别车辆数量、碰撞位置、明显损伤和人员状态;只报告画面中可直接确认的信息。" : "例如:问题应聚焦车辆数量、碰撞位置和人员状态;只询问画面中可直接判断的信息。"
}
className="field-sizing-fixed min-h-28 resize-y border-hairline-strong bg-background text-sm leading-6 text-foreground placeholder:text-muted-soft" className="field-sizing-fixed min-h-28 resize-y border-hairline-strong bg-background text-sm leading-6 text-foreground placeholder:text-muted-soft"
/> />
<p className="text-[11px] leading-5 text-muted-soft"> <p className="text-[11px] leading-5 text-muted-soft">
可选。系统仍保留通用视觉安全规则;这里用于让每次看图稳定聚焦业务目标。 {promptMode === "fixed"
? "工具调用时不会让模型改写这段内容。"
: "模型会结合当前对话生成具体问题,并受到这段内容约束。"}
</p> </p>
{generationError && ( {!prompt.trim() && (
<p className="text-xs leading-5 text-destructive"> <p className="text-xs leading-5 text-destructive">
{generationError} 当前模式需要填写提示词后才能保存。
</p> </p>
)} )}
</div> </div>
)}
</> </>
)} )}
</SectionCard> </SectionCard>
@@ -180,7 +180,8 @@ function blankPromptForm(name: string): AssistantForm {
}, },
visionEnabled: false, visionEnabled: false,
visionModelResourceId: "", visionModelResourceId: "",
visionFocusPrompt: "", visionPromptMode: "runtime",
visionPrompt: "",
toolIds: [], toolIds: [],
}; };
} }
@@ -436,7 +437,8 @@ export function AssistantPage(props: AssistantPageProps) {
}, },
visionEnabled: a.visionEnabled, visionEnabled: a.visionEnabled,
visionModelResourceId: a.visionModelResourceId ?? "", visionModelResourceId: a.visionModelResourceId ?? "",
visionFocusPrompt: a.visionFocusPrompt ?? "", visionPromptMode: a.visionPromptMode ?? "runtime",
visionPrompt: a.visionPrompt ?? "",
toolIds: a.toolIds ?? [], toolIds: a.toolIds ?? [],
}; };
setForm(next); setForm(next);
@@ -487,7 +489,8 @@ export function AssistantPage(props: AssistantPageProps) {
}, },
visionEnabled: false, visionEnabled: false,
visionModelResourceId: null, visionModelResourceId: null,
visionFocusPrompt: "", visionPromptMode: "runtime",
visionPrompt: "",
modelResourceIds: {}, modelResourceIds: {},
knowledgeBaseId: null, knowledgeBaseId: null,
knowledgeRetrievalConfig: defaultKnowledgeRetrievalConfig(), knowledgeRetrievalConfig: defaultKnowledgeRetrievalConfig(),
@@ -552,7 +555,8 @@ export function AssistantPage(props: AssistantPageProps) {
startup: form.startup, startup: form.startup,
visionEnabled: form.visionEnabled, visionEnabled: form.visionEnabled,
visionModelResourceId: form.visionModelResourceId || null, visionModelResourceId: form.visionModelResourceId || null,
visionFocusPrompt: form.visionFocusPrompt, visionPromptMode: form.visionPromptMode,
visionPrompt: form.visionPrompt,
modelResourceIds: { modelResourceIds: {
...(form.model ? { LLM: form.model } : {}), ...(form.model ? { LLM: form.model } : {}),
...(form.asr ? { ASR: form.asr } : {}), ...(form.asr ? { ASR: form.asr } : {}),
@@ -680,7 +684,8 @@ export function AssistantPage(props: AssistantPageProps) {
graph.settings?.visionModelResourceId ?? graph.settings?.visionModelResourceId ??
assistant.visionModelResourceId ?? assistant.visionModelResourceId ??
"", "",
visionFocusPrompt: graph.settings?.visionFocusPrompt ?? "", visionPromptMode: graph.settings?.visionPromptMode ?? "runtime",
visionPrompt: graph.settings?.visionPrompt ?? "",
toolIds: graph.settings?.toolIds ?? [], toolIds: graph.settings?.toolIds ?? [],
knowledgeBaseId: knowledgeBaseId:
graph.settings?.knowledgeBaseId || assistant.knowledgeBaseId || "", graph.settings?.knowledgeBaseId || assistant.knowledgeBaseId || "",
@@ -108,7 +108,8 @@ function baseUpsert(over: Partial<AssistantUpsert>): AssistantUpsert {
}, },
visionEnabled: false, visionEnabled: false,
visionModelResourceId: null, visionModelResourceId: null,
visionFocusPrompt: "", visionPromptMode: "runtime",
visionPrompt: "",
modelResourceIds: {}, modelResourceIds: {},
knowledgeBaseId: null, knowledgeBaseId: null,
knowledgeRetrievalConfig: defaultKnowledgeRetrievalConfig(), knowledgeRetrievalConfig: defaultKnowledgeRetrievalConfig(),
@@ -96,7 +96,8 @@ function baseUpsertFromTemplate(
}, },
visionEnabled: false, visionEnabled: false,
visionModelResourceId: null, visionModelResourceId: null,
visionFocusPrompt: "", visionPromptMode: "runtime",
visionPrompt: "",
modelResourceIds: {}, modelResourceIds: {},
knowledgeBaseId: null, knowledgeBaseId: null,
knowledgeRetrievalConfig: defaultKnowledgeRetrievalConfig(), knowledgeRetrievalConfig: defaultKnowledgeRetrievalConfig(),
@@ -129,7 +129,8 @@ function fromFlow(nodes: Node[], edges: Edge[]): WorkflowGraph {
defaultTtsResourceId: "", defaultTtsResourceId: "",
visionEnabled: false, visionEnabled: false,
visionModelResourceId: "", visionModelResourceId: "",
visionFocusPrompt: "", visionPromptMode: "runtime",
visionPrompt: "",
toolIds: [], toolIds: [],
knowledgeBaseId: "", knowledgeBaseId: "",
knowledgeMode: "automatic", knowledgeMode: "automatic",
@@ -15,7 +15,8 @@ export function settingsFromWorkflowGraph(
tts: graph.settings.defaultTtsResourceId, tts: graph.settings.defaultTtsResourceId,
visionEnabled: graph.settings.visionEnabled, visionEnabled: graph.settings.visionEnabled,
visionModelResourceId: graph.settings.visionModelResourceId, visionModelResourceId: graph.settings.visionModelResourceId,
visionFocusPrompt: graph.settings.visionFocusPrompt, visionPromptMode: graph.settings.visionPromptMode,
visionPrompt: graph.settings.visionPrompt,
toolIds: graph.settings.toolIds, toolIds: graph.settings.toolIds,
knowledgeBaseId: graph.settings.knowledgeBaseId, knowledgeBaseId: graph.settings.knowledgeBaseId,
knowledgeRetrievalConfig: { knowledgeRetrievalConfig: {
@@ -45,7 +46,8 @@ export function workflowGraphWithSettings(
defaultTtsResourceId: settings.tts ?? "", defaultTtsResourceId: settings.tts ?? "",
visionEnabled: settings.visionEnabled, visionEnabled: settings.visionEnabled,
visionModelResourceId: settings.visionModelResourceId, visionModelResourceId: settings.visionModelResourceId,
visionFocusPrompt: settings.visionFocusPrompt, visionPromptMode: settings.visionPromptMode,
visionPrompt: settings.visionPrompt,
toolIds: settings.toolIds, toolIds: settings.toolIds,
knowledgeBaseId: settings.knowledgeBaseId, knowledgeBaseId: settings.knowledgeBaseId,
knowledgeMode: settings.knowledgeRetrievalConfig.mode, knowledgeMode: settings.knowledgeRetrievalConfig.mode,
@@ -78,8 +78,10 @@ export function AgentNodePanel({
visionModelResourceId: visionModelResourceId:
(draft.visionModelResourceId as string) || (draft.visionModelResourceId as string) ||
workflowSettings.visionModelResourceId, workflowSettings.visionModelResourceId,
visionFocusPrompt: visionPromptMode:
draft.visionFocusPrompt ?? workflowSettings.visionFocusPrompt, draft.visionPromptMode ?? workflowSettings.visionPromptMode,
visionPrompt:
draft.visionPrompt ?? workflowSettings.visionPrompt,
...(!isRealtime ...(!isRealtime
? { ? {
llmResourceId: llmResourceId:
@@ -308,13 +310,8 @@ export function AgentNodePanel({
} }
enabled={Boolean(draft.visionEnabled)} enabled={Boolean(draft.visionEnabled)}
modelResourceId={draft.visionModelResourceId ?? ""} modelResourceId={draft.visionModelResourceId ?? ""}
focusPrompt={draft.visionFocusPrompt ?? ""} promptMode={draft.visionPromptMode ?? "runtime"}
sourcePrompt={draft.prompt ?? ""} prompt={draft.visionPrompt ?? ""}
generatorModelResourceId={
isRealtime
? draft.visionModelResourceId ?? ""
: (draft.llmResourceId as string) || ""
}
mainModelResourceId={ mainModelResourceId={
isRealtime isRealtime
? workflowSettings.realtime ?? "" ? workflowSettings.realtime ?? ""
@@ -339,8 +336,11 @@ export function AgentNodePanel({
onModelResourceIdChange={(visionModelResourceId) => onModelResourceIdChange={(visionModelResourceId) =>
set("visionModelResourceId", visionModelResourceId) set("visionModelResourceId", visionModelResourceId)
} }
onFocusPromptChange={(visionFocusPrompt) => onPromptModeChange={(visionPromptMode) =>
set("visionFocusPrompt", visionFocusPrompt) set("visionPromptMode", visionPromptMode)
}
onPromptChange={(visionPrompt) =>
set("visionPrompt", visionPrompt)
} }
/> />
@@ -184,13 +184,8 @@ export function GlobalSettingsPanel({
} }
enabled={settings.visionEnabled} enabled={settings.visionEnabled}
modelResourceId={settings.visionModelResourceId} modelResourceId={settings.visionModelResourceId}
focusPrompt={settings.visionFocusPrompt} promptMode={settings.visionPromptMode}
sourcePrompt={settings.globalPrompt} prompt={settings.visionPrompt}
generatorModelResourceId={
settings.runtimeMode === "realtime"
? settings.visionModelResourceId
: settings.llm ?? ""
}
mainModelResourceId={ mainModelResourceId={
settings.runtimeMode === "realtime" settings.runtimeMode === "realtime"
? settings.realtime ?? "" ? settings.realtime ?? ""
@@ -216,8 +211,11 @@ export function GlobalSettingsPanel({
onModelResourceIdChange={(visionModelResourceId) => onModelResourceIdChange={(visionModelResourceId) =>
onSettingsChange({ ...settings, visionModelResourceId }) onSettingsChange({ ...settings, visionModelResourceId })
} }
onFocusPromptChange={(visionFocusPrompt) => onPromptModeChange={(visionPromptMode) =>
onSettingsChange({ ...settings, visionFocusPrompt }) onSettingsChange({ ...settings, visionPromptMode })
}
onPromptChange={(visionPrompt) =>
onSettingsChange({ ...settings, visionPrompt })
} }
/> />
+7 -4
View File
@@ -3,7 +3,7 @@
import * as LucideIcons from "lucide-react"; import * as LucideIcons from "lucide-react";
import { Circle, type LucideIcon } from "lucide-react"; import { Circle, type LucideIcon } from "lucide-react";
import type { NodeSpecDto, TurnConfig } from "@/lib/api"; import type { NodeSpecDto, TurnConfig, VisionPromptMode } from "@/lib/api";
import { defaultTurnConfig } from "@/lib/turn-config"; import { defaultTurnConfig } from "@/lib/turn-config";
export type WorkflowNodeType = export type WorkflowNodeType =
@@ -54,7 +54,8 @@ export type WorkflowNodeData = {
ttsResourceId?: string; ttsResourceId?: string;
visionEnabled?: boolean; visionEnabled?: boolean;
visionModelResourceId?: string; visionModelResourceId?: string;
visionFocusPrompt?: string; visionPromptMode?: VisionPromptMode;
visionPrompt?: string;
enableInterrupt?: boolean; enableInterrupt?: boolean;
turnConfig?: TurnConfig; turnConfig?: TurnConfig;
toolId?: string; toolId?: string;
@@ -214,7 +215,8 @@ export type WorkflowGraph = {
defaultTtsResourceId: string; defaultTtsResourceId: string;
visionEnabled: boolean; visionEnabled: boolean;
visionModelResourceId: string; visionModelResourceId: string;
visionFocusPrompt: string; visionPromptMode: VisionPromptMode;
visionPrompt: string;
toolIds: string[]; toolIds: string[];
knowledgeBaseId: string; knowledgeBaseId: string;
knowledgeMode: "automatic" | "on_demand"; knowledgeMode: "automatic" | "on_demand";
@@ -252,7 +254,8 @@ export function defaultGraph(): WorkflowGraph {
defaultTtsResourceId: "", defaultTtsResourceId: "",
visionEnabled: false, visionEnabled: false,
visionModelResourceId: "", visionModelResourceId: "",
visionFocusPrompt: "", visionPromptMode: "runtime",
visionPrompt: "",
toolIds: [], toolIds: [],
knowledgeBaseId: "", knowledgeBaseId: "",
knowledgeMode: "automatic", knowledgeMode: "automatic",
+3 -1
View File
@@ -5,6 +5,7 @@ import type {
SystemToolKind, SystemToolKind,
Tool, Tool,
TurnConfig, TurnConfig,
VisionPromptMode,
} from "@/lib/api"; } from "@/lib/api";
import type { WorkflowGraph } from "./specs"; import type { WorkflowGraph } from "./specs";
@@ -18,7 +19,8 @@ export type WorkflowSettings = {
tts?: string; tts?: string;
visionEnabled: boolean; visionEnabled: boolean;
visionModelResourceId: string; visionModelResourceId: string;
visionFocusPrompt: string; visionPromptMode: VisionPromptMode;
visionPrompt: string;
toolIds: string[]; toolIds: string[];
knowledgeBaseId: string; knowledgeBaseId: string;
knowledgeRetrievalConfig: KnowledgeRetrievalConfig; knowledgeRetrievalConfig: KnowledgeRetrievalConfig;
+4 -9
View File
@@ -315,6 +315,8 @@ export type AnalysisConfig = {
fields: AnalysisField[]; fields: AnalysisField[];
}; };
export type VisionPromptMode = "fixed" | "runtime" | "guided";
/** 后端 AssistantOut(宽表 STI:瘦字段平铺,workflow 用 graph)。apiKey 读时打码 */ /** 后端 AssistantOut(宽表 STI:瘦字段平铺,workflow 用 graph)。apiKey 读时打码 */
export type Assistant = { export type Assistant = {
id: string; id: string;
@@ -327,7 +329,8 @@ export type Assistant = {
startup: StartupConfig; startup: StartupConfig;
visionEnabled: boolean; visionEnabled: boolean;
visionModelResourceId: string | null; visionModelResourceId: string | null;
visionFocusPrompt: string; visionPromptMode: VisionPromptMode;
visionPrompt: string;
modelResourceIds: Partial<Record<ModelType, string>>; modelResourceIds: Partial<Record<ModelType, string>>;
knowledgeBaseId: string | null; knowledgeBaseId: string | null;
knowledgeRetrievalConfig: KnowledgeRetrievalConfig; knowledgeRetrievalConfig: KnowledgeRetrievalConfig;
@@ -372,14 +375,6 @@ export const assistantsApi = {
// 服务端整行复制(含真 key,密钥不经浏览器) // 服务端整行复制(含真 key,密钥不经浏览器)
duplicate: (id: string) => duplicate: (id: string) =>
request<Assistant>(`/api/assistants/${id}/duplicate`, { method: "POST" }), request<Assistant>(`/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) => remove: (id: string) =>
request<{ ok: boolean }>(`/api/assistants/${id}`, { method: "DELETE" }), request<{ ok: boolean }>(`/api/assistants/${id}`, { method: "DELETE" }),
}; };