Compare commits

...

31 Commits

Author SHA1 Message Date
Xin Wang
fc026e765e fix: record message confirmations in context 2026-08-11 14:15:10 +08:00
Xin Wang
86639692ba feat: implement OpenAI-compatible Realtime API with authentication and management features
- Added support for public Realtime API, including new routes for managing API keys and handling WebRTC connections.
- Introduced RealtimeApiKey model and associated CRUD operations for admin management of API keys.
- Implemented authentication mechanisms for API keys and client secrets.
- Enhanced environment configuration with new secrets for Realtime API.
- Created OpenAIRealtime session management and event processing for real-time interactions.
- Updated schemas and settings to accommodate new features and ensure compatibility with existing systems.
2026-08-11 10:05:55 +08:00
Xin Wang
cf32ea605d feat: mock continuous voice test editor 2026-08-10 14:46:29 +08:00
Xin Wang
19e8c8c108 feat: add persisted batch text testing 2026-08-10 13:49:24 +08:00
Xin Wang
5b8b9fd097 Remove .DS_Store from version control and update .gitignore. Enhance BatchCaseDetailDrawer and BatchRunCompletedView components to support error handling and improved UI for displaying test case statuses. Add functionality for editing and rerunning test cases. Update TestCasesPage to handle initial case selection. Refactor related components for better maintainability. 2026-08-10 09:33:29 +08:00
Xin Wang
ba6c7c6be3 Expand test case fixed-input editor and batch case detail split view.
Unify multi-turn behaviors (reply/tool), overall expectation, and input-mode picker while keeping non-text modes as coming soon; batch completed/running use a half-and-half detail panel below the top bar.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 17:08:53 +08:00
Xin Wang
e988f9c133 Improve test case selection ui 2026-08-08 20:52:38 +08:00
Xin Wang
433ff0b255 Update batch test page 2026-08-08 19:08:48 +08:00
Xin Wang
21a25874a9 feat(frontend): implement batch testing views and status components
Add components for batch testing, including BatchRunCompletedView and BatchRunRunningView, to display test results and progress. Introduce BatchCaseStatusBadge and BatchPhasePill for visual status representation. Implement mock data handling for batch run snapshots and case statuses, enhancing the user experience for managing batch tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 17:37:48 +08:00
Xin Wang
398b20778d feat(frontend): add Test Suite management and Next Reply case editor
Replace the test-cases placeholder with suite list/detail flows, multi-select and drag reorder, and an edit-only single-step reply editor with keyword/LLM assertions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 17:03:12 +08:00
Xin Wang
2f755969d3 feat(frontend): add auto test functionality to debug drawer
Introduce a new auto test mode in the debug drawer, allowing users to run automated tests with mock data. This includes the addition of AutoTestPanel, AutoTestLivePanel, and AutoTestRunBar components for managing test cases and displaying results. The debug mode can now switch between manual and auto testing, enhancing the debugging experience.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 16:09:18 +08:00
Xin Wang
36117b976e feat(frontend): split test assistant nav into cases and batch pages
Make 测试助手 a sidebar section like 监控观察, with placeholder routes for 测试用例 and 批量测试.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 16:00:50 +08:00
Xin Wang
b2e99baa19 Implement webhook 2026-08-07 14:08:49 +08:00
Xin Wang
39ee5ef96e refactor(backend): change prompt field type from varchar(8192) to text
Update the 'prompt' field in the Assistant model to use a Text type for improved flexibility. This change is accompanied by a migration script to alter the database schema accordingly.
2026-08-07 11:30:58 +08:00
Xin Wang
a9d54e7c2b feat(frontend): enhance ProfilePage with language selection and scrollable sections
Add language selection functionality and implement scrollable sections for the ProfilePage. Introduce a new SectionAnchorTabs component for navigation between Webhook and Language sections, improving user experience and organization of settings.
2026-08-07 11:28:05 +08:00
Xin Wang
bdbda8047d Add webhook to profile 2026-08-07 11:21:09 +08:00
Xin Wang
7575afaeee Splite docs to developer and user and bring profile link back 2026-08-07 11:13:45 +08:00
Xin Wang
95e04d8c94 Split Mintlify docs into user and developer sections.
Organize product guides under docs/user and setup/integration under docs/developer, with tabbed navigation for clearer audience targeting.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 11:09:10 +08:00
Xin Wang
56a32e81ca Add analysis feature 2026-08-07 10:59:47 +08:00
Xin Wang
485ad623d4 feat(frontend): improve history detail and model list UX
Open model resources via row click, restyle history detail with
session/analysis panels and mock playback, and theme scrollbars
globally with navy light/dark tokens.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 00:25:58 +08:00
Xin Wang
87b6392eea style(frontend): enhance button styling in AssistantPage
Update button styles in AssistantPage to include minimum width and padding for better layout consistency across different screen sizes.
2026-08-06 23:19:00 +08:00
Xin Wang
b51b768c29 feat(frontend): add template library and extract create-assistant page
Introduce a split-view template library for creating assistants from presets, share selection cards between create and template flows, and move the choose step out of AssistantPage into CreateAssistantPage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 23:15:06 +08:00
Xin Wang
723ee84925 feat(frontend): add assistant delete menu to prompt editor topbar
Place a more-actions dropdown beside the assistant ID with confirmed delete that returns to the assistant list after removal.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 17:08:40 +08:00
Xin Wang
b6e2ac5765 feat(frontend): add mock post-call analysis and webhook sections to prompt editor
Introduce Analysis and Webhook section cards with local mock state so operators can configure post-call extraction fields and delivery before backend wiring.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 16:57:31 +08:00
Xin Wang
4d00e719c4 fix(chat): preserve user image message order 2026-08-06 13:59:59 +08:00
Xin Wang
4a07abd7fc fix(llm): adapt async tool results for compatible APIs 2026-08-06 10:22:05 +08:00
Xin Wang
aaca128f82 fix(workflow): track fixed speech at transport output 2026-08-06 10:21:36 +08:00
Ubuntu
5cb13a3cac Merge branch 'main' of https://gitea.xiaowang.eu.org/wx44wx/ai-video-fullstack 2026-08-06 08:35:25 +08:00
Xin Wang
90bb27a377 fix: release input mute after async client tools 2026-08-06 00:12:34 +08:00
Ubuntu
8bd781242a fix(workflow): prevent untracked BotStoppedSpeakingFrame from consuming end-node tracked-speech counter
The CallEndCoordinator.observe() decremented _tracked_speeches on every
BotStoppedSpeakingFrame, not just the one corresponding to tracked
(end-node) speech.  When a preceding LLM utterance finished between
track_speech() and the actual fixed-speech playback, the counter was
consumed prematurely, ending the call before the end-node message played.

Gate the decrement behind a _current_speech_is_tracked flag that is set
only when BotStartedSpeakingFrame follows a pending tracked-speech start.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 22:35:06 +08:00
Xin Wang
0c0a51da95 Improve image upload in debug drawer 2026-08-05 21:28:51 +08:00
129 changed files with 19647 additions and 663 deletions

BIN
.DS_Store vendored

Binary file not shown.

4
.gitignore vendored
View File

@@ -1,3 +1,7 @@
# Local state created by docker-compose services.
/data/
/logs/
# Local editor and UX review artifacts.
.DS_Store
/artifacts/

View File

@@ -9,11 +9,19 @@ DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/postgres
# ---- 服务监听 & 跨域 ----
HOST=0.0.0.0
PORT=8000
# NLTK 3.9+ import guard vs local .venv under backend/ (set in app.py by default; override here if needed)
# NLTK_DISABLE_IMPORT_SECURITY=1
# 前端开发地址,允许跨域(公网部署时加上实际前端 origin)
CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
# ---- OpenAI-compatible Realtime API ----
# 用独立随机值签名长期 API Key 摘要和短期 Client Secret生产环境必须修改。
REALTIME_TOKEN_SECRET=replace-with-a-long-random-secret
REALTIME_CLIENT_SECRET_TTL_SECONDS=60
# ---- RustFS / S3-compatible storage ----
S3_ENDPOINT_URL=http://localhost:9000
# Use 127.0.0.1 (not localhost) if HTTP_PROXY is set — proxies often return 502 for localhost.
S3_ENDPOINT_URL=http://127.0.0.1:9000
S3_ACCESS_KEY=rustfsadmin
S3_SECRET_KEY=rustfsadmin
S3_BUCKET=ai-video

View File

@@ -12,12 +12,25 @@
/api/webrtc/ice-servers WebRTC STUN/TURN 配置
"""
import asyncio
import os
# NLTK 3.9+ blocks dependency imports when .venv lives under cwd (local dev layout).
# pipecat -> nltk -> regex hits this false positive; disable before any nltk import.
os.environ.setdefault("NLTK_DISABLE_IMPORT_SECURITY", "1")
from contextlib import asynccontextmanager
import settings
import uvicorn
from db.session import sync_default_tools, sync_interface_definitions
from services.knowledge import recover_interrupted_documents
from services.post_call.worker import recover_interrupted_analyses, run_analysis_worker
from services.webhooks.worker import recover_interrupted_webhooks, run_webhook_worker
from services.test_runs.orchestrator import (
recover_interrupted_test_runs,
test_run_orchestrator,
)
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
@@ -31,6 +44,11 @@ from routes import (
mcp_servers,
model_registry,
node_types,
openai_realtime,
realtime_api_keys,
site_settings,
test_cases,
test_runs,
tools,
voice_webrtc,
voice_ws,
@@ -42,9 +60,24 @@ async def lifespan(_app: FastAPI):
await sync_interface_definitions()
await sync_default_tools()
await recover_interrupted_documents()
await recover_interrupted_analyses()
await recover_interrupted_webhooks()
await recover_interrupted_test_runs()
analysis_worker = asyncio.create_task(
run_analysis_worker(), name="post-call-analysis-worker"
)
webhook_worker = asyncio.create_task(
run_webhook_worker(), name="analysis-webhook-worker"
)
try:
yield
finally:
analysis_worker.cancel()
webhook_worker.cancel()
await asyncio.gather(
analysis_worker, webhook_worker, return_exceptions=True
)
await test_run_orchestrator.shutdown()
await voice_webrtc.shutdown_active_sessions()
@@ -67,6 +100,11 @@ app.include_router(knowledge_bases.router)
app.include_router(mcp_servers.router)
app.include_router(model_registry.router)
app.include_router(node_types.router)
app.include_router(openai_realtime.router)
app.include_router(realtime_api_keys.router)
app.include_router(site_settings.router)
app.include_router(test_cases.router)
app.include_router(test_runs.router)
app.include_router(tools.router)
app.include_router(voice_webrtc.router)
app.include_router(voice_ws.router)

View File

@@ -171,8 +171,9 @@ class Assistant(Base):
)
# ---- 瘦类型专属字段(真列,稀疏:按 type 用其中几列) ----
prompt: Mapped[str] = mapped_column(String(8192), default="") # prompt / opencode
prompt: Mapped[str] = mapped_column(Text, default="") # prompt / opencode
dynamic_variable_definitions: Mapped[dict] = mapped_column(JSON, default=dict)
analysis_config: Mapped[dict] = mapped_column(JSONB, default=dict)
api_url: Mapped[str] = mapped_column(String(512), default="") # dify / fastgpt / opencode
api_key: Mapped[str] = mapped_column(String(512), default="") # dify / fastgpt / opencode(打码/哨兵)
app_id: Mapped[str] = mapped_column(String(128), default="") # fastgpt
@@ -313,6 +314,11 @@ class ConversationSession(Base):
ended_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
analysis_status: Mapped[str] = mapped_column(
String(16), index=True, default="none"
)
analysis_data: Mapped[dict] = mapped_column(JSONB, default=dict)
analysis_error: Mapped[str] = mapped_column(String(2048), default="")
extra: Mapped[dict] = mapped_column(JSONB, default=dict)
@@ -368,3 +374,189 @@ class ConversationArtifact(Base):
DateTime(timezone=True), server_default=func.now()
)
extra: Mapped[dict] = mapped_column(JSONB, default=dict)
class SiteSetting(Base):
"""Deployment-wide settings, split into public values and write-only secrets."""
__tablename__ = "site_settings"
key: Mapped[str] = mapped_column(String(64), primary_key=True)
value: Mapped[dict] = mapped_column(JSONB, default=dict)
secrets: Mapped[dict] = mapped_column(JSONB, default=dict)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class RealtimeApiKey(Base):
"""Hashed deployment-level credential for the public Realtime API."""
__tablename__ = "realtime_api_keys"
id: Mapped[str] = mapped_column(String(40), primary_key=True)
name: Mapped[str] = mapped_column(String(128))
key_prefix: Mapped[str] = mapped_column(String(32), unique=True, index=True)
key_hash: Mapped[str] = mapped_column(String(64))
expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
last_used_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
revoked_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class WebhookDelivery(Base):
"""Immutable event snapshot plus its asynchronous delivery state."""
__tablename__ = "webhook_deliveries"
id: Mapped[str] = mapped_column(String(40), primary_key=True)
event_id: Mapped[str] = mapped_column(String(40), unique=True)
event_type: Mapped[str] = mapped_column(String(128), index=True)
conversation_id: Mapped[str | None] = mapped_column(
String(40),
ForeignKey("conversation_sessions.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
payload: Mapped[dict] = mapped_column(JSONB)
url: Mapped[str] = mapped_column(String(2048))
secrets: Mapped[dict] = mapped_column(JSONB, default=dict)
status: Mapped[str] = mapped_column(String(16), index=True, default="pending")
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
next_attempt_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
last_status_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
last_error: Mapped[str] = mapped_column(String(2048), default="")
delivered_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class TestSuite(Base):
"""A persisted group of text-based assistant test cases."""
__tablename__ = "test_suites"
id: Mapped[str] = mapped_column(String(40), primary_key=True)
name: Mapped[str] = mapped_column(String(128))
description: Mapped[str] = mapped_column(String(2048), default="")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class TestCase(Base):
"""One editable test definition; nested turns remain one JSON document."""
__tablename__ = "test_cases"
__table_args__ = (
UniqueConstraint("suite_id", "sort_order", name="uq_test_case_suite_order"),
)
id: Mapped[str] = mapped_column(String(40), primary_key=True)
suite_id: Mapped[str] = mapped_column(
String(40),
ForeignKey("test_suites.id", ondelete="CASCADE"),
index=True,
)
name: Mapped[str] = mapped_column(String(128))
description: Mapped[str] = mapped_column(String(2048), default="")
input_mode: Mapped[str] = mapped_column(
String(40), default="fixed_script_text", index=True
)
definition: Mapped[dict] = mapped_column(JSONB, default=dict)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
last_result: Mapped[str] = mapped_column(String(16), default="not_run")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class TestRun(Base):
"""One immutable batch-run request plus its aggregate lifecycle state."""
__tablename__ = "test_runs"
id: Mapped[str] = mapped_column(String(40), primary_key=True)
assistant_id: Mapped[str | None] = mapped_column(
String(40),
ForeignKey("assistants.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
assistant_name: Mapped[str] = mapped_column(String(128), default="")
title: Mapped[str] = mapped_column(String(256), default="批量测试")
status: Mapped[str] = mapped_column(String(16), index=True, default="queued")
config: Mapped[dict] = mapped_column(JSONB, default=dict)
stop_reason: Mapped[str | None] = mapped_column(String(32), nullable=True)
cancel_requested: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
started_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
finished_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
class TestRunCase(Base):
"""Run-local test case snapshot and detailed execution result."""
__tablename__ = "test_run_cases"
__table_args__ = (
UniqueConstraint("run_id", "test_case_id", name="uq_test_run_case"),
)
id: Mapped[str] = mapped_column(String(40), primary_key=True)
run_id: Mapped[str] = mapped_column(
String(40),
ForeignKey("test_runs.id", ondelete="CASCADE"),
index=True,
)
test_case_id: Mapped[str | None] = mapped_column(
String(40),
ForeignKey("test_cases.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
test_case_name: Mapped[str] = mapped_column(String(128))
suite_id: Mapped[str | None] = mapped_column(String(40), nullable=True)
position: Mapped[int] = mapped_column(Integer, default=0)
case_snapshot: Mapped[dict] = mapped_column(JSONB, default=dict)
status: Mapped[str] = mapped_column(String(16), index=True, default="waiting")
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
max_attempts: Mapped[int] = mapped_column(Integer, default=1)
result: Mapped[dict] = mapped_column(JSONB, default=dict)
execution_error: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
started_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
finished_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)

View File

@@ -0,0 +1,75 @@
"""add post-call analysis configuration and results
Revision ID: 20260807_0013
Revises: 20260804_0012
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "20260807_0013"
down_revision: str | Sequence[str] | None = "20260804_0012"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"assistants",
sa.Column(
"analysis_config",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
server_default=sa.text("'{}'::jsonb"),
),
)
op.add_column(
"conversation_sessions",
sa.Column(
"analysis_status",
sa.String(length=16),
nullable=False,
server_default="none",
),
)
op.add_column(
"conversation_sessions",
sa.Column(
"analysis_data",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
server_default=sa.text("'{}'::jsonb"),
),
)
op.add_column(
"conversation_sessions",
sa.Column(
"analysis_error",
sa.String(length=2048),
nullable=False,
server_default="",
),
)
op.create_index(
"ix_conversation_sessions_analysis_status",
"conversation_sessions",
["analysis_status"],
unique=False,
)
def downgrade() -> None:
op.drop_index(
"ix_conversation_sessions_analysis_status",
table_name="conversation_sessions",
)
op.drop_column("conversation_sessions", "analysis_error")
op.drop_column("conversation_sessions", "analysis_data")
op.drop_column("conversation_sessions", "analysis_status")
op.drop_column("assistants", "analysis_config")

View File

@@ -0,0 +1,40 @@
"""change assistants.prompt from varchar(8192) to text
Revision ID: 20260807_0014
Revises: 20260807_0013
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
revision: str = "20260807_0014"
down_revision: str | Sequence[str] | None = "20260807_0013"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.alter_column(
"assistants",
"prompt",
existing_type=sa.String(length=8192),
type_=sa.Text(),
existing_nullable=False,
existing_server_default=sa.text("''"),
)
def downgrade() -> None:
op.alter_column(
"assistants",
"prompt",
existing_type=sa.Text(),
type_=sa.String(length=8192),
existing_nullable=False,
existing_server_default=sa.text("''"),
)

View File

@@ -0,0 +1,86 @@
"""add global analysis webhook settings and delivery queue
Revision ID: 20260807_0015
Revises: 20260807_0014
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "20260807_0015"
down_revision: str | Sequence[str] | None = "20260807_0014"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"site_settings",
sa.Column("key", sa.String(length=64), nullable=False),
sa.Column(
"value",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'{}'::jsonb"),
nullable=False,
),
sa.Column(
"secrets",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'{}'::jsonb"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("key"),
)
op.create_table(
"webhook_deliveries",
sa.Column("id", sa.String(length=40), nullable=False),
sa.Column("event_id", sa.String(length=40), nullable=False),
sa.Column("event_type", sa.String(length=128), nullable=False),
sa.Column("conversation_id", sa.String(length=40), nullable=True),
sa.Column("payload", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("url", sa.String(length=2048), nullable=False),
sa.Column(
"secrets",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'{}'::jsonb"),
nullable=False,
),
sa.Column("status", sa.String(length=16), server_default="pending", nullable=False),
sa.Column("attempt_count", sa.Integer(), server_default="0", nullable=False),
sa.Column("next_attempt_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_status_code", sa.Integer(), nullable=True),
sa.Column("last_error", sa.String(length=2048), server_default="", nullable=False),
sa.Column("delivered_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(
["conversation_id"], ["conversation_sessions.id"], ondelete="SET NULL"
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("event_id"),
)
op.create_index("ix_webhook_deliveries_event_type", "webhook_deliveries", ["event_type"])
op.create_index("ix_webhook_deliveries_conversation_id", "webhook_deliveries", ["conversation_id"])
op.create_index("ix_webhook_deliveries_status", "webhook_deliveries", ["status"])
op.create_index("ix_webhook_deliveries_next_attempt_at", "webhook_deliveries", ["next_attempt_at"])
def downgrade() -> None:
op.drop_index("ix_webhook_deliveries_next_attempt_at", table_name="webhook_deliveries")
op.drop_index("ix_webhook_deliveries_status", table_name="webhook_deliveries")
op.drop_index("ix_webhook_deliveries_conversation_id", table_name="webhook_deliveries")
op.drop_index("ix_webhook_deliveries_event_type", table_name="webhook_deliveries")
op.drop_table("webhook_deliveries")
op.drop_table("site_settings")

View File

@@ -0,0 +1,112 @@
"""add persisted test suites, cases, and batch runs
Revision ID: 20260810_0016
Revises: 20260807_0015
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "20260810_0016"
down_revision: str | Sequence[str] | None = "20260807_0015"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"test_suites",
sa.Column("id", sa.String(length=40), nullable=False),
sa.Column("name", sa.String(length=128), nullable=False),
sa.Column("description", sa.String(length=2048), server_default="", nullable=False),
sa.Column("assistant_id", sa.String(length=40), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["assistant_id"], ["assistants.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_test_suites_assistant_id", "test_suites", ["assistant_id"])
op.create_table(
"test_cases",
sa.Column("id", sa.String(length=40), nullable=False),
sa.Column("suite_id", sa.String(length=40), nullable=False),
sa.Column("name", sa.String(length=128), nullable=False),
sa.Column("description", sa.String(length=2048), server_default="", nullable=False),
sa.Column("input_mode", sa.String(length=40), server_default="fixed_script_text", nullable=False),
sa.Column("definition", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
sa.Column("sort_order", sa.Integer(), server_default="0", nullable=False),
sa.Column("last_result", sa.String(length=16), server_default="not_run", nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["suite_id"], ["test_suites.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("suite_id", "sort_order", name="uq_test_case_suite_order"),
)
op.create_index("ix_test_cases_suite_id", "test_cases", ["suite_id"])
op.create_index("ix_test_cases_input_mode", "test_cases", ["input_mode"])
op.create_table(
"test_runs",
sa.Column("id", sa.String(length=40), nullable=False),
sa.Column("assistant_id", sa.String(length=40), nullable=True),
sa.Column("assistant_name", sa.String(length=128), server_default="", nullable=False),
sa.Column("title", sa.String(length=256), server_default="批量测试", nullable=False),
sa.Column("status", sa.String(length=16), server_default="queued", nullable=False),
sa.Column("config", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
sa.Column("stop_reason", sa.String(length=32), nullable=True),
sa.Column("cancel_requested", sa.Boolean(), server_default=sa.text("false"), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["assistant_id"], ["assistants.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_test_runs_assistant_id", "test_runs", ["assistant_id"])
op.create_index("ix_test_runs_status", "test_runs", ["status"])
op.create_table(
"test_run_cases",
sa.Column("id", sa.String(length=40), nullable=False),
sa.Column("run_id", sa.String(length=40), nullable=False),
sa.Column("test_case_id", sa.String(length=40), nullable=True),
sa.Column("test_case_name", sa.String(length=128), nullable=False),
sa.Column("suite_id", sa.String(length=40), nullable=True),
sa.Column("position", sa.Integer(), server_default="0", nullable=False),
sa.Column("case_snapshot", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
sa.Column("status", sa.String(length=16), server_default="waiting", nullable=False),
sa.Column("attempt_count", sa.Integer(), server_default="0", nullable=False),
sa.Column("max_attempts", sa.Integer(), server_default="1", nullable=False),
sa.Column("result", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
sa.Column("execution_error", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["run_id"], ["test_runs.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["test_case_id"], ["test_cases.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("run_id", "test_case_id", name="uq_test_run_case"),
)
op.create_index("ix_test_run_cases_run_id", "test_run_cases", ["run_id"])
op.create_index("ix_test_run_cases_test_case_id", "test_run_cases", ["test_case_id"])
op.create_index("ix_test_run_cases_status", "test_run_cases", ["status"])
def downgrade() -> None:
op.drop_index("ix_test_run_cases_status", table_name="test_run_cases")
op.drop_index("ix_test_run_cases_test_case_id", table_name="test_run_cases")
op.drop_index("ix_test_run_cases_run_id", table_name="test_run_cases")
op.drop_table("test_run_cases")
op.drop_index("ix_test_runs_status", table_name="test_runs")
op.drop_index("ix_test_runs_assistant_id", table_name="test_runs")
op.drop_table("test_runs")
op.drop_index("ix_test_cases_input_mode", table_name="test_cases")
op.drop_index("ix_test_cases_suite_id", table_name="test_cases")
op.drop_table("test_cases")
op.drop_index("ix_test_suites_assistant_id", table_name="test_suites")
op.drop_table("test_suites")

View File

@@ -0,0 +1,43 @@
"""remove obsolete assistant binding from test suites
Revision ID: 20260810_0017
Revises: 20260810_0016
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
revision: str = "20260810_0017"
down_revision: str | Sequence[str] | None = "20260810_0016"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.drop_index("ix_test_suites_assistant_id", table_name="test_suites")
op.drop_column("test_suites", "assistant_id")
def downgrade() -> None:
op.add_column(
"test_suites",
sa.Column("assistant_id", sa.String(length=40), nullable=True),
)
op.create_foreign_key(
"fk_test_suites_assistant_id_assistants",
"test_suites",
"assistants",
["assistant_id"],
["id"],
ondelete="SET NULL",
)
op.create_index(
"ix_test_suites_assistant_id",
"test_suites",
["assistant_id"],
)

View File

@@ -0,0 +1,58 @@
"""add public Realtime API keys
Revision ID: 20260810_0018
Revises: 20260810_0017
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
revision: str = "20260810_0018"
down_revision: str | Sequence[str] | None = "20260810_0017"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"realtime_api_keys",
sa.Column("id", sa.String(length=40), nullable=False),
sa.Column("name", sa.String(length=128), nullable=False),
sa.Column("key_prefix", sa.String(length=32), nullable=False),
sa.Column("key_hash", sa.String(length=64), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_realtime_api_keys_key_prefix",
"realtime_api_keys",
["key_prefix"],
unique=True,
)
def downgrade() -> None:
op.drop_index(
"ix_realtime_api_keys_key_prefix",
table_name="realtime_api_keys",
)
op.drop_table("realtime_api_keys")

View File

@@ -107,6 +107,7 @@ class AssistantConfig(BaseModel):
enableInterrupt: bool = True
turnConfig: dict = Field(default_factory=dict)
startup: dict = Field(default_factory=dict)
analysis_config: dict = Field(default_factory=dict)
# ``tools`` is the complete runtime pool (conversation + lifecycle actions).
# ``llm_tool_ids`` limits which tools are advertised to a Prompt model. None

View File

@@ -277,6 +277,17 @@ async def _validate_vision_model(
raise HTTPException(400, "视觉模型必须支持图片输入")
async def _validate_analysis_model(
session: AsyncSession, body: AssistantUpsert
) -> None:
config = body.analysis_config
if not config.enabled:
return
resource = await session.get(ModelResource, config.model_resource_id)
if not resource or not resource.enabled or resource.capability != "LLM":
raise HTTPException(400, "分析模型必须引用已启用的 LLM 模型资源")
async def _validate_knowledge_base(session: AsyncSession, body: AssistantUpsert) -> None:
if body.runtime_mode != "pipeline" or body.type not in {"prompt", "workflow"}:
body.knowledge_base_id = None
@@ -384,6 +395,7 @@ async def _to_out(session: AsyncSession, assistant: Assistant) -> AssistantOut:
tool_ids=await _tool_ids(session, assistant.id),
prompt=assistant.prompt,
dynamic_variable_definitions=assistant.dynamic_variable_definitions or {},
analysis_config=assistant.analysis_config or {},
api_url=assistant.api_url,
api_key=mask(assistant.api_key),
app_id=assistant.app_id,
@@ -413,6 +425,7 @@ async def create_assistant(
await _validate_system_tool_selection(session, body)
await _validate_startup_actions(session, body)
await _validate_vision_model(session, body)
await _validate_analysis_model(session, body)
await _validate_knowledge_base(session, body)
data = body.model_dump()
resource_ids = data.pop("model_resource_ids")
@@ -459,6 +472,7 @@ async def duplicate_assistant(
knowledge_retrieval_config=dict(source.knowledge_retrieval_config or {}),
prompt=source.prompt,
dynamic_variable_definitions=dict(source.dynamic_variable_definitions or {}),
analysis_config=dict(source.analysis_config or {}),
api_url=source.api_url,
api_key=source.api_key,
app_id=source.app_id,
@@ -489,6 +503,7 @@ async def update_assistant(
await _validate_system_tool_selection(session, body)
await _validate_startup_actions(session, body)
await _validate_vision_model(session, body)
await _validate_analysis_model(session, body)
await _validate_knowledge_base(session, body)
data = body.model_dump()
resource_ids = data.pop("model_resource_ids")

View File

@@ -8,6 +8,8 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Response
from loguru import logger
from schemas import (
ConversationArtifactOut,
ConversationAnalysisFieldOut,
ConversationAnalysisOut,
ConversationDetailOut,
ConversationListOut,
ConversationMessageOut,
@@ -40,6 +42,33 @@ def _session_out(row: ConversationSession) -> ConversationOut:
)
def _analysis_out(row: ConversationSession) -> ConversationAnalysisOut:
data = row.analysis_data or {}
plan = data.get("plan") if isinstance(data.get("plan"), dict) else {}
definitions = plan.get("fields") if isinstance(plan.get("fields"), list) else []
result = data.get("result") if isinstance(data.get("result"), dict) else {}
fields = []
for definition in definitions:
if not isinstance(definition, dict):
continue
name = str(definition.get("name") or "")
if not name:
continue
fields.append(
ConversationAnalysisFieldOut(
name=name,
type=str(definition.get("type") or "string"),
value=result.get(name),
)
)
return ConversationAnalysisOut(
status=row.analysis_status or "none",
fields=fields,
error=row.analysis_error or "",
completed_at=data.get("completedAt"),
)
@router.get("", response_model=ConversationListOut)
async def list_conversations(
page: int = Query(1, ge=1),
@@ -119,6 +148,7 @@ async def get_conversation(
return ConversationDetailOut(
**_session_out(conversation).model_dump(),
extra=conversation.extra or {},
analysis=_analysis_out(conversation),
messages=[
ConversationMessageOut(
id=message.id,

View File

@@ -0,0 +1,341 @@
"""Public OpenAI-compatible Realtime HTTP, WebRTC, and WebSocket entries."""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Any
from uuid import uuid4
from db.session import SessionLocal
from fastapi import APIRouter, Request, WebSocket
from fastapi.responses import JSONResponse, Response
from loguru import logger
from starlette.websockets import WebSocketState
from services.openai_realtime.auth import (
RealtimeAuthError,
RealtimeCredential,
authenticate_bearer,
bearer_from_authorization,
create_client_secret,
hash_safety_identifier,
)
from services.openai_realtime.bridge import OpenAIRealtimeBridge
from services.openai_realtime.events import (
RealtimeEventError,
assistant_id_from_model,
)
from services.openai_realtime.session import OpenAIRealtimeSession
from services.openai_realtime.webrtc import OpenAIRealtimeWebRTCConnection
from services.openai_realtime.websocket import build_openai_websocket_transport
from services.pipecat.pipeline import run_pipeline
from services.pipecat.transports import build_webrtc_transport
from services.realtime import lifecycle
from services.realtime.launcher import (
resolve_assistant_config,
validate_runtime_requirements,
validate_visual_runtime,
)
from services.webrtc_ice import aiortc_ice_servers
router = APIRouter(prefix="/v1/realtime", tags=["openai-realtime"])
_webrtc_peers: dict[str, OpenAIRealtimeWebRTCConnection] = {}
def _api_error(message: str, *, code: str, status_code: int) -> JSONResponse:
return JSONResponse(
status_code=status_code,
content={
"error": {
"type": "invalid_request_error",
"code": code,
"message": message,
"param": None,
}
},
)
async def _credential(request: Request) -> RealtimeCredential:
token = bearer_from_authorization(request.headers.get("authorization"))
async with SessionLocal() as db:
return await authenticate_bearer(db, token)
def _session_payload(value: object) -> dict[str, Any]:
if not isinstance(value, dict):
raise RealtimeEventError("session must be an object", param="session")
session = value.get("session", value)
if not isinstance(session, dict):
raise RealtimeEventError("session must be an object", param="session")
return session
def _reject_locked_creation_fields(session: dict[str, Any]) -> None:
locked = {"instructions", "voice", "tools", "tool_choice"}.intersection(session)
audio = session.get("audio")
if isinstance(audio, dict) and isinstance(audio.get("output"), dict):
if "voice" in audio["output"]:
locked.add("audio.output.voice")
if locked:
field = sorted(locked)[0]
raise RealtimeEventError(
f"{field} is owned by the selected assistant",
code="immutable_session_field",
param=f"session.{field}",
)
modalities = session.get("output_modalities")
if modalities is not None and modalities not in (["audio"], ["text"]):
raise RealtimeEventError(
'output_modalities must be ["audio"] or ["text"]',
param="session.output_modalities",
)
async def _build_session(
credential: RealtimeCredential,
requested: dict[str, Any] | None,
*,
safety_identifier_hash: str | None,
) -> OpenAIRealtimeSession:
session_value = dict(credential.session or requested or {})
_reject_locked_creation_fields(session_value)
requested_assistant_id = assistant_id_from_model(session_value.get("model"))
if credential.assistant_id and requested_assistant_id != credential.assistant_id:
raise RealtimeEventError(
"Client secret is bound to another assistant",
code="invalid_model",
param="session.model",
)
config = await resolve_assistant_config(requested_assistant_id)
validate_runtime_requirements(config)
vision_enabled = validate_visual_runtime(config)
state = OpenAIRealtimeSession(
assistant_id=requested_assistant_id,
config=config,
vision_enabled=vision_enabled,
safety_identifier_hash=(
credential.safety_identifier_hash or safety_identifier_hash
),
)
state.apply_initial_options(session_value)
return state
@router.post("/client_secrets")
async def create_realtime_client_secret(request: Request):
try:
credential = await _credential(request)
if credential.ephemeral:
raise RealtimeAuthError("A long-lived API key is required")
body = await request.json()
session = _session_payload(body)
_reject_locked_creation_fields(session)
assistant_id = assistant_id_from_model(session.get("model"))
# Resolve now so invalid models/configurations fail before a browser gets a token.
config = await resolve_assistant_config(assistant_id)
validate_runtime_requirements(config)
validate_visual_runtime(config)
safety_hash = hash_safety_identifier(
request.headers.get("openai-safety-identifier")
)
value, expires_at = create_client_secret(
api_key_id=credential.api_key_id,
assistant_id=assistant_id,
session=session,
safety_identifier_hash=safety_hash,
)
return {
"value": value,
"expires_at": expires_at,
"session": session,
}
except RealtimeAuthError as exc:
return _api_error(str(exc), code="invalid_api_key", status_code=401)
except RealtimeEventError as exc:
return _api_error(str(exc), code=exc.code, status_code=400)
except ValueError as exc:
code = "invalid_model" if "助手不存在" in str(exc) else "invalid_session"
return _api_error(str(exc), code=code, status_code=400)
@router.post("/calls")
async def create_realtime_call(request: Request):
try:
credential = await _credential(request)
content_type = request.headers.get("content-type", "").lower()
if content_type.startswith("multipart/form-data"):
if credential.ephemeral:
raise RealtimeAuthError(
"Client secrets must send an application/sdp offer"
)
form = await request.form()
sdp = str(form.get("sdp") or "")
raw_session = str(form.get("session") or "{}")
requested = _session_payload(json.loads(raw_session))
elif content_type.startswith("application/sdp"):
if not credential.ephemeral:
raise RealtimeAuthError(
"application/sdp requires a short-lived client secret"
)
sdp = (await request.body()).decode("utf-8")
requested = None
else:
raise RealtimeEventError(
"Use multipart/form-data or application/sdp",
code="unsupported_content_type",
)
if not sdp.strip():
raise RealtimeEventError("SDP offer is empty", param="sdp")
state = await _build_session(
credential,
requested,
safety_identifier_hash=hash_safety_identifier(
request.headers.get("openai-safety-identifier")
),
)
answer = await _start_webrtc(sdp, state)
return Response(
content=answer,
media_type="application/sdp",
headers={"Location": f"/v1/realtime/calls/{state.id}"},
)
except RealtimeAuthError as exc:
return _api_error(str(exc), code="invalid_api_key", status_code=401)
except (RealtimeEventError, json.JSONDecodeError, UnicodeDecodeError) as exc:
code = exc.code if isinstance(exc, RealtimeEventError) else "invalid_request_error"
return _api_error(str(exc), code=code, status_code=400)
except ValueError as exc:
code = "invalid_model" if "助手不存在" in str(exc) else "invalid_session"
return _api_error(str(exc), code=code, status_code=400)
except Exception as exc: # noqa: BLE001 - no internal details in public response
logger.exception(f"OpenAI Realtime WebRTC 启动失败: {exc}")
return _api_error(
"Realtime connection could not be established",
code="connection_error",
status_code=500,
)
async def _start_webrtc(sdp: str, state: OpenAIRealtimeSession) -> str:
connection = OpenAIRealtimeWebRTCConnection(
ice_servers=aiortc_ice_servers()
)
await connection.initialize(sdp=sdp, type="offer")
_webrtc_peers[connection.pc_id] = connection
bridge = OpenAIRealtimeBridge(state, channel="webrtc")
transport = build_webrtc_transport(
connection,
video_in_enabled=state.vision_enabled,
)
task = lifecycle.start_pipeline_task(
connection,
run_pipeline(
transport,
state.config,
vision_enabled=state.vision_enabled,
assistant_id=state.assistant_id,
channel="openai-webrtc",
protocol_adapter=bridge,
),
protocol="openai-webrtc",
)
@connection.event_handler("closed")
async def on_closed(conn: OpenAIRealtimeWebRTCConnection):
_webrtc_peers.pop(conn.pc_id, None)
lifecycle.active_connections.discard(conn)
await lifecycle.wait_for_pipeline_close(
task,
connection_id=conn.pc_id,
)
answer = connection.get_answer()
if not answer:
raise RuntimeError("WebRTC answer was not created")
return str(answer["sdp"])
def _websocket_token(websocket: WebSocket) -> tuple[str, str | None]:
authorization = websocket.headers.get("authorization")
if authorization:
return bearer_from_authorization(authorization), None
protocols = [
item.strip()
for item in websocket.headers.get("sec-websocket-protocol", "").split(",")
if item.strip()
]
for protocol in protocols:
prefix = "openai-insecure-api-key."
if protocol.startswith(prefix):
return protocol.removeprefix(prefix), "realtime" if "realtime" in protocols else None
raise RealtimeAuthError("Missing Bearer credential")
@dataclass(eq=False)
class _ManagedWebSocket:
websocket: WebSocket
pc_id: str = field(default_factory=lambda: f"ws_{uuid4().hex}")
async def disconnect(self) -> None:
if self.websocket.application_state == WebSocketState.CONNECTED:
await self.websocket.close(code=1001)
@router.websocket("")
async def realtime_websocket(websocket: WebSocket):
managed: _ManagedWebSocket | None = None
task = None
try:
token, accepted_subprotocol = _websocket_token(websocket)
async with SessionLocal() as db:
credential = await authenticate_bearer(db, token)
requested = (
credential.session
if credential.ephemeral
else {"model": websocket.query_params.get("model")}
)
state = await _build_session(
credential,
requested,
safety_identifier_hash=hash_safety_identifier(
websocket.headers.get("openai-safety-identifier")
),
)
await websocket.accept(subprotocol=accepted_subprotocol)
managed = _ManagedWebSocket(websocket)
transport = build_openai_websocket_transport(websocket)
bridge = OpenAIRealtimeBridge(state, channel="websocket")
task = lifecycle.start_pipeline_task(
managed,
run_pipeline(
transport,
state.config,
vision_enabled=False,
assistant_id=state.assistant_id,
channel="openai-websocket",
protocol_adapter=bridge,
),
protocol="openai-websocket",
)
await task
except (RealtimeAuthError, RealtimeEventError, ValueError) as exc:
logger.warning(f"拒绝 OpenAI Realtime WebSocket: {exc}")
if websocket.application_state == WebSocketState.CONNECTED:
await websocket.close(code=1008, reason=str(exc)[:120])
else:
await websocket.close(code=1008)
except Exception as exc: # noqa: BLE001 - pipeline callback logs full exception
logger.warning(f"OpenAI Realtime WebSocket 已关闭: {type(exc).__name__}")
if websocket.application_state == WebSocketState.CONNECTED:
await websocket.close(code=1011)
finally:
if managed:
lifecycle.active_connections.discard(managed)
if task and not task.done():
await lifecycle.wait_for_pipeline_close(
task,
connection_id=managed.pc_id if managed else "websocket",
)

View File

@@ -0,0 +1,85 @@
"""Admin-only management of public Realtime API credentials."""
from __future__ import annotations
from datetime import UTC, datetime
from db.models import RealtimeApiKey
from db.session import get_session
from fastapi import APIRouter, Depends, HTTPException, Response, status
from schemas import RealtimeApiKeyCreate, RealtimeApiKeyCreated, RealtimeApiKeyOut
from services.auth import require_admin
from services.openai_realtime.auth import create_api_key_value
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
router = APIRouter(
prefix="/api/realtime/api-keys",
tags=["realtime-api-keys"],
dependencies=[Depends(require_admin)],
)
def _status(row: RealtimeApiKey) -> str:
if row.revoked_at is not None:
return "revoked"
if row.expires_at is not None and row.expires_at <= datetime.now(UTC):
return "expired"
return "active"
def _out(row: RealtimeApiKey) -> RealtimeApiKeyOut:
return RealtimeApiKeyOut(
id=row.id,
name=row.name,
key_prefix=row.key_prefix,
status=_status(row),
expires_at=row.expires_at,
last_used_at=row.last_used_at,
revoked_at=row.revoked_at,
created_at=row.created_at,
)
@router.post("", response_model=RealtimeApiKeyCreated, status_code=status.HTTP_201_CREATED)
async def create_realtime_api_key(
body: RealtimeApiKeyCreate,
session: AsyncSession = Depends(get_session),
):
key_id, key, prefix, digest = create_api_key_value()
row = RealtimeApiKey(
id=key_id,
name=body.name,
key_prefix=prefix,
key_hash=digest,
expires_at=body.expires_at,
)
session.add(row)
await session.commit()
await session.refresh(row)
return RealtimeApiKeyCreated(**_out(row).model_dump(), key=key)
@router.get("", response_model=list[RealtimeApiKeyOut])
async def list_realtime_api_keys(session: AsyncSession = Depends(get_session)):
rows = (
await session.execute(
select(RealtimeApiKey).order_by(RealtimeApiKey.created_at.desc())
)
).scalars().all()
return [_out(row) for row in rows]
@router.delete("/{key_id}", status_code=status.HTTP_204_NO_CONTENT)
async def revoke_realtime_api_key(
key_id: str,
session: AsyncSession = Depends(get_session),
):
row = await session.get(RealtimeApiKey, key_id)
if row is None:
raise HTTPException(404, "Realtime API Key 不存在")
if row.revoked_at is None:
row.revoked_at = datetime.now(UTC)
await session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)

View File

@@ -0,0 +1,126 @@
"""Administration API for deployment-wide settings."""
from __future__ import annotations
from datetime import UTC, datetime
import uuid
from db.models import SiteSetting
from db.session import get_session
from fastapi import APIRouter, Depends, HTTPException
from schemas import (
AnalysisWebhookSettingOut,
AnalysisWebhookSettingUpdate,
AnalysisWebhookTestIn,
AnalysisWebhookTestOut,
)
from services.auth import require_admin
from services.webhooks.delivery import deliver_webhook
from services.webhooks.events import ANALYSIS_WEBHOOK_KEY
from services.webhooks.security import UnsafeWebhookUrl, validate_webhook_url
from sqlalchemy.ext.asyncio import AsyncSession
router = APIRouter(
prefix="/api/settings",
tags=["site-settings"],
dependencies=[Depends(require_admin)],
)
def _setting_out(row: SiteSetting | None) -> AnalysisWebhookSettingOut:
values = dict(row.value or {}) if row else {}
secrets = dict(row.secrets or {}) if row else {}
return AnalysisWebhookSettingOut(
enabled=bool(values.get("enabled")),
url=str(values.get("url") or ""),
secret_configured=bool(secrets.get("secret")),
updated_at=row.updated_at if row else None,
)
async def _safe_url_or_422(url: str) -> str:
try:
return await validate_webhook_url(url)
except UnsafeWebhookUrl as exc:
raise HTTPException(422, str(exc)) from exc
@router.get("/analysis-webhook", response_model=AnalysisWebhookSettingOut)
async def get_analysis_webhook(session: AsyncSession = Depends(get_session)):
return _setting_out(await session.get(SiteSetting, ANALYSIS_WEBHOOK_KEY))
@router.put("/analysis-webhook", response_model=AnalysisWebhookSettingOut)
async def update_analysis_webhook(
body: AnalysisWebhookSettingUpdate,
session: AsyncSession = Depends(get_session),
):
row = await session.get(SiteSetting, ANALYSIS_WEBHOOK_KEY)
stored_secrets = dict(row.secrets or {}) if row else {}
incoming_secret = (body.secret or "").strip()
if body.clear_secret:
stored_secrets.pop("secret", None)
if incoming_secret:
stored_secrets["secret"] = incoming_secret
if body.url:
await _safe_url_or_422(body.url)
if body.enabled and not stored_secrets.get("secret"):
raise HTTPException(422, "启用 Webhook 时必须配置签名密钥")
if row is None:
row = SiteSetting(key=ANALYSIS_WEBHOOK_KEY)
session.add(row)
row.value = {"enabled": body.enabled, "url": body.url}
row.secrets = stored_secrets
await session.commit()
await session.refresh(row)
return _setting_out(row)
@router.post("/analysis-webhook/test", response_model=AnalysisWebhookTestOut)
async def test_analysis_webhook(
body: AnalysisWebhookTestIn,
session: AsyncSession = Depends(get_session),
):
url = await _safe_url_or_422(body.url)
row = await session.get(SiteSetting, ANALYSIS_WEBHOOK_KEY)
stored_secret = str((row.secrets or {}).get("secret") or "") if row else ""
secret = (body.secret or "").strip() or stored_secret
if not secret:
raise HTTPException(422, "请先填写或保存签名密钥")
event_id = f"evt_{uuid.uuid4().hex}"
payload = {
"id": event_id,
"event": "call.analysis.completed",
"timestamp": datetime.now(UTC).isoformat(),
"data": {
"conversationId": "conv_test_001",
"assistantId": "asst_test_001",
"assistantName": "Webhook 测试助手",
"analysis": {
"customer_intent": "high",
"need_follow_up": True,
},
},
}
result = await deliver_webhook(
url=url,
secret=secret,
event_id=event_id,
payload=payload,
)
message = (
f"测试事件已送达,目标返回 HTTP {result.status_code}"
if result.ok
else result.error or "测试事件发送失败。"
)
return AnalysisWebhookTestOut(
ok=result.ok,
status_code=result.status_code,
latency_ms=result.latency_ms,
message=message,
payload=payload,
)

View File

@@ -0,0 +1,369 @@
"""Persistent test suite and fixed-text test case CRUD."""
from __future__ import annotations
from datetime import UTC, datetime
from uuid import uuid4
from db.models import TestCase, TestRunCase, TestSuite
from db.session import get_session
from fastapi import APIRouter, Depends, HTTPException, Query
from services.auth import require_admin
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from test_schemas import (
TestCaseBulkDeleteIn,
TestCaseDefinition,
TestCaseOrderIn,
TestCaseOut,
TestCaseWrite,
TestSuiteCreate,
TestSuiteOut,
TestSuiteUpdate,
)
router = APIRouter(
tags=["tests"],
dependencies=[Depends(require_admin)],
)
def _new_id(prefix: str) -> str:
return f"{prefix}_{uuid4().hex}"
async def _touch_suite(session: AsyncSession, suite_id: str) -> None:
suite = await session.get(TestSuite, suite_id)
if suite is not None:
suite.updated_at = datetime.now(UTC)
async def _suite_out(session: AsyncSession, row: TestSuite) -> TestSuiteOut:
total = await session.scalar(
select(func.count()).select_from(TestCase).where(TestCase.suite_id == row.id)
)
run = await session.scalar(
select(func.count(func.distinct(TestRunCase.run_id))).where(
TestRunCase.suite_id == row.id
)
)
passed = await session.scalar(
select(func.count())
.select_from(TestCase)
.where(TestCase.suite_id == row.id, TestCase.last_result == "pass")
)
return TestSuiteOut(
id=row.id,
name=row.name,
description=row.description,
case_count=int(total or 0),
passed_count=int(passed or 0),
run_count=int(run or 0),
updated_at=row.updated_at,
)
def _case_out(row: TestCase) -> TestCaseOut:
definition = TestCaseDefinition.model_validate(row.definition or {})
return TestCaseOut(
id=row.id,
suite_id=row.suite_id,
name=row.name,
description=row.description,
input_mode=row.input_mode,
last_result=row.last_result,
sort_order=row.sort_order,
updated_at=row.updated_at,
**definition.model_dump(),
)
def _apply_case_write(row: TestCase, body: TestCaseWrite) -> None:
row.name = body.name.strip()
row.description = body.description.strip()
row.input_mode = body.input_mode
row.definition = body.definition().model_dump(mode="json", by_alias=True)
async def _renumber_cases(session: AsyncSession, suite_id: str) -> None:
rows = (
await session.execute(
select(TestCase)
.where(TestCase.suite_id == suite_id)
.order_by(TestCase.sort_order, TestCase.created_at, TestCase.id)
)
).scalars().all()
offset = len(rows) + 1
for index, row in enumerate(rows):
row.sort_order = offset + index
await session.flush()
for index, row in enumerate(rows):
row.sort_order = index
@router.get("/api/test-suites", response_model=list[TestSuiteOut])
async def list_test_suites(session: AsyncSession = Depends(get_session)):
rows = (
await session.execute(select(TestSuite).order_by(TestSuite.updated_at.desc()))
).scalars().all()
return [await _suite_out(session, row) for row in rows]
@router.post("/api/test-suites", response_model=TestSuiteOut)
async def create_test_suite(
body: TestSuiteCreate,
session: AsyncSession = Depends(get_session),
):
row = TestSuite(
id=_new_id("suite"),
name=body.name.strip(),
description=body.description.strip(),
)
session.add(row)
await session.commit()
await session.refresh(row)
return await _suite_out(session, row)
@router.get("/api/test-suites/{suite_id}", response_model=TestSuiteOut)
async def get_test_suite(
suite_id: str,
session: AsyncSession = Depends(get_session),
):
row = await session.get(TestSuite, suite_id)
if row is None:
raise HTTPException(404, "测试集不存在")
return await _suite_out(session, row)
@router.put("/api/test-suites/{suite_id}", response_model=TestSuiteOut)
async def update_test_suite(
suite_id: str,
body: TestSuiteUpdate,
session: AsyncSession = Depends(get_session),
):
row = await session.get(TestSuite, suite_id)
if row is None:
raise HTTPException(404, "测试集不存在")
row.name = body.name.strip()
row.description = body.description.strip()
await session.commit()
await session.refresh(row)
return await _suite_out(session, row)
@router.delete("/api/test-suites/{suite_id}")
async def delete_test_suite(
suite_id: str,
session: AsyncSession = Depends(get_session),
):
row = await session.get(TestSuite, suite_id)
if row is None:
raise HTTPException(404, "测试集不存在")
await session.delete(row)
await session.commit()
return {"ok": True}
@router.post("/api/test-suites/{suite_id}/duplicate", response_model=TestSuiteOut)
async def duplicate_test_suite(
suite_id: str,
session: AsyncSession = Depends(get_session),
):
source = await session.get(TestSuite, suite_id)
if source is None:
raise HTTPException(404, "测试集不存在")
copied = TestSuite(
id=_new_id("suite"),
name=f"{source.name}(副本)",
description=source.description,
)
session.add(copied)
source_cases = (
await session.execute(
select(TestCase)
.where(TestCase.suite_id == suite_id)
.order_by(TestCase.sort_order)
)
).scalars().all()
for index, source_case in enumerate(source_cases):
session.add(
TestCase(
id=_new_id("tc"),
suite_id=copied.id,
name=source_case.name,
description=source_case.description,
input_mode=source_case.input_mode,
definition=dict(source_case.definition or {}),
sort_order=index,
last_result="not_run",
)
)
await session.commit()
await session.refresh(copied)
return await _suite_out(session, copied)
@router.get("/api/test-cases", response_model=list[TestCaseOut])
async def list_test_cases(
suite_id: str | None = Query(default=None, alias="suiteId"),
session: AsyncSession = Depends(get_session),
):
statement = select(TestCase)
if suite_id:
statement = statement.where(TestCase.suite_id == suite_id)
rows = (
await session.execute(
statement.order_by(TestCase.suite_id, TestCase.sort_order, TestCase.name)
)
).scalars().all()
return [_case_out(row) for row in rows]
@router.post("/api/test-suites/{suite_id}/cases", response_model=TestCaseOut)
async def create_test_case(
suite_id: str,
body: TestCaseWrite,
session: AsyncSession = Depends(get_session),
):
if await session.get(TestSuite, suite_id) is None:
raise HTTPException(404, "测试集不存在")
max_order = await session.scalar(
select(func.max(TestCase.sort_order)).where(TestCase.suite_id == suite_id)
)
row = TestCase(
id=_new_id("tc"),
suite_id=suite_id,
name=body.name.strip(),
description=body.description.strip(),
input_mode=body.input_mode,
definition=body.definition().model_dump(mode="json", by_alias=True),
sort_order=int(max_order if max_order is not None else -1) + 1,
last_result="not_run",
)
session.add(row)
await _touch_suite(session, suite_id)
await session.commit()
await session.refresh(row)
return _case_out(row)
@router.post("/api/test-cases/bulk-delete")
async def bulk_delete_test_cases(
body: TestCaseBulkDeleteIn,
session: AsyncSession = Depends(get_session),
):
rows = (
await session.execute(select(TestCase).where(TestCase.id.in_(body.case_ids)))
).scalars().all()
suite_ids = {row.suite_id for row in rows}
for row in rows:
await session.delete(row)
await session.flush()
for suite_id in suite_ids:
await _renumber_cases(session, suite_id)
await _touch_suite(session, suite_id)
await session.commit()
return {"ok": True, "deleted": len(rows)}
@router.put("/api/test-suites/{suite_id}/case-order")
async def reorder_test_cases(
suite_id: str,
body: TestCaseOrderIn,
session: AsyncSession = Depends(get_session),
):
rows = (
await session.execute(
select(TestCase)
.where(TestCase.suite_id == suite_id)
.order_by(TestCase.sort_order)
)
).scalars().all()
if {row.id for row in rows} != set(body.case_ids) or len(rows) != len(body.case_ids):
raise HTTPException(422, "排序列表必须包含测试集内全部用例且不能重复")
by_id = {row.id: row for row in rows}
offset = len(rows) + 1
for index, row in enumerate(rows):
row.sort_order = offset + index
await session.flush()
for index, case_id in enumerate(body.case_ids):
by_id[case_id].sort_order = index
await _touch_suite(session, suite_id)
await session.commit()
return {"ok": True}
@router.get("/api/test-cases/{case_id}", response_model=TestCaseOut)
async def get_test_case(
case_id: str,
session: AsyncSession = Depends(get_session),
):
row = await session.get(TestCase, case_id)
if row is None:
raise HTTPException(404, "测试用例不存在")
return _case_out(row)
@router.put("/api/test-cases/{case_id}", response_model=TestCaseOut)
async def update_test_case(
case_id: str,
body: TestCaseWrite,
session: AsyncSession = Depends(get_session),
):
row = await session.get(TestCase, case_id)
if row is None:
raise HTTPException(404, "测试用例不存在")
_apply_case_write(row, body)
await _touch_suite(session, row.suite_id)
await session.commit()
await session.refresh(row)
return _case_out(row)
@router.delete("/api/test-cases/{case_id}")
async def delete_test_case(
case_id: str,
session: AsyncSession = Depends(get_session),
):
row = await session.get(TestCase, case_id)
if row is None:
raise HTTPException(404, "测试用例不存在")
suite_id = row.suite_id
await session.delete(row)
await session.flush()
await _renumber_cases(session, suite_id)
await _touch_suite(session, suite_id)
await session.commit()
return {"ok": True}
@router.post("/api/test-cases/{case_id}/duplicate", response_model=TestCaseOut)
async def duplicate_test_case(
case_id: str,
session: AsyncSession = Depends(get_session),
):
source = await session.get(TestCase, case_id)
if source is None:
raise HTTPException(404, "测试用例不存在")
max_order = await session.scalar(
select(func.max(TestCase.sort_order)).where(
TestCase.suite_id == source.suite_id
)
)
copied = TestCase(
id=_new_id("tc"),
suite_id=source.suite_id,
name=f"{source.name}(副本)",
description=source.description,
input_mode=source.input_mode,
definition=dict(source.definition or {}),
sort_order=int(max_order if max_order is not None else -1) + 1,
last_result="not_run",
)
session.add(copied)
await _touch_suite(session, source.suite_id)
await session.commit()
await session.refresh(copied)
return _case_out(copied)

240
backend/routes/test_runs.py Normal file
View File

@@ -0,0 +1,240 @@
"""Start, inspect, and cancel persisted batch text test runs."""
from __future__ import annotations
from datetime import UTC, datetime
from uuid import uuid4
from db.models import (
Assistant,
ModelResource,
TestCase,
TestRun,
TestRunCase,
TestSuite,
)
from db.session import get_session
from fastapi import APIRouter, Depends, HTTPException
from services.auth import require_admin
from services.config_resolver import resolve_runtime_config
from services.test_runs.orchestrator import test_run_orchestrator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from test_schemas import (
BatchExecutionError,
BatchRunCaseOut,
BatchRunCreate,
BatchRunSnapshotOut,
BatchTurnResult,
BatchEvaluationResult,
TestCaseDefinition,
)
router = APIRouter(
prefix="/api/test-runs",
tags=["tests"],
dependencies=[Depends(require_admin)],
)
def _new_id(prefix: str) -> str:
return f"{prefix}_{uuid4().hex}"
async def _run_out(
session: AsyncSession,
run: TestRun,
) -> BatchRunSnapshotOut:
rows = (
await session.execute(
select(TestRunCase)
.where(TestRunCase.run_id == run.id)
.order_by(TestRunCase.position)
)
).scalars().all()
cases: list[BatchRunCaseOut] = []
for row in rows:
result = dict(row.result or {})
cases.append(
BatchRunCaseOut(
id=str(row.test_case_id or row.case_snapshot.get("id") or row.id),
name=row.test_case_name,
status=row.status,
turns=[
BatchTurnResult.model_validate(item)
for item in result.get("turns") or []
],
overall_criteria=[
BatchEvaluationResult.model_validate(item)
for item in result.get("overallCriteria")
or result.get("overall_criteria")
or []
],
attempt_count=row.attempt_count,
max_attempts=row.max_attempts,
execution_error=(
BatchExecutionError.model_validate(row.execution_error)
if row.execution_error
else None
),
)
)
return BatchRunSnapshotOut(
id=run.id,
status=run.status,
title=run.title,
assistant_name=run.assistant_name,
config=dict(run.config or {}),
cases=cases,
started_at=run.started_at or run.created_at,
finished_at=run.finished_at,
stop_reason=run.stop_reason,
)
@router.post("", response_model=BatchRunSnapshotOut)
async def create_test_run(
body: BatchRunCreate,
session: AsyncSession = Depends(get_session),
):
assistant = await session.get(Assistant, body.assistant_id)
if assistant is None:
raise HTTPException(404, "被测助手不存在")
if assistant.runtime_mode != "pipeline":
raise HTTPException(422, "第一版批量测试只支持 Pipeline 运行模式")
if assistant.type not in {"prompt", "workflow"}:
raise HTTPException(422, f"第一版批量测试暂不支持 {assistant.type} 类型助手")
evaluator_resource = await session.get(
ModelResource,
body.evaluator_model_resource_id,
)
if (
evaluator_resource is None
or evaluator_resource.capability != "LLM"
or not evaluator_resource.enabled
):
raise HTTPException(422, "评估模型不存在、已停用或不是 LLM 资源")
try:
runtime_config = await resolve_runtime_config(session, assistant.id)
except ValueError as exc:
raise HTTPException(422, str(exc)) from exc
rows = (
await session.execute(select(TestCase).where(TestCase.id.in_(body.case_ids)))
).scalars().all()
by_id = {row.id: row for row in rows}
missing = [case_id for case_id in body.case_ids if case_id not in by_id]
if missing:
raise HTTPException(422, "测试用例不存在: " + "".join(missing))
ordered_cases = [by_id[case_id] for case_id in body.case_ids]
tools_by_id = {tool.id: tool for tool in runtime_config.tools}
for row in ordered_cases:
if row.input_mode != "fixed_script_text":
raise HTTPException(422, f"用例“{row.name}”不是可运行的固定文字模式")
try:
definition = TestCaseDefinition.model_validate(row.definition or {})
except ValueError as exc:
raise HTTPException(422, f"用例“{row.name}”校验失败: {exc}") from exc
for turn in definition.turns:
for behavior in turn.behaviors:
if behavior.type != "tool_call":
continue
tool = tools_by_id.get(behavior.tool_id)
if tool is None or tool.function_name != behavior.function_name:
raise HTTPException(
422,
f"用例“{row.name}”引用的工具 {behavior.function_name} "
"不属于当前被测助手",
)
suite_ids = list(dict.fromkeys(row.suite_id for row in ordered_cases))
suites = (
await session.execute(select(TestSuite).where(TestSuite.id.in_(suite_ids)))
).scalars().all()
suite_by_id = {row.id: row for row in suites}
if body.title:
title = body.title.strip()
elif len(suite_ids) == 1 and suite_ids[0] in suite_by_id:
title = f"{suite_by_id[suite_ids[0]].name} · {len(ordered_cases)} 个用例"
else:
title = f"批量测试 · {len(ordered_cases)} 个用例"
now = datetime.now(UTC)
run = TestRun(
id=_new_id("run"),
assistant_id=assistant.id,
assistant_name=assistant.name,
title=title,
status="queued",
config={
"suiteCount": len(suite_ids),
"evaluatorModelResourceId": evaluator_resource.id,
"evaluatorModelResourceName": evaluator_resource.name,
"evaluatorModel": str(
(evaluator_resource.values or {}).get("modelId") or ""
),
**body.config.model_dump(mode="json", by_alias=True),
},
started_at=now,
)
session.add(run)
for position, row in enumerate(ordered_cases):
definition = TestCaseDefinition.model_validate(row.definition or {})
snapshot = {
"id": row.id,
"suiteId": row.suite_id,
"name": row.name,
"description": row.description,
"inputMode": row.input_mode,
**definition.model_dump(mode="json", by_alias=True),
}
session.add(
TestRunCase(
id=_new_id("rc"),
run_id=run.id,
test_case_id=row.id,
test_case_name=row.name,
suite_id=row.suite_id,
position=position,
case_snapshot=snapshot,
status="waiting",
attempt_count=0,
max_attempts=body.config.error_retry_count + 1,
result={"turns": [], "overallCriteria": []},
)
)
await session.commit()
await session.refresh(run)
test_run_orchestrator.start(run.id)
return await _run_out(session, run)
@router.get("/{run_id}", response_model=BatchRunSnapshotOut)
async def get_test_run(
run_id: str,
session: AsyncSession = Depends(get_session),
):
run = await session.get(TestRun, run_id)
if run is None:
raise HTTPException(404, "批量测试运行不存在")
return await _run_out(session, run)
@router.post("/{run_id}/cancel", response_model=BatchRunSnapshotOut)
async def cancel_test_run(
run_id: str,
session: AsyncSession = Depends(get_session),
):
run = await session.get(TestRun, run_id)
if run is None:
raise HTTPException(404, "批量测试运行不存在")
if run.status in {"completed", "cancelled"}:
return await _run_out(session, run)
run.cancel_requested = True
run.stop_reason = "manual"
await session.commit()
await test_run_orchestrator.cancel(run_id)
await session.refresh(run)
return await _run_out(session, run)

View File

@@ -8,18 +8,20 @@
server → {type:"error", payload:{message}}
"""
import asyncio
import base64
import json
from collections.abc import Coroutine
from typing import Any
from db.session import SessionLocal
from fastapi import APIRouter, Body, Depends, Request, WebSocket
from loguru import logger
from models import AssistantConfig, SignalingOffer
from services.auth import require_admin, require_admin_websocket
from services.config_resolver import resolve_runtime_config
from services.realtime import lifecycle as realtime_lifecycle
from services.realtime.launcher import (
resolve_assistant_config,
validate_visual_runtime,
)
from services.runtime_variables import DynamicVariableError, prepare_dynamic_config
from starlette.websockets import WebSocketDisconnect, WebSocketState
@@ -29,116 +31,42 @@ from services.webrtc_ice import aiortc_ice_servers, client_ice_servers
router = APIRouter(tags=["voice"])
_http_peers: dict[str, object] = {}
_active_connections: set[object] = set()
_pipeline_tasks: set[asyncio.Task[None]] = set()
_connection_tasks: dict[object, asyncio.Task[None]] = {}
_active_connections = realtime_lifecycle.active_connections
_pipeline_tasks = realtime_lifecycle.pipeline_tasks
_connection_tasks = realtime_lifecycle.connection_tasks
PIPELINE_CLOSE_GRACE_SECONDS = 10.0
def _consume_pipeline_result(task: asyncio.Task[None], connection: object) -> None:
"""Retrieve background exceptions and release the task's strong reference."""
_pipeline_tasks.discard(task)
if _connection_tasks.get(connection) is task:
_connection_tasks.pop(connection, None)
try:
error = task.exception()
except asyncio.CancelledError:
logger.info(f"WebRTC pipeline 已取消: task={task.get_name()}")
return
if error is not None:
logger.opt(exception=error).error(
f"WebRTC pipeline 异常结束: task={task.get_name()}"
)
def _start_pipeline_task(
connection: object,
coroutine: Coroutine[Any, Any, None],
) -> asyncio.Task[None]:
"""Start one strongly referenced pipeline task for a WebRTC connection."""
connection_id = str(getattr(connection, "pc_id", "unknown"))
task = asyncio.create_task(
) -> object:
"""Compatibility proxy for existing RTVI callers and tests."""
return realtime_lifecycle.start_pipeline_task(
connection,
coroutine,
name=f"webrtc-pipeline:{connection_id}",
protocol="webrtc",
)
_active_connections.add(connection)
_pipeline_tasks.add(task)
_connection_tasks[connection] = task
task.add_done_callback(
lambda completed, connection=connection: _consume_pipeline_result(
completed,
connection,
)
)
return task
async def _wait_for_pipeline_close(
task: asyncio.Task[None] | None,
task,
*,
connection_id: str,
) -> None:
"""Let transport disconnect finish normally, then cancel a stuck pipeline."""
if task is None:
return
try:
await asyncio.wait_for(
asyncio.shield(task),
timeout=PIPELINE_CLOSE_GRACE_SECONDS,
)
return
except TimeoutError:
logger.warning(
f"WebRTC pipeline 关闭超过 {PIPELINE_CLOSE_GRACE_SECONDS:g} 秒,"
f"执行取消: pc_id={connection_id}"
)
except asyncio.CancelledError:
raise
except Exception:
# The done callback owns exception reporting and retrieval.
return
task.cancel()
done, _pending = await asyncio.wait(
{task},
await realtime_lifecycle.wait_for_pipeline_close(
task,
connection_id=connection_id,
timeout=PIPELINE_CLOSE_GRACE_SECONDS,
)
if not done:
logger.error(f"WebRTC pipeline 取消后仍未退出: pc_id={connection_id}")
async def shutdown_active_sessions() -> None:
"""Close active peers and drain every managed pipeline during app shutdown."""
connections = list(_active_connections)
if connections:
await asyncio.gather(
*(connection.disconnect() for connection in connections),
return_exceptions=True,
)
_active_connections.difference_update(connections)
_http_peers.clear()
tasks = list(_pipeline_tasks)
if not tasks:
return
done, pending = await asyncio.wait(
tasks,
"""Compatibility proxy; now drains RTVI and OpenAI Realtime sessions."""
_http_peers.clear()
await realtime_lifecycle.shutdown_active_sessions(
timeout=PIPELINE_CLOSE_GRACE_SECONDS,
)
for task in pending:
task.cancel()
if pending:
cancelled, stuck = await asyncio.wait(
pending,
timeout=PIPELINE_CLOSE_GRACE_SECONDS,
)
if stuck:
logger.error(f"应用关闭时仍有 {len(stuck)} 个 WebRTC pipeline 未退出")
else:
cancelled = set()
logger.info(
f"WebRTC 会话清理完成: normal={len(done)} cancelled={len(cancelled)}"
)
@router.get("/api/webrtc/ice-servers", dependencies=[Depends(require_admin)])
@@ -229,12 +157,9 @@ async def voice_signaling(websocket: WebSocket):
async def _resolve_config(offer: SignalingOffer) -> AssistantConfig:
"""优先用 assistant_id 从 DB 解析(含真 key);否则用调试内联配置。"""
if offer.assistant_id:
async with SessionLocal() as session:
cfg = await resolve_runtime_config(session, offer.assistant_id)
return prepare_dynamic_config(
cfg,
offer.dynamic_variables,
assistant_id=offer.assistant_id,
return await resolve_assistant_config(
offer.assistant_id,
dynamic_variables=offer.dynamic_variables,
)
if offer.inline_config:
return prepare_dynamic_config(
@@ -280,24 +205,7 @@ async def _handle_offer_payload(payload, peers):
else:
cfg = await _resolve_config(offer) # 解析放在建连前,配置错就别建连
# 服务端助手配置是视觉理解的唯一授权来源;客户端 offer 只负责携带媒体轨。
if cfg.type == "workflow":
from services.workflow_engine import WorkflowEngine
vision_enabled = WorkflowEngine(cfg.graph).uses_vision()
else:
vision_enabled = cfg.vision_enabled
if vision_enabled and cfg.type != "workflow":
has_native_vision = (
not cfg.vision_model_resource_id and cfg.llm_support_image_input
)
has_aux_vision_model = (
bool(cfg.vision_model_resource_id)
and cfg.vision_llm_support_image_input
)
if not (has_native_vision or has_aux_vision_model):
raise ValueError(
"当前模型不支持图片输入,请在模型资源中选择支持图片输入的视觉模型"
)
vision_enabled = validate_visual_runtime(cfg)
pc = SmallWebRTCConnection(ice_servers=aiortc_ice_servers())
if pc_id:
pc._pc_id = pc_id

View File

@@ -7,7 +7,7 @@ JSON 用 camelCase(modelId/interfaceType/apiUrl/apiKey),Python 内部用 snake_c
from __future__ import annotations
from datetime import datetime
from datetime import UTC, datetime
import re
from typing import Annotated, Any, Literal, Union
@@ -90,6 +90,45 @@ class KnowledgeRetrievalConfig(CamelModel):
return value
class AnalysisField(CamelModel):
id: str = Field(min_length=1, max_length=64)
name: str = Field(pattern=r"^[A-Za-z][A-Za-z0-9_]{0,63}$")
type: Literal["string", "boolean", "integer", "number", "enum"] = "string"
description: str = Field(default="", max_length=500)
enum_values: list[str] = Field(default_factory=list, max_length=30)
@model_validator(mode="after")
def validate_enum_values(self):
normalized = list(
dict.fromkeys(
value.strip() for value in self.enum_values if value.strip()
)
)
if self.type == "enum" and not normalized:
raise ValueError("enum 字段必须至少配置一个枚举值")
self.enum_values = normalized if self.type == "enum" else []
return self
class AnalysisConfig(CamelModel):
enabled: bool = False
model_resource_id: str = ""
fields: list[AnalysisField] = Field(default_factory=list, max_length=20)
@model_validator(mode="after")
def validate_enabled_config(self):
if not self.enabled:
return self
if not self.model_resource_id:
raise ValueError("开启通话后分析时必须选择分析模型")
if not self.fields:
raise ValueError("开启通话后分析时必须配置至少一个关键信息字段")
names = [field.name for field in self.fields]
if len(names) != len(set(names)):
raise ValueError("关键信息字段名不能重复")
return self
# 各 type 允许的瘦字段(其余字段写入时清零,防止跨类型脏数据)
ALLOWED_FIELDS: dict[str, set[str]] = {
"prompt": {"prompt"},
@@ -171,6 +210,7 @@ class AssistantUpsert(CamelModel):
dynamic_variable_definitions: dict[str, "DynamicVariableDefinition"] = Field(
default_factory=dict
)
analysis_config: AnalysisConfig = Field(default_factory=AnalysisConfig)
api_url: str = ""
api_key: str = "" # 写时:占位符/空 → 保留旧(哨兵)
app_id: str = ""
@@ -500,9 +540,25 @@ class ConversationOut(CamelModel):
ended_at: datetime | None
class ConversationAnalysisFieldOut(CamelModel):
name: str
type: str
value: Any = None
class ConversationAnalysisOut(CamelModel):
status: Literal[
"none", "pending", "processing", "completed", "failed"
] = "none"
fields: list[ConversationAnalysisFieldOut] = Field(default_factory=list)
error: str = ""
completed_at: datetime | None = None
class ConversationDetailOut(ConversationOut):
extra: dict[str, Any] = Field(default_factory=dict)
messages: list[ConversationMessageOut] = Field(default_factory=list)
analysis: ConversationAnalysisOut = Field(default_factory=ConversationAnalysisOut)
class ConversationListOut(CamelModel):
@@ -510,3 +566,74 @@ class ConversationListOut(CamelModel):
total: int
page: int
page_size: int
# ---------- 站点级 Webhook ----------
class AnalysisWebhookSettingOut(CamelModel):
enabled: bool = False
url: str = ""
secret_configured: bool = False
updated_at: datetime | None = None
class AnalysisWebhookSettingUpdate(CamelModel):
enabled: bool = False
url: str = Field(default="", max_length=2048)
secret: str | None = Field(default=None, max_length=512)
clear_secret: bool = False
@model_validator(mode="after")
def validate_enabled_url(self):
self.url = self.url.strip()
if self.enabled and not self.url:
raise ValueError("启用 Webhook 时必须填写 URL")
return self
class AnalysisWebhookTestIn(CamelModel):
url: str = Field(min_length=1, max_length=2048)
secret: str | None = Field(default=None, max_length=512)
class AnalysisWebhookTestOut(CamelModel):
ok: bool
status_code: int | None = None
latency_ms: int | None = None
message: str
payload: dict[str, Any]
# ---------- Realtime API keys ----------
class RealtimeApiKeyCreate(CamelModel):
name: str = Field(min_length=1, max_length=128)
expires_at: datetime | None = None
@field_validator("name", mode="before")
@classmethod
def normalize_name(cls, value: object) -> object:
return value.strip() if isinstance(value, str) else value
@field_validator("expires_at")
@classmethod
def validate_expiry(cls, value: datetime | None) -> datetime | None:
if value is None:
return None
normalized = value if value.tzinfo else value.replace(tzinfo=UTC)
if normalized <= datetime.now(UTC):
raise ValueError("expires_at 必须晚于当前时间")
return normalized
class RealtimeApiKeyOut(CamelModel):
id: str
name: str
key_prefix: str
status: Literal["active", "expired", "revoked"]
expires_at: datetime | None = None
last_used_at: datetime | None = None
revoked_at: datetime | None = None
created_at: datetime
class RealtimeApiKeyCreated(RealtimeApiKeyOut):
key: str

View File

@@ -99,6 +99,9 @@ class BrainRuntime:
Callable[[bool, dict[str, Any]], Awaitable[None]] | None
) = None
flow_global_functions: list[Any] = field(default_factory=list)
# Tests replace external side effects without changing Pipecat or stored
# tool resources. Production leaves this unset and uses ToolExecutor.
tool_executor_factory: Callable[[Any], Any] | None = None
@dataclass(frozen=True)

View File

@@ -43,6 +43,7 @@ from services.message_stage import (
MessageDisplaySpec,
MessageStageRunner,
MessageStageSpec,
confirmation_context_message,
)
from services.runtime_variables import DynamicVariableError, DynamicVariableStore
from services.system_tools import state_update_properties, system_tool_kind
@@ -103,6 +104,8 @@ class PromptBrain(BaseBrain):
async def setup(self, cfg: AssistantConfig, runtime: BrainRuntime) -> None:
self._runtime = runtime
if runtime.tool_executor_factory is not None:
self._tools = runtime.tool_executor_factory(self._store)
self._tools.set_client_tools(runtime.client_tools)
self._actions = ActionRunner(
self._tools,
@@ -224,8 +227,12 @@ class PromptBrain(BaseBrain):
self.prepare_greeting_context(speech, runtime.context)
try:
if opening_message is not None:
message_spec = self._opening_message_stage_spec(
speech,
opening_message,
)
message_result = await self._message_stages.run(
self._opening_message_stage_spec(speech, opening_message),
message_spec,
speak=self._speak_opening,
set_input_enabled=runtime.set_input_enabled,
input_already_blocked=self._opening_input_blocked,
@@ -239,6 +246,12 @@ class PromptBrain(BaseBrain):
message_result.error or "开场消息显示失败"
)
return
context_message = confirmation_context_message(
message_result,
source="prompt-opening",
)
if context_message is not None:
runtime.context.add_message(context_message)
if opening_actions:
result = await self._action_stages.run(
@@ -651,14 +664,27 @@ class PromptBrain(BaseBrain):
if not kind:
raise ValueError(f"系统工具 {tool.id} 缺少有效 kind")
if kind == "end_conversation":
return self._make_end_call_tool(tool, runtime)
if kind == "update_state":
return self._make_update_state_tool(tool, runtime)
if kind == "skip_turn":
return self._make_skip_turn_tool(tool)
if kind == "request_human_handoff":
return self._make_handoff_tool(tool, runtime)
raise ValueError(f"未知系统工具: {kind}")
schema, handler = self._make_end_call_tool(tool, runtime)
elif kind == "update_state":
schema, handler = self._make_update_state_tool(tool, runtime)
elif kind == "skip_turn":
schema, handler = self._make_skip_turn_tool(tool)
elif kind == "request_human_handoff":
schema, handler = self._make_handoff_tool(tool, runtime)
else:
raise ValueError(f"未知系统工具: {kind}")
if runtime.tool_executor_factory is None:
return schema, handler
async def mock_system_tool(params: FunctionCallParams) -> None:
result = await self._tools.execute(
tool,
dict(params.arguments or {}),
)
await params.result_callback(result)
return schema, mock_system_tool
def _make_update_state_tool(self, tool, runtime: BrainRuntime):
"""更新已声明的动态变量(会话状态),并让模型继续当前回答。"""

View File

@@ -58,6 +58,7 @@ from services.message_stage import (
MessageStageResult,
MessageStageRunner,
MessageStageSpec,
confirmation_context_message,
)
from services.runtime_variables import DynamicVariableError, DynamicVariableStore
from services.system_tools import state_update_properties, system_tool_kind
@@ -214,7 +215,12 @@ class WorkflowBrain(BaseBrain):
self._cfg = cfg
self._runtime = runtime
self._store = DynamicVariableStore.from_config(cfg)
self._tools = ToolExecutor(self._store, client_tools=runtime.client_tools)
self._tools = (
runtime.tool_executor_factory(self._store)
if runtime.tool_executor_factory is not None
else ToolExecutor(self._store, client_tools=runtime.client_tools)
)
self._tools.set_client_tools(runtime.client_tools)
self._actions = ActionRunner(
self._tools,
is_session_ending=lambda: runtime.call_end.ending,
@@ -934,18 +940,28 @@ class WorkflowBrain(BaseBrain):
"""Build one platform-owned tool scoped to the active Agent node."""
kind = system_tool_kind(tool.definition or {})
if kind == "update_state":
return self._workflow_update_state_tool(
schema = self._workflow_update_state_tool(
tool,
node_id,
state_variable_names=state_variable_names,
)
if kind == "skip_turn":
return self._workflow_skip_turn_tool(tool)
if kind == "request_human_handoff":
return self._workflow_handoff_tool(tool, node_id)
if kind == "end_conversation":
return self._workflow_end_conversation_tool(tool, node_id)
raise ValueError(f"系统工具 {tool.id} 缺少有效 kind")
elif kind == "skip_turn":
schema = self._workflow_skip_turn_tool(tool)
elif kind == "request_human_handoff":
schema = self._workflow_handoff_tool(tool, node_id)
elif kind == "end_conversation":
schema = self._workflow_end_conversation_tool(tool, node_id)
else:
raise ValueError(f"系统工具 {tool.id} 缺少有效 kind")
runtime = self._require_runtime()
if runtime.tool_executor_factory is None:
return schema
async def mock_system_tool(args, _flow_manager):
return await self._tools.execute(tool, dict(args or {}))
return replace(schema, handler=mock_system_tool)
def _workflow_update_state_tool(
self,
@@ -1406,6 +1422,12 @@ class WorkflowBrain(BaseBrain):
context_message = fixed_speech_context_message(result.speech)
if context_message is not None:
context_messages.append(context_message)
confirmation_message = confirmation_context_message(
result,
source=f"workflow-message:{continuation.node_id}",
)
if confirmation_message is not None:
context_messages.append(confirmation_message)
if not self._engine.has_outgoing(continuation.node_id):
self._state.enter(

View File

@@ -266,6 +266,7 @@ async def resolve_runtime_config(
enableInterrupt=assistant.enable_interrupt,
turnConfig=assistant.turn_config or {},
startup=assistant.startup or {},
analysis_config=assistant.analysis_config or {},
tools=runtime_tools,
llm_tool_ids=llm_tool_ids,
knowledge_base_id=assistant.knowledge_base_id,

View File

@@ -31,8 +31,9 @@ def _parse_timestamp(value: object) -> datetime:
class ConversationRecorder:
"""按事件顺序写入一通会话;写库失败不应中断实时通话。"""
def __init__(self, session_id: str):
def __init__(self, session_id: str, analysis_plan: dict | None = None):
self.session_id = session_id
self._analysis_plan = deepcopy(analysis_plan or {})
self._sequence = 0
self._trace_sequence = 0
self._lock = asyncio.Lock()
@@ -49,6 +50,7 @@ class ConversationRecorder:
runtime_mode: str,
session_id: str | None = None,
extra: dict | None = None,
analysis_plan: dict | None = None,
) -> "ConversationRecorder | None":
session_id = session_id or f"conv_{uuid4().hex[:20]}"
try:
@@ -62,11 +64,17 @@ class ConversationRecorder:
runtime_mode=runtime_mode,
status="active",
message_count=0,
analysis_status="none",
analysis_data=(
{"plan": deepcopy(analysis_plan)}
if analysis_plan and analysis_plan.get("enabled")
else {}
),
extra=deepcopy(extra or {}),
)
)
await db.commit()
return cls(session_id)
return cls(session_id, analysis_plan)
except Exception as exc:
logger.error(f"创建对话历史会话失败,不影响本次通话: {exc}")
return None
@@ -235,6 +243,9 @@ class ConversationRecorder:
},
)
)
# Flush before artifact insert: db.get() below autoflushes and
# can otherwise write conversation_artifacts first (FK violation).
await db.flush()
db.add(
ConversationArtifact(
id=artifact_id,
@@ -286,6 +297,16 @@ class ConversationRecorder:
conversation.status = status
conversation.ended_at = datetime.now(UTC)
conversation.message_count = self._sequence
if (
status == "completed"
and self._sequence > 0
and self._analysis_plan.get("enabled")
):
conversation.analysis_status = "pending"
conversation.analysis_error = ""
conversation.analysis_data = {
"plan": deepcopy(self._analysis_plan)
}
await db.commit()
except Exception as exc:
logger.error(f"结束对话历史会话失败: {exc}")

View File

@@ -3,14 +3,17 @@
from __future__ import annotations
from collections.abc import Awaitable
from typing import Any
from typing import TYPE_CHECKING, Any
from pipecat.frames.frames import OutputTransportMessageUrgentFrame, TTSSpeakFrame
from pipecat.utils.time import time_now_iso8601
from services.brains.base import BrainRuntime
from services.pipecat.call_lifecycle import playback_marker_for
from services.runtime_variables import DynamicVariableStore
if TYPE_CHECKING:
from services.brains.base import BrainRuntime
FIXED_SPEECH_CONTEXT_MARKER = "[会话事实:以下固定消息已向用户播报]"
@@ -79,6 +82,10 @@ class FixedSpeechOutput:
await self._runtime.queue_frame(
TTSSpeakFrame(content, append_to_context=False)
)
playback_marker = playback_marker_for(playback_completion)
if playback_marker is not None:
await self._runtime.queue_frame(playback_marker)
playback_marker.completion.mark_queued()
return playback_completion
async def emit(self, message: dict[str, Any]) -> None:

View File

@@ -17,6 +17,7 @@ from services.message_policy import (
BUILTIN_SHOW_MESSAGE = "show_message"
MESSAGE_CONFIRMATION_CONTEXT_MARKER = "[客户端交互事件,不是语音转写]"
SpeechCompletion = Awaitable[None] | None
Speak = Callable[[str], Awaitable[SpeechCompletion]]
StartedHook = Callable[[], Awaitable[None]]
@@ -40,15 +41,54 @@ class MessageStageSpec:
completion_policy: MessageCompletionPolicy = MESSAGE_PLAYBACK
@dataclass(frozen=True)
class MessageConfirmation:
"""The exact dialog and user action that completed a confirmation stage."""
title: str
message: str
confirm_label: str
action: str
@dataclass(frozen=True)
class MessageStageResult:
"""Result used by Workflow routing and Prompt opening failure handling."""
succeeded: bool
speech: str = ""
action: str | None = None
confirmation: MessageConfirmation | None = None
error: str | None = None
@property
def action(self) -> str | None:
"""Keep existing trace and routing callers independent of result shape."""
return self.confirmation.action if self.confirmation is not None else None
def confirmation_context_message(
result: MessageStageResult,
*,
source: str,
) -> dict[str, str] | None:
"""Represent a confirmed client dialog as one provider-neutral user event."""
confirmation = result.confirmation
if not result.succeeded or confirmation is None:
return None
return {
"role": "user",
"content": "\n".join(
[
MESSAGE_CONFIRMATION_CONTEXT_MARKER,
f"用户已阅读消息弹窗并点击“{confirmation.confirm_label}”。",
f"弹窗标题:{confirmation.title}",
f"弹窗内容:{confirmation.message}",
f"操作结果:{confirmation.action}",
f"事件来源:{source}",
]
),
}
class MessageStageRunner:
"""Run one atomic user-visible message stage.
@@ -110,12 +150,12 @@ class MessageStageRunner:
if speech and speak is not None:
playback_completion = await speak(speech)
action: str | None = None
confirmation: MessageConfirmation | None = None
if spec.display is not None:
result = await self._show_message(spec, speech=speech)
if not result.succeeded:
return result
action = result.action
confirmation = result.confirmation
# Confirmation is the gate. It deliberately does not wait for the
# audio completion future, so the user can continue immediately.
@@ -128,7 +168,7 @@ class MessageStageRunner:
result = MessageStageResult(
succeeded=True,
speech=speech,
action=action,
confirmation=confirmation,
)
return result
except asyncio.CancelledError:
@@ -207,8 +247,23 @@ class MessageStageRunner:
if isinstance(data, dict)
else None
)
if require_confirmation and action is None:
return MessageStageResult(
succeeded=False,
speech=speech,
error="客户端未返回确认操作",
)
return MessageStageResult(
succeeded=True,
speech=speech,
action=action,
confirmation=(
MessageConfirmation(
title=display.title,
message=display.message,
confirm_label=display.confirm_label,
action=action,
)
if require_confirmation and action is not None
else None
),
)

View File

@@ -0,0 +1 @@
"""OpenAI Realtime-compatible northbound protocol adapter."""

View File

@@ -0,0 +1,181 @@
"""Authentication for the public OpenAI-compatible Realtime API."""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import secrets
import time
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any
from uuid import uuid4
import settings
from db.models import RealtimeApiKey
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
API_KEY_PREFIX = "sk-rt-"
CLIENT_SECRET_PREFIX = "ek-rt-"
class RealtimeAuthError(ValueError):
"""Raised when a public Realtime credential cannot be accepted."""
@dataclass(frozen=True)
class RealtimeCredential:
api_key_id: str
assistant_id: str | None = None
session: dict[str, Any] | None = None
safety_identifier_hash: str | None = None
ephemeral: bool = False
def _b64encode(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def _b64decode(value: str) -> bytes:
padding = "=" * (-len(value) % 4)
return base64.urlsafe_b64decode(f"{value}{padding}".encode("ascii"))
def _peppered_digest(value: str) -> str:
return hmac.new(
settings.REALTIME_TOKEN_SECRET.encode("utf-8"),
value.encode("utf-8"),
hashlib.sha256,
).hexdigest()
def _token_signature(encoded_payload: str) -> str:
digest = hmac.new(
settings.REALTIME_TOKEN_SECRET.encode("utf-8"),
encoded_payload.encode("ascii"),
hashlib.sha256,
).digest()
return _b64encode(digest)
def hash_safety_identifier(value: str | None) -> str | None:
normalized = (value or "").strip()
if not normalized:
return None
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
def create_api_key_value() -> tuple[str, str, str, str]:
key_id = f"rtkey_{uuid4().hex}"
random_secret = secrets.token_urlsafe(32)
value = f"{API_KEY_PREFIX}{key_id[6:18]}.{random_secret}"
return key_id, value, value[:24], _peppered_digest(value)
def create_client_secret(
*,
api_key_id: str,
assistant_id: str,
session: dict[str, Any],
safety_identifier_hash: str | None,
) -> tuple[str, int]:
now = int(time.time())
expires_at = now + settings.REALTIME_CLIENT_SECRET_TTL_SECONDS
payload = {
"sub": api_key_id,
"assistant_id": assistant_id,
"session": session,
"safety_identifier_hash": safety_identifier_hash,
"iat": now,
"exp": expires_at,
"jti": f"rtcs_{uuid4().hex}",
}
encoded = _b64encode(
json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode(
"utf-8"
)
)
return f"{CLIENT_SECRET_PREFIX}{encoded}.{_token_signature(encoded)}", expires_at
async def _active_api_key(
session: AsyncSession,
*,
key_id: str | None = None,
key_prefix: str | None = None,
) -> RealtimeApiKey | None:
if key_id:
row = await session.get(RealtimeApiKey, key_id)
elif key_prefix:
row = (
await session.execute(
select(RealtimeApiKey).where(
RealtimeApiKey.key_prefix == key_prefix
)
)
).scalar_one_or_none()
else:
return None
now = datetime.now(UTC)
if row is None or row.revoked_at is not None:
return None
if row.expires_at is not None and row.expires_at <= now:
return None
return row
async def authenticate_bearer(
session: AsyncSession,
token: str,
) -> RealtimeCredential:
if token.startswith(API_KEY_PREFIX):
prefix = token[:24]
row = await _active_api_key(session, key_prefix=prefix)
if row is None or not hmac.compare_digest(
row.key_hash,
_peppered_digest(token),
):
raise RealtimeAuthError("Invalid or expired API key")
row.last_used_at = datetime.now(UTC)
await session.commit()
return RealtimeCredential(api_key_id=row.id)
if not token.startswith(CLIENT_SECRET_PREFIX):
raise RealtimeAuthError("Unsupported Realtime credential")
compact = token[len(CLIENT_SECRET_PREFIX) :]
try:
encoded, signature = compact.rsplit(".", 1)
except ValueError as exc:
raise RealtimeAuthError("Invalid client secret") from exc
if not hmac.compare_digest(_token_signature(encoded), signature):
raise RealtimeAuthError("Invalid client secret")
try:
payload = json.loads(_b64decode(encoded))
except (ValueError, json.JSONDecodeError) as exc:
raise RealtimeAuthError("Invalid client secret") from exc
if int(payload.get("exp", 0)) < int(time.time()):
raise RealtimeAuthError("Client secret expired")
api_key_id = str(payload.get("sub") or "")
row = await _active_api_key(session, key_id=api_key_id)
if row is None:
raise RealtimeAuthError("Parent API key is no longer active")
row.last_used_at = datetime.now(UTC)
await session.commit()
return RealtimeCredential(
api_key_id=api_key_id,
assistant_id=str(payload.get("assistant_id") or "") or None,
session=(payload.get("session") if isinstance(payload.get("session"), dict) else None),
safety_identifier_hash=str(payload.get("safety_identifier_hash") or "") or None,
ephemeral=True,
)
def bearer_from_authorization(value: str | None) -> str:
scheme, _, token = (value or "").partition(" ")
if scheme.lower() != "bearer" or not token.strip():
raise RealtimeAuthError("Missing Bearer credential")
return token.strip()

View File

@@ -0,0 +1,916 @@
"""Translate OpenAI Realtime events to the project's neutral pipeline messages."""
from __future__ import annotations
import asyncio
import base64
import binascii
import json
from typing import Any
from uuid import uuid4
from pipecat.audio.utils import create_stream_resampler
from pipecat.frames.frames import (
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
InputAudioRawFrame,
InputTransportMessageFrame,
LLMContextFrame,
OutputAudioRawFrame,
OutputTransportMessageFrame,
OutputTransportMessageUrgentFrame,
StartFrame,
UserStartedSpeakingFrame,
UserStoppedSpeakingFrame,
VADParamsUpdateFrame,
)
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from services.input_assets import store_input_image
from services.openai_realtime.events import (
SUPPORTED_CAPABILITIES,
RealtimeEventError,
error_event,
normalize_turn_detection,
require_event,
)
from services.openai_realtime.session import OpenAIRealtimeSession
from services.pipecat.turn_config import create_vad_params
from services.realtime.protocol import PipelineProtocolRuntime
MAX_BUFFERED_AUDIO_BYTES = 24000 * 2 * 120
def _event_id() -> str:
return f"event_{uuid4().hex}"
def _server_event(event_type: str, **payload: Any) -> dict[str, Any]:
return {"type": event_type, "event_id": _event_id(), **payload}
def _decode_base64(value: object, *, param: str) -> bytes:
if not isinstance(value, str) or not value:
raise RealtimeEventError(f"{param} must be non-empty Base64", param=param)
try:
return base64.b64decode(value, validate=True)
except (ValueError, binascii.Error) as exc:
raise RealtimeEventError(f"{param} is not valid Base64", param=param) from exc
def _decode_data_url(value: object) -> bytes:
if not isinstance(value, str) or not value.startswith("data:image/"):
raise RealtimeEventError(
"input_image only supports image Data URLs",
code="unsupported_content_type",
param="item.content",
)
header, separator, encoded = value.partition(",")
if not separator or ";base64" not in header:
raise RealtimeEventError(
"input_image Data URL must use Base64 encoding",
param="item.content",
)
return _decode_base64(encoded, param="item.content")
class OpenAIRealtimeInputProcessor(FrameProcessor):
def __init__(self, bridge: "OpenAIRealtimeBridge") -> None:
super().__init__()
self._bridge = bridge
self._started = False
async def process_frame(self, frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if isinstance(frame, StartFrame) and not self._started:
self._started = True
# Initialize every downstream processor/serializer before the first
# business event is sent to the client.
await self.push_frame(frame, direction)
await self._bridge.emit(
_server_event(
"session.created",
session=self._bridge.session.public_value(),
)
)
return
if isinstance(frame, InputAudioRawFrame):
if (
self._bridge.session.external_turn_control
and frame.transport_source != "openai-committed"
):
try:
self._bridge.buffer_audio(
frame.audio,
sample_rate=frame.sample_rate,
num_channels=frame.num_channels,
)
except RealtimeEventError as exc:
await self._bridge.emit(error_event(exc))
return
await self.push_frame(frame, direction)
return
if not isinstance(frame, InputTransportMessageFrame):
await self.push_frame(frame, direction)
return
if isinstance(frame.message, dict) and frame.message.pop(
"_pipeline_internal", False
):
await self.push_frame(frame, direction)
return
if not self._bridge.client_ready_sent:
self._bridge.client_ready_sent = True
await self.push_frame(
InputTransportMessageFrame(
message={"type": "client-ready"}
),
direction,
)
try:
await self._bridge.handle_client_event(require_event(frame.message))
except RealtimeEventError as exc:
await self._bridge.emit(error_event(exc))
except Exception as exc: # noqa: BLE001 - event errors must stay connection-local
client_event_id = (
str(frame.message.get("event_id") or "") or None
if isinstance(frame.message, dict)
else None
)
await self._bridge.emit(
error_event(
RealtimeEventError(
f"Event processing failed: {exc}",
code="event_processing_error",
event_id=client_event_id,
)
)
)
class OpenAIRealtimeOutputProcessor(FrameProcessor):
def __init__(self, bridge: "OpenAIRealtimeBridge") -> None:
super().__init__()
self._bridge = bridge
self._audio_resampler = create_stream_resampler()
async def process_frame(self, frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if isinstance(frame, BotStartedSpeakingFrame):
if not self._bridge.session.active_response_id:
for event in self._bridge._start_assistant_output():
await self.push_frame(
OutputTransportMessageUrgentFrame(message=event)
)
await self.push_frame(frame, direction)
return
if isinstance(frame, BotStoppedSpeakingFrame):
if self._bridge.session.active_response_id:
for event in self._bridge._end_assistant_output(False):
await self.push_frame(
OutputTransportMessageUrgentFrame(message=event)
)
await self.push_frame(frame, direction)
return
if isinstance(frame, UserStartedSpeakingFrame):
item_id = f"item_{uuid4().hex}"
self._bridge.session.active_input_item_id = item_id
await self.push_frame(
OutputTransportMessageUrgentFrame(
message=_server_event(
"input_audio_buffer.speech_started",
audio_start_ms=0,
item_id=item_id,
)
)
)
await self.push_frame(frame, direction)
return
if isinstance(frame, UserStoppedSpeakingFrame):
item_id = (
self._bridge.session.active_input_item_id
or f"item_{uuid4().hex}"
)
self._bridge.session.active_input_item_id = None
await self.push_frame(
OutputTransportMessageUrgentFrame(
message=_server_event(
"input_audio_buffer.speech_stopped",
audio_end_ms=0,
item_id=item_id,
)
)
)
await self.push_frame(frame, direction)
return
if isinstance(frame, OutputAudioRawFrame):
if not self._bridge.session.output_is_audio:
return
if self._bridge.channel == "websocket":
if not self._bridge.session.active_response_id:
for event in self._bridge._start_assistant_output():
await self.push_frame(
OutputTransportMessageUrgentFrame(message=event)
)
response_id, item_id = self._bridge.session.begin_response()
audio = frame.audio
if frame.sample_rate != 24000:
audio = await self._audio_resampler.resample(
audio,
frame.sample_rate,
24000,
)
await self.push_frame(
OutputTransportMessageUrgentFrame(
message=_server_event(
"response.output_audio.delta",
response_id=response_id,
item_id=item_id,
output_index=0,
content_index=0,
delta=base64.b64encode(audio).decode("ascii"),
)
)
)
return
await self.push_frame(frame, direction)
return
if not isinstance(
frame,
(OutputTransportMessageFrame, OutputTransportMessageUrgentFrame),
):
await self.push_frame(frame, direction)
return
message = frame.message
if not isinstance(message, dict):
return
translated = self._bridge.translate_server_message(message)
for event in translated:
await self.push_frame(
OutputTransportMessageUrgentFrame(message=event),
direction,
)
class OpenAIResponseGateProcessor(FrameProcessor):
"""Hold automatic inference when create_response is disabled or PTT is active."""
def __init__(self, bridge: "OpenAIRealtimeBridge") -> None:
super().__init__()
self._bridge = bridge
self._held: list[tuple[Any, FrameDirection]] = []
self._allow_next = False
async def process_frame(self, frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if direction != FrameDirection.DOWNSTREAM or not isinstance(
frame, LLMContextFrame
):
await self.push_frame(frame, direction)
return
turn = self._bridge.session.turn_detection
auto_response = bool(turn and turn.get("create_response", True))
if auto_response or self._allow_next:
self._allow_next = False
await self.push_frame(frame, direction)
return
self._held.append((frame, direction))
async def allow_one_response(self) -> None:
if self._held:
held, self._held = self._held, []
for frame, direction in held:
await self.push_frame(frame, direction)
return
self._allow_next = True
class OpenAIRealtimeBridge:
"""One adapter instance is owned by exactly one public Realtime session."""
def __init__(self, session: OpenAIRealtimeSession, *, channel: str) -> None:
self.session = session
self.channel = channel
self._runtime: PipelineProtocolRuntime | None = None
self.client_ready_sent = False
self._input = OpenAIRealtimeInputProcessor(self)
self._inference = OpenAIResponseGateProcessor(self)
self._output = OpenAIRealtimeOutputProcessor(self)
def input_processors(self) -> list[FrameProcessor]:
return [self._input]
def output_processors(self) -> list[FrameProcessor]:
return [self._output]
def inference_processors(self) -> list[FrameProcessor]:
return [self._inference]
async def bind(self, runtime: PipelineProtocolRuntime) -> None:
self._runtime = runtime
await runtime.set_external_turn_control(self.session.external_turn_control)
turn = self.session.turn_detection
if turn:
await runtime.set_response_interruption(
bool(turn.get("interrupt_response", True))
)
@property
def runtime(self) -> PipelineProtocolRuntime:
if self._runtime is None:
raise RuntimeError("OpenAI Realtime bridge is not bound to a pipeline")
return self._runtime
async def emit(self, event: dict[str, Any]) -> None:
await self._input.push_frame(
OutputTransportMessageUrgentFrame(message=event)
)
async def queue_internal_message(self, message: dict[str, Any]) -> None:
await self.runtime.queue_frame(
InputTransportMessageFrame(
message={**message, "_pipeline_internal": True}
)
)
def buffer_audio(
self,
audio: bytes,
*,
sample_rate: int,
num_channels: int,
) -> None:
if self.session.buffered_audio_bytes + len(audio) > MAX_BUFFERED_AUDIO_BYTES:
self.clear_buffered_audio()
raise RealtimeEventError(
"input_audio_buffer exceeds the 120 second MVP limit",
code="input_audio_buffer_too_large",
)
self.session.audio_chunks.append((audio, sample_rate, num_channels))
self.session.buffered_audio_bytes += len(audio)
def clear_buffered_audio(self) -> None:
self.session.audio_chunks.clear()
self.session.buffered_audio_bytes = 0
self.session.audio_committed = False
async def handle_client_event(self, event: dict[str, Any]) -> None:
event_type = str(event["type"])
if event_type == "session.update":
await self._update_session(event)
elif event_type == "conversation.item.create":
await self._create_item(event)
elif event_type == "conversation.item.truncate":
await self.runtime.cancel_response()
await self.emit(
_server_event(
"conversation.item.truncated",
item_id=event.get("item_id"),
content_index=int(event.get("content_index") or 0),
audio_end_ms=int(event.get("audio_end_ms") or 0),
)
)
elif event_type == "input_audio_buffer.append":
await self._append_audio(event)
elif event_type == "input_audio_buffer.commit":
await self._commit_audio()
elif event_type == "input_audio_buffer.clear":
self.clear_buffered_audio()
await self.runtime.clear_audio()
await self.emit(_server_event("input_audio_buffer.cleared"))
elif event_type == "output_audio_buffer.clear":
await self.runtime.cancel_response()
await self._finish_response(status="cancelled")
await self.emit(_server_event("output_audio_buffer.cleared"))
elif event_type == "response.create":
await self._create_response(event)
elif event_type == "response.cancel":
await self.runtime.cancel_response()
await self._finish_response(status="cancelled")
elif event_type == "x.interactive_media.capabilities.update":
await self._update_capabilities(event)
elif event_type == "x.interactive_media.session.variables.update":
await self._update_variables(event)
async def _update_session(self, event: dict[str, Any]) -> None:
update = event.get("session")
if not isinstance(update, dict):
raise RealtimeEventError("session.update requires session", param="session")
locked = {"model", "instructions", "voice", "tools", "tool_choice"}
changed_locked = sorted(locked.intersection(update))
audio = update.get("audio")
if isinstance(audio, dict):
output = audio.get("output")
if isinstance(output, dict) and "voice" in output:
changed_locked.append("audio.output.voice")
if changed_locked:
raise RealtimeEventError(
f"Assistant-owned session fields cannot be changed: {', '.join(changed_locked)}",
code="immutable_session_field",
param=changed_locked[0],
event_id=str(event.get("event_id") or "") or None,
)
modalities = update.get("output_modalities")
if modalities is not None:
if modalities not in (["audio"], ["text"]):
raise RealtimeEventError(
'output_modalities must be ["audio"] or ["text"]',
param="session.output_modalities",
)
self.session.output_modalities = list(modalities)
marker = object()
turn_detection: object = marker
if isinstance(audio, dict) and isinstance(audio.get("input"), dict):
turn_detection = audio["input"].get("turn_detection", marker)
if turn_detection is marker:
turn_detection = update.get("turn_detection", marker)
if turn_detection is not marker:
self.session.turn_detection = self._validate_turn_detection(turn_detection)
await self.runtime.set_external_turn_control(
self.session.external_turn_control
)
if self.session.turn_detection:
await self.runtime.set_response_interruption(
bool(self.session.turn_detection.get("interrupt_response", True))
)
if self.session.turn_detection:
turn = self.session.turn_detection
config = {
"vad": {
"confidence": turn["threshold"],
"start_secs": max(0.05, turn["prefix_padding_ms"] / 1000),
"stop_secs": 0.2,
},
"turn_detection": {
"strategy": "silence",
"silence_timeout_secs": turn["silence_duration_ms"] / 1000,
},
}
await self.runtime.queue_frame(
VADParamsUpdateFrame(params=create_vad_params(config))
)
await self.emit(
_server_event("session.updated", session=self.session.public_value())
)
def _validate_turn_detection(self, value: object) -> dict[str, Any] | None:
return normalize_turn_detection(value)
async def _create_item(self, event: dict[str, Any]) -> None:
item = event.get("item")
if not isinstance(item, dict):
raise RealtimeEventError("conversation.item.create requires item", param="item")
item_type = item.get("type")
if item_type == "function_call_output":
call_id = str(item.get("call_id") or "")
if not call_id:
raise RealtimeEventError("function_call_output requires call_id", param="item.call_id")
raw_output = item.get("output")
try:
data = json.loads(raw_output) if isinstance(raw_output, str) else raw_output
except json.JSONDecodeError:
data = raw_output
await self.queue_internal_message(
{
"type": "client-tool-result",
"tool_call_id": call_id,
"status": "ok",
"data": data,
}
)
await self._emit_item_ack(item)
return
if item_type != "message" or item.get("role") != "user":
raise RealtimeEventError(
"Only user messages and function_call_output items are accepted",
code="unsupported_item_type",
param="item.type",
)
wire_parts: list[dict[str, Any]] = []
for part in item.get("content") or []:
if not isinstance(part, dict):
continue
if part.get("type") == "input_text":
text = str(part.get("text") or "").strip()
if text:
wire_parts.append({"type": "input_text", "text": text})
elif part.get("type") == "input_image":
if self.session.config.runtimeMode == "realtime":
raise RealtimeEventError(
"input_image is not supported by the assistant's realtime runtime",
code="unsupported_content_type",
)
if not self.session.vision_enabled:
raise RealtimeEventError(
"This assistant has not enabled image input",
code="unsupported_content_type",
)
data = _decode_data_url(part.get("image_url"))
stored = await asyncio.to_thread(store_input_image, data)
wire_parts.append(
{
"type": "input_image",
"source": {
"type": "uploaded_asset",
"asset_token": stored.token,
},
}
)
else:
raise RealtimeEventError(
f"Unsupported content part: {part.get('type')}",
code="unsupported_content_type",
param="item.content",
)
if not wire_parts:
raise RealtimeEventError("User item has no supported content", param="item.content")
item_id = str(item.get("id") or f"item_{uuid4().hex}")
public_item = {**item, "id": item_id, "status": "completed"}
self.session.pending_item = {
"type": "user-input",
"schema_version": 1,
"input_id": item_id,
"parts": wire_parts,
"options": {
"run_immediately": self.session.config.runtimeMode != "realtime",
"interrupt": True,
},
}
await self._emit_item_ack(public_item)
async def _emit_item_ack(self, item: dict[str, Any]) -> None:
await self.emit(
_server_event(
"conversation.item.added",
previous_item_id=None,
item=item,
)
)
await self.emit(_server_event("conversation.item.done", item=item))
async def _append_audio(self, event: dict[str, Any]) -> None:
audio = _decode_base64(event.get("audio"), param="audio")
if len(audio) % 2:
raise RealtimeEventError(
"WebSocket audio must contain complete PCM16 samples",
code="invalid_audio_format",
param="audio",
)
if self.channel != "websocket":
raise RealtimeEventError(
"input_audio_buffer.append is only used by WebSocket audio",
code="invalid_event",
)
if self.session.external_turn_control:
self.buffer_audio(audio, sample_rate=24000, num_channels=1)
return
await self.runtime.queue_frame(
InputAudioRawFrame(audio=audio, sample_rate=24000, num_channels=1)
)
async def _commit_audio(self) -> None:
if not self.session.audio_chunks and self.session.external_turn_control:
raise RealtimeEventError(
"input_audio_buffer is empty",
code="input_audio_buffer_commit_empty",
)
self.session.audio_committed = True
item_id = f"item_{uuid4().hex}"
await self.emit(
_server_event("input_audio_buffer.committed", item_id=item_id)
)
async def _create_response(self, event: dict[str, Any]) -> None:
response = event.get("response")
if isinstance(response, dict):
forbidden = {"instructions", "voice", "tools", "tool_choice", "model"}
changed = forbidden.intersection(response)
if changed:
raise RealtimeEventError(
"Per-response assistant configuration is locked",
code="immutable_session_field",
param=f"response.{sorted(changed)[0]}",
)
if (
self.session.external_turn_control
and self.session.audio_chunks
and not self.session.audio_committed
):
raise RealtimeEventError(
"Commit input_audio_buffer before response.create",
code="input_audio_buffer_not_committed",
)
await self._inference.allow_one_response()
submitted_item = bool(self.session.pending_item)
if self.session.pending_item:
pending = self.session.pending_item
self.session.pending_item = None
await self.queue_internal_message(pending)
if self.session.external_turn_control and self.session.audio_chunks:
await self.runtime.queue_frame(UserStartedSpeakingFrame())
for audio, sample_rate, num_channels in self.session.audio_chunks:
await self.runtime.queue_frame(
InputAudioRawFrame(
audio=audio,
sample_rate=sample_rate,
num_channels=num_channels,
transport_source="openai-committed",
)
)
await self.runtime.queue_frame(UserStoppedSpeakingFrame())
await self.runtime.commit_audio()
self.clear_buffered_audio()
if not submitted_item or self.session.config.runtimeMode == "realtime":
await self.runtime.request_response()
async def _finish_response(self, *, status: str) -> None:
response_id, _item_id, text = self.session.finish_response()
if not response_id:
return
await self.emit(
_server_event(
"response.done",
response={
"id": response_id,
"object": "realtime.response",
"status": status,
"output": [],
"output_text": text,
},
)
)
async def _update_capabilities(self, event: dict[str, Any]) -> None:
requested = event.get("capabilities") or []
if not isinstance(requested, list) or not all(isinstance(x, str) for x in requested):
raise RealtimeEventError("capabilities must be a string array", param="capabilities")
enabled = set(requested).intersection(SUPPORTED_CAPABILITIES)
if "video_track" in enabled and not self.session.vision_enabled:
enabled.remove("video_track")
self.session.capabilities = enabled
await self.emit(
_server_event(
"x.interactive_media.capabilities.updated",
capabilities=sorted(enabled),
rejected=sorted(set(requested) - enabled),
)
)
async def _update_variables(self, event: dict[str, Any]) -> None:
if "dynamic_variables" not in self.session.capabilities:
raise RealtimeEventError(
"dynamic_variables capability was not negotiated",
code="extension_not_negotiated",
)
variables = event.get("variables")
if not isinstance(variables, dict) or not variables:
raise RealtimeEventError("variables must be a non-empty object", param="variables")
await self.queue_internal_message(
{
"type": "session-update",
"schema_version": 1,
"update_id": str(event.get("event_id") or f"update_{uuid4().hex}"),
"dynamic_variables": variables,
}
)
def translate_server_message(self, message: dict[str, Any]) -> list[dict[str, Any]]:
message_type = str(message.get("type") or "")
if message_type == "error":
return [message]
if "." in message_type:
return [message]
if message_type == "transcript":
return self._translate_transcript(message)
if message_type == "assistant-text-start":
return self._start_assistant_output()
if message_type == "assistant-text-delta":
return self._assistant_delta(str(message.get("delta") or ""))
if message_type == "assistant-text-end":
return self._end_assistant_output(bool(message.get("interrupted")))
if message_type == "client-tool-call":
return self._client_tool_call(message)
if message_type == "user-input-result" and message.get("status") == "error":
return [
error_event(
RealtimeEventError(
str(message.get("message") or "User input failed"),
code="input_error",
)
)
]
extension = self._extension_event(message)
return [extension] if extension else []
def _translate_transcript(self, message: dict[str, Any]) -> list[dict[str, Any]]:
role = message.get("role")
text = str(message.get("content") or "")
item_id = f"item_{uuid4().hex}"
if role == "user":
return [
_server_event(
"conversation.item.input_audio_transcription.completed",
item_id=item_id,
content_index=0,
transcript=text,
)
]
response_id, output_item_id = self.session.begin_response()
item = {
"id": output_item_id,
"type": "message",
"role": "assistant",
"status": "completed",
"content": [{"type": "output_text", "text": text}],
}
self.session.response_text += text
response = {
"id": response_id,
"object": "realtime.response",
"status": "completed",
"output": [item],
}
self.session.finish_response()
return [
_server_event("response.created", response={**response, "status": "in_progress", "output": []}),
_server_event("response.output_item.added", response_id=response_id, output_index=0, item=item),
_server_event("response.output_item.done", response_id=response_id, output_index=0, item=item),
_server_event("response.done", response=response),
]
def _start_assistant_output(self) -> list[dict[str, Any]]:
if self.session.active_response_id:
return []
response_id, item_id = self.session.begin_response()
content_type = "audio" if self.session.output_is_audio else "text"
item = {
"id": item_id,
"type": "message",
"role": "assistant",
"status": "in_progress",
"content": [{"type": content_type}],
}
return [
_server_event(
"response.created",
response={
"id": response_id,
"object": "realtime.response",
"status": "in_progress",
"output": [],
},
),
_server_event(
"response.output_item.added",
response_id=response_id,
output_index=0,
item=item,
),
_server_event(
"response.content_part.added",
response_id=response_id,
item_id=item_id,
output_index=0,
content_index=0,
part={"type": content_type},
),
]
def _assistant_delta(self, delta: str) -> list[dict[str, Any]]:
events = self._start_assistant_output()
response_id, item_id = self.session.begin_response()
self.session.response_text += delta
event_type = (
"response.output_audio_transcript.delta"
if self.session.output_is_audio
else "response.output_text.delta"
)
events.append(
_server_event(
event_type,
response_id=response_id,
item_id=item_id,
output_index=0,
content_index=0,
delta=delta,
)
)
return events
def _end_assistant_output(self, interrupted: bool) -> list[dict[str, Any]]:
response_id, item_id, text = self.session.finish_response()
if not response_id or not item_id:
return []
content_type = "audio" if self.session.output_is_audio else "text"
done_type = (
"response.output_audio_transcript.done"
if self.session.output_is_audio
else "response.output_text.done"
)
item = {
"id": item_id,
"type": "message",
"role": "assistant",
"status": "incomplete" if interrupted else "completed",
"content": [{"type": content_type, "transcript" if self.session.output_is_audio else "text": text}],
}
status = "cancelled" if interrupted else "completed"
events = [
_server_event(done_type, response_id=response_id, item_id=item_id, output_index=0, content_index=0, transcript=text, text=text),
_server_event("response.content_part.done", response_id=response_id, item_id=item_id, output_index=0, content_index=0, part=item["content"][0]),
_server_event("response.output_item.done", response_id=response_id, output_index=0, item=item),
_server_event(
"response.done",
response={
"id": response_id,
"object": "realtime.response",
"status": status,
"output": [item],
},
),
]
if self.session.output_is_audio:
events.insert(
1,
_server_event(
"response.output_audio.done",
response_id=response_id,
item_id=item_id,
output_index=0,
content_index=0,
),
)
return events
def _client_tool_call(self, message: dict[str, Any]) -> list[dict[str, Any]]:
events: list[dict[str, Any]] = []
if not self.session.active_response_id:
response_id, _unused_item_id = self.session.begin_response()
events.append(
_server_event(
"response.created",
response={
"id": response_id,
"object": "realtime.response",
"status": "in_progress",
"output": [],
},
)
)
else:
response_id = self.session.active_response_id
item_id = f"item_{uuid4().hex}"
call_id = str(message.get("tool_call_id") or "")
arguments = json.dumps(message.get("arguments") or {}, ensure_ascii=False)
item = {
"id": item_id,
"type": "function_call",
"status": "completed",
"name": str(message.get("function_name") or ""),
"call_id": call_id,
"arguments": arguments,
}
events.extend([
_server_event("response.output_item.added", response_id=response_id, output_index=0, item=item),
_server_event("response.function_call_arguments.delta", response_id=response_id, item_id=item_id, output_index=0, call_id=call_id, delta=arguments),
_server_event("response.function_call_arguments.done", response_id=response_id, item_id=item_id, output_index=0, call_id=call_id, name=item["name"], arguments=arguments),
_server_event("response.output_item.done", response_id=response_id, output_index=0, item=item),
])
self.session.active_output_item_id = None
return events
def _extension_event(self, message: dict[str, Any]) -> dict[str, Any] | None:
kind = str(message.get("type") or "")
if kind == "session-update-result" and "dynamic_variables" in self.session.capabilities:
return _server_event(
"x.interactive_media.session.variables.updated",
update_id=message.get("update_id"),
status=message.get("status"),
message=message.get("message"),
)
if kind in {"node-active", "workflow-event", "workflow-variables", "workflow-error"}:
if "workflow_events" not in self.session.capabilities:
return None
names = {
"node-active": "x.interactive_media.workflow.node_active",
"workflow-event": "x.interactive_media.workflow.event",
"workflow-variables": "x.interactive_media.workflow.variables.updated",
"workflow-error": "x.interactive_media.workflow.error",
}
return _server_event(names[kind], **{k: v for k, v in message.items() if k != "type"})
if kind in {"handoff-requested", "call-ended"}:
if "handoff" not in self.session.capabilities:
return None
event_type = (
"x.interactive_media.call.handoff_requested"
if kind == "handoff-requested"
else "x.interactive_media.call.ended"
)
return _server_event(event_type, **{k: v for k, v in message.items() if k != "type"})
return None

View File

@@ -0,0 +1,115 @@
"""Small, explicit validation helpers for the supported Realtime event subset."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from uuid import uuid4
SUPPORTED_CLIENT_EVENTS = {
"session.update",
"conversation.item.create",
"conversation.item.truncate",
"input_audio_buffer.append",
"input_audio_buffer.commit",
"input_audio_buffer.clear",
"output_audio_buffer.clear",
"response.create",
"response.cancel",
"x.interactive_media.capabilities.update",
"x.interactive_media.session.variables.update",
}
SUPPORTED_CAPABILITIES = {
"dynamic_variables",
"workflow_events",
"handoff",
"video_track",
}
@dataclass
class RealtimeEventError(ValueError):
message: str
code: str = "invalid_request_error"
param: str | None = None
event_id: str | None = None
def __str__(self) -> str:
return self.message
def require_event(message: object) -> dict[str, Any]:
if not isinstance(message, dict):
raise RealtimeEventError("Event must be a JSON object")
event_type = str(message.get("type") or "")
if event_type not in SUPPORTED_CLIENT_EVENTS:
raise RealtimeEventError(
f"Unsupported client event: {event_type or '<missing>'}",
code="invalid_event",
param="type",
event_id=str(message.get("event_id") or "") or None,
)
return message
def assistant_id_from_model(model: object) -> str:
value = str(model or "").strip()
if not value.startswith("assistant:") or not value.removeprefix("assistant:"):
raise RealtimeEventError(
"model must use assistant:asst_xxx",
code="invalid_model",
param="session.model",
)
return value.removeprefix("assistant:")
def normalize_turn_detection(value: object) -> dict[str, Any] | None:
if value is None:
return None
if not isinstance(value, dict) or value.get("type") != "server_vad":
raise RealtimeEventError(
"turn_detection must be server_vad or null",
param="session.audio.input.turn_detection",
)
try:
threshold = float(value.get("threshold", 0.7))
prefix_ms = int(value.get("prefix_padding_ms", 200))
silence_ms = int(value.get("silence_duration_ms", 600))
except (TypeError, ValueError) as exc:
raise RealtimeEventError(
"Invalid server_vad threshold, padding, or silence duration",
param="session.audio.input.turn_detection",
) from exc
if (
not 0 <= threshold <= 1
or not 0 <= prefix_ms <= 5000
or not 100 <= silence_ms <= 10000
):
raise RealtimeEventError(
"Invalid server_vad threshold, padding, or silence duration",
param="session.audio.input.turn_detection",
)
return {
"type": "server_vad",
"threshold": threshold,
"prefix_padding_ms": prefix_ms,
"silence_duration_ms": silence_ms,
"create_response": bool(value.get("create_response", True)),
"interrupt_response": bool(value.get("interrupt_response", True)),
}
def error_event(error: RealtimeEventError) -> dict[str, Any]:
return {
"type": "error",
"event_id": f"event_{uuid4().hex}",
"error": {
"type": "invalid_request_error",
"code": error.code,
"message": error.message,
"param": error.param,
"event_id": error.event_id,
},
}

View File

@@ -0,0 +1,141 @@
"""Connection-local state for the OpenAI-compatible Realtime wire protocol."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from uuid import uuid4
from models import AssistantConfig
from services.openai_realtime.events import normalize_turn_detection
def _id(prefix: str) -> str:
return f"{prefix}_{uuid4().hex}"
@dataclass
class OpenAIRealtimeSession:
assistant_id: str
config: AssistantConfig
vision_enabled: bool
safety_identifier_hash: str | None = None
id: str = field(default_factory=lambda: _id("sess"))
output_modalities: list[str] = field(default_factory=lambda: ["audio"])
turn_detection: dict[str, Any] | None = field(
default_factory=lambda: {
"type": "server_vad",
"threshold": 0.7,
"prefix_padding_ms": 200,
"silence_duration_ms": 600,
"create_response": True,
"interrupt_response": True,
}
)
capabilities: set[str] = field(default_factory=set)
pending_item: dict[str, Any] | None = None
audio_chunks: list[tuple[bytes, int, int]] = field(default_factory=list)
buffered_audio_bytes: int = 0
audio_committed: bool = False
active_response_id: str | None = None
active_output_item_id: str | None = None
active_input_item_id: str | None = None
response_text: str = ""
@property
def model(self) -> str:
return f"assistant:{self.assistant_id}"
@property
def external_turn_control(self) -> bool:
return self.turn_detection is None
@property
def output_is_audio(self) -> bool:
return self.output_modalities == ["audio"]
def client_tools(self) -> list[dict[str, Any]]:
result = []
for tool in self.config.tools:
if tool.type != "client":
continue
definition = tool.definition or {}
parameters = (definition.get("config") or {}).get("parameters") or []
properties: dict[str, Any] = {}
required: list[str] = []
for parameter in parameters:
name = str(parameter.get("name") or "")
if not name:
continue
properties[name] = {
"type": parameter.get("type") or "string",
**(
{"description": parameter["description"]}
if parameter.get("description")
else {}
),
}
if parameter.get("required"):
required.append(name)
result.append(
{
"type": "function",
"name": tool.function_name,
"description": tool.description,
"parameters": {
"type": "object",
"properties": properties,
"required": required,
},
}
)
return result
def public_value(self) -> dict[str, Any]:
return {
"id": self.id,
"type": "realtime",
"model": self.model,
"output_modalities": self.output_modalities,
"audio": {
"input": {
"format": {"type": "audio/pcm", "rate": 24000},
"turn_detection": self.turn_detection,
},
"output": {
"format": {"type": "audio/pcm", "rate": 24000},
},
},
"tools": self.client_tools(),
"tool_choice": "auto",
}
def apply_initial_options(self, value: dict[str, Any]) -> None:
modalities = value.get("output_modalities")
if modalities in (["audio"], ["text"]):
self.output_modalities = list(modalities)
audio = value.get("audio")
if isinstance(audio, dict) and isinstance(audio.get("input"), dict):
turn_detection = audio["input"].get("turn_detection", self.turn_detection)
else:
turn_detection = value.get("turn_detection", self.turn_detection)
self.turn_detection = normalize_turn_detection(turn_detection)
def begin_response(self) -> tuple[str, str]:
if not self.active_response_id:
self.active_response_id = _id("resp")
if not self.active_output_item_id:
self.active_output_item_id = _id("item")
self.response_text = ""
return self.active_response_id, str(self.active_output_item_id)
def finish_response(self) -> tuple[str | None, str | None, str]:
result = (
self.active_response_id,
self.active_output_item_id,
self.response_text,
)
self.active_response_id = None
self.active_output_item_id = None
self.response_text = ""
return result

View File

@@ -0,0 +1,26 @@
"""Project-local SmallWebRTC specialization for the OpenAI event channel."""
from __future__ import annotations
from typing import Any
from pipecat.transports.smallwebrtc.connection import SmallWebRTCConnection
class OpenAIRealtimeWebRTCConnection(SmallWebRTCConnection):
"""Prevent Pipecat's private signalling envelope from leaking to clients."""
data_channel_label = "oai-events"
def _setup_listeners(self):
super()._setup_listeners()
@self._pc.on("datachannel")
def require_openai_event_channel(channel):
if channel.label != self.data_channel_label:
channel.close()
def send_app_message(self, message: Any):
if isinstance(message, dict) and message.get("type") == "signalling":
return
super().send_app_message(message)

View File

@@ -0,0 +1,51 @@
"""UTF-8 JSON serializer and transport builder for public Realtime WebSockets."""
from __future__ import annotations
import json
from typing import Any
from fastapi import WebSocket
from pipecat.frames.frames import (
Frame,
InputTransportMessageFrame,
OutputTransportMessageFrame,
OutputTransportMessageUrgentFrame,
)
from pipecat.serializers.base_serializer import FrameSerializer
from services.pipecat.transports import build_serialized_ws_transport
class OpenAIRealtimeJSONSerializer(FrameSerializer):
async def serialize(self, frame: Frame) -> str | bytes | None:
if self.should_ignore_frame(frame):
return None
if isinstance(
frame,
(OutputTransportMessageFrame, OutputTransportMessageUrgentFrame),
) and isinstance(frame.message, dict):
return json.dumps(frame.message, ensure_ascii=False, separators=(",", ":"))
return None
async def deserialize(self, data: str | bytes) -> Frame | None:
if isinstance(data, bytes):
message: Any = {
"type": "_invalid_binary_frame",
"event_id": None,
}
else:
try:
message = json.loads(data)
except json.JSONDecodeError:
message = {"type": "_invalid_json", "event_id": None}
return InputTransportMessageFrame(message=message)
def build_openai_websocket_transport(websocket: WebSocket):
return build_serialized_ws_transport(
websocket,
serializer=OpenAIRealtimeJSONSerializer(),
sample_rate=24000,
)

View File

@@ -3,14 +3,66 @@
from __future__ import annotations
import asyncio
from collections import deque
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from loguru import logger
from pipecat.frames.frames import BotStartedSpeakingFrame, BotStoppedSpeakingFrame
from pipecat.frames.frames import (
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
DataFrame,
InterruptionFrame,
)
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
class SpeechPlaybackCompletion:
"""Awaitable completed by its exact marker at the transport output."""
def __init__(self, coordinator: CallEndCoordinator):
self._coordinator = coordinator
self._future: asyncio.Future[None] = (
asyncio.get_running_loop().create_future()
)
self._queued = False
def __await__(self):
return self._future.__await__()
def done(self) -> bool:
return self._future.done()
@property
def queued(self) -> bool:
return self._queued
def mark_queued(self) -> None:
self._queued = True
async def mark_played(self) -> None:
await self._coordinator.complete_tracked_speech(self)
def _resolve(self) -> None:
if not self._future.done():
self._future.set_result(None)
@dataclass
class FixedSpeechPlaybackMarkerFrame(DataFrame):
"""Ordered frame that identifies one fixed utterance at transport output."""
completion: SpeechPlaybackCompletion
def playback_marker_for(
completion: Awaitable[None] | None,
) -> FixedSpeechPlaybackMarkerFrame | None:
"""Build a marker only for the production call-end coordinator."""
if not isinstance(completion, SpeechPlaybackCompletion):
return None
return FixedSpeechPlaybackMarkerFrame(completion=completion)
class CallEndCoordinator:
"""End immediately or after the currently armed closing speech finishes."""
@@ -22,8 +74,7 @@ class CallEndCoordinator:
self._speech_stopped = asyncio.Event()
self._speech_stopped.set()
self._response_speech_started = False
self._tracked_speeches = 0
self._tracked_speech_completions: deque[asyncio.Future[None]] = deque()
self._tracked_speech_completions: set[SpeechPlaybackCompletion] = set()
self._finish_after_tracked_speech = False
self._finished = False
self._reason = "completed"
@@ -53,17 +104,32 @@ class CallEndCoordinator:
"""Wait for the next observed bot speech to finish."""
self._armed = True
def track_speech(self) -> Awaitable[None]:
def track_speech(self) -> SpeechPlaybackCompletion:
"""Register fixed speech and return its transport completion signal."""
completion = asyncio.get_running_loop().create_future()
self._tracked_speech_completions.append(completion)
self._tracked_speeches += 1
completion = SpeechPlaybackCompletion(self)
self._tracked_speech_completions.add(completion)
return completion
async def complete_tracked_speech(
self,
completion: SpeechPlaybackCompletion,
) -> None:
"""Complete one fixed utterance when its marker reaches output."""
if completion not in self._tracked_speech_completions:
return
self._tracked_speech_completions.remove(completion)
completion._resolve()
if (
self._finish_after_tracked_speech
and not self._tracked_speech_completions
):
logger.info("所有工作流结束语播报完毕,挂断通话")
await self.finish()
async def arm_after_tracked_speech(self) -> None:
"""Finish after every already queued fixed utterance has played."""
self._finish_after_tracked_speech = True
if self._tracked_speeches == 0:
if not self._tracked_speech_completions:
await self.finish()
async def finish_after_current_speech(self, *, has_text: bool) -> None:
@@ -83,25 +149,25 @@ class CallEndCoordinator:
await self._queue_end(self._reason)
async def observe(self, frame) -> None:
if isinstance(frame, BotStartedSpeakingFrame):
if isinstance(frame, InterruptionFrame):
# Pipecat discards queued data frames on interruption, including
# playback markers. Treat already queued fixed speech as stopped so
# an interrupted Message cannot block a later EndNode forever.
interrupted = tuple(
completion
for completion in self._tracked_speech_completions
if completion.queued
)
for completion in interrupted:
await self.complete_tracked_speech(completion)
elif isinstance(frame, BotStartedSpeakingFrame):
self._speaking = True
self._speech_stopped.clear()
self._response_speech_started = True
elif isinstance(frame, BotStoppedSpeakingFrame) and self._speaking:
self._speaking = False
self._speech_stopped.set()
if self._tracked_speeches > 0:
self._tracked_speeches -= 1
completion = self._tracked_speech_completions.popleft()
if not completion.done():
completion.set_result(None)
if (
self._finish_after_tracked_speech
and self._tracked_speeches == 0
):
logger.info("所有工作流结束语播报完毕,挂断通话")
await self.finish()
elif self._armed:
if self._armed:
logger.info("结束语播报完毕,挂断通话")
await self.finish()

View File

@@ -47,6 +47,7 @@ from pipecat.adapters.schemas.tools_schema import ToolsSchema
from pipecat.flows import FlowsFunctionSchema
from pipecat.frames.frames import (
EndFrame,
InterruptionFrame,
LLMMessagesAppendFrame,
OutputTransportMessageUrgentFrame,
UserImageRawFrame,
@@ -94,6 +95,11 @@ from services.pipecat.pipeline_events import (
bind_cascade_pipeline_events,
bind_realtime_pipeline_events,
)
from services.realtime.protocol import (
PipelineProtocolAdapter,
PipelineProtocolRuntime,
RealtimeProviderControlFrame,
)
from pipecat.workers.runner import WorkerRunner
@@ -201,6 +207,7 @@ async def run_pipeline(
vision_enabled: bool = False,
assistant_id: str | None = None,
channel: str = "webrtc",
protocol_adapter: PipelineProtocolAdapter | None = None,
) -> None:
"""在给定 transport 上构建并运行管线,直到连接结束。
@@ -234,6 +241,7 @@ async def run_pipeline(
vision_enabled=vision_enabled,
assistant_id=assistant_id,
channel=channel,
protocol_adapter=protocol_adapter,
)
return
@@ -623,17 +631,25 @@ async def run_pipeline(
channel=channel,
runtime_mode=cfg.runtimeMode,
session_id=cfg.conversation_id or None,
analysis_plan=cfg.analysis_config,
extra=(workflow_engine.session_metadata() if workflow_engine else None),
)
protocol_inputs = protocol_adapter.input_processors() if protocol_adapter else []
protocol_inference = (
protocol_adapter.inference_processors() if protocol_adapter else []
)
protocol_outputs = protocol_adapter.output_processors() if protocol_adapter else []
pipeline = Pipeline(
[
transport.input(),
*protocol_inputs,
client_tools,
session_update,
vision_capture,
user_input,
stt_processor,
user_aggregator,
*protocol_inference,
user_turn_router,
knowledge_retrieval,
llm,
@@ -644,6 +660,7 @@ async def run_pipeline(
tts_processor,
EndCallAfterSpeechProcessor(call_end),
ConversationHistoryProcessor(recorder),
*protocol_outputs,
transport.output(),
]
)
@@ -656,6 +673,47 @@ async def run_pipeline(
enable_rtvi=False,
)
worker_holder["worker"] = worker
protocol_turn_state: dict[str, bool | None] = {
"external": False,
"interrupt_response": None,
}
if protocol_adapter:
async def set_external_turn_control(enabled: bool) -> None:
protocol_turn_state["external"] = enabled
await user_aggregator.apply_external_turn_control(enabled)
async def request_response() -> None:
# Cascade inference is triggered when the adapter submits user-input.
return None
async def commit_audio() -> None:
return None
async def clear_audio() -> None:
return None
async def set_response_interruption(enabled: bool) -> None:
protocol_turn_state["interrupt_response"] = enabled
await user_aggregator.apply_turn_strategies(
cfg.turnConfig,
enable_interruptions=enabled,
)
async def cancel_response() -> None:
await worker.queue_frame(InterruptionFrame())
await protocol_adapter.bind(
PipelineProtocolRuntime(
queue_frame=worker.queue_frame,
set_external_turn_control=set_external_turn_control,
set_response_interruption=set_response_interruption,
commit_audio=commit_audio,
clear_audio=clear_audio,
request_response=request_response,
cancel_response=cancel_response,
)
)
service_controller = WorkflowServiceController(
worker=worker,
llm_services=llm_services,
@@ -684,6 +742,15 @@ async def run_pipeline(
normalized,
enable_interruptions=enable_interrupt,
)
if protocol_turn_state["external"]:
await user_aggregator.apply_external_turn_control(True)
elif protocol_turn_state["interrupt_response"] is not None:
await user_aggregator.apply_turn_strategies(
normalized,
enable_interruptions=bool(
protocol_turn_state["interrupt_response"]
),
)
await worker.queue_frame(
VADParamsUpdateFrame(params=create_vad_params(normalized))
)
@@ -871,6 +938,7 @@ async def run_realtime_pipeline(
vision_enabled: bool = False,
assistant_id: str | None = None,
channel: str = "webrtc",
protocol_adapter: PipelineProtocolAdapter | None = None,
) -> None:
"""Run a speech-to-speech model that owns ASR, reasoning, and synthesis."""
realtime = create_realtime_service(
@@ -952,24 +1020,33 @@ async def run_realtime_pipeline(
channel=channel,
runtime_mode=cfg.runtimeMode,
session_id=cfg.conversation_id or None,
analysis_plan=cfg.analysis_config,
extra=(
WorkflowEngine(cfg.graph).session_metadata()
if cfg.type == "workflow"
else None
),
)
protocol_inputs = protocol_adapter.input_processors() if protocol_adapter else []
protocol_inference = (
protocol_adapter.inference_processors() if protocol_adapter else []
)
protocol_outputs = protocol_adapter.output_processors() if protocol_adapter else []
pipeline = Pipeline(
[
transport.input(),
*protocol_inputs,
vision_capture,
client_tools,
session_update,
user_input,
input_gate,
*protocol_inference,
realtime,
dynamic_variables,
EndCallAfterSpeechProcessor(call_end),
ConversationHistoryProcessor(recorder),
*protocol_outputs,
transport.output(),
]
)
@@ -983,6 +1060,46 @@ async def run_realtime_pipeline(
enable_rtvi=False,
)
worker_holder["worker"] = worker
if protocol_adapter:
async def set_external_turn_control(enabled: bool) -> None:
update = getattr(realtime, "update_turn_detection", None)
if callable(update):
await update(None if enabled else cfg.turnConfig)
async def request_response() -> None:
await worker.queue_frame(
RealtimeProviderControlFrame(action="request_response")
)
async def commit_audio() -> None:
await worker.queue_frame(
RealtimeProviderControlFrame(action="commit_audio")
)
async def clear_audio() -> None:
await worker.queue_frame(
RealtimeProviderControlFrame(action="clear_audio")
)
async def cancel_response() -> None:
await realtime.interrupt()
async def set_response_interruption(_enabled: bool) -> None:
# Provider-side settings vary; explicit response.cancel remains portable.
return None
await protocol_adapter.bind(
PipelineProtocolRuntime(
queue_frame=worker.queue_frame,
set_external_turn_control=set_external_turn_control,
set_response_interruption=set_response_interruption,
commit_audio=commit_audio,
clear_audio=clear_audio,
request_response=request_response,
cancel_response=cancel_response,
)
)
def set_input_enabled(enabled: bool) -> None:
input_state["enabled"] = enabled

View File

@@ -257,8 +257,12 @@ class UserInputProcessor(FrameProcessor):
return
if self._should_ignore_input():
logger.debug("通话正在结束,忽略后续文字输入")
await self._emit_result(user_input.input_id, "error", "当前不能接收新的用户输入")
logger.debug("当前会话输入已暂停,忽略用户输入")
await self._emit_result(
user_input.input_id,
"error",
"当前暂不能接收新的用户输入",
)
return
await self._call_event_handler("on_user_input", user_input)
@@ -438,8 +442,22 @@ class ToolInterruptionUserMuteStrategy(BaseUserMuteStrategy):
elif isinstance(frame, FunctionCallResultFrame):
call = self._calls.get(frame.tool_call_id)
if call is not None:
call.tool_finished = True
self._release_completed()
properties = frame.properties
is_final = properties.is_final if properties else True
if is_final:
call.tool_finished = True
run_llm = (
properties.run_llm
if properties and properties.run_llm is not None
else frame.run_llm
)
if run_llm is False and not call.response_started:
# A tool-only result has no following bot response whose
# speech boundary could release the mute. If a preamble
# is already playing, BotStoppedSpeakingFrame still owns
# the release so allow_interruptions=False is respected.
call.response_finished = True
self._release_completed()
elif isinstance(frame, FunctionCallCancelFrame):
# A canceled call cannot reliably produce a follow-up response.
self._calls.pop(frame.tool_call_id, None)

View File

@@ -41,13 +41,14 @@ from services.pipecat.realtime_tools import (
RealtimeToolDispatcher,
RealtimeToolSession,
)
from services.realtime.protocol import RealtimeProviderControlFrame
DEFAULT_QWEN_AUDIO_REALTIME_MODEL = "qwen-audio-3.0-realtime-flash"
DEFAULT_QWEN_AUDIO_REALTIME_VOICE = "longanqian"
QWEN_INPUT_SAMPLE_RATE = 16_000
QWEN_OUTPUT_SAMPLE_RATE = 24_000
SUPPORTED_TURN_DETECTION_MODES = frozenset({"server_vad", "smart_turn"})
SUPPORTED_TURN_DETECTION_MODES = frozenset({"none", "server_vad", "smart_turn"})
ExtraEventHandler = Callable[[dict[str, Any]], Awaitable[None] | None]
SpeechStartedHandler = Callable[[], Awaitable[None]]
@@ -161,6 +162,15 @@ class QwenAudioRealtimeService(AIService):
async def process_frame(self, frame: Frame, direction: FrameDirection) -> None:
await super().process_frame(frame, direction)
if isinstance(frame, RealtimeProviderControlFrame):
if frame.action == "commit_audio":
await self.commit_audio_buffer()
elif frame.action == "clear_audio":
await self.clear_audio_buffer()
else:
await self.request_response()
return
if isinstance(frame, InputAudioRawFrame):
if (
frame.sample_rate != self._input_sample_rate
@@ -234,6 +244,20 @@ class QwenAudioRealtimeService(AIService):
)
await self._send_event({"type": "response.create"})
async def commit_audio_buffer(self) -> None:
await self._send_event({"type": "input_audio_buffer.commit"})
async def clear_audio_buffer(self) -> None:
await self._send_event({"type": "input_audio_buffer.clear"})
async def update_turn_detection(self, config: dict[str, Any] | None) -> None:
"""Apply OpenAI PTT before provider startup; reject unsafe live changes."""
mode = "none" if config is None else "server_vad"
if self._session_ready.is_set():
raise ValueError("Qwen Realtime 不支持会话建立后切换轮次检测")
self._turn_detection_mode = mode
async def wait_for_response_boundary(self) -> None:
"""Wait until Qwen has finished or cancelled the current response."""
await self._response_done.wait()
@@ -355,7 +379,9 @@ class QwenAudioRealtimeService(AIService):
"tool_choice": "auto",
}
def _turn_detection_config(self) -> dict[str, Any]:
def _turn_detection_config(self) -> dict[str, Any] | None:
if self._turn_detection_mode == "none":
return None
if self._turn_detection_mode == "smart_turn":
return {"type": "smart_turn"}
return {

View File

@@ -117,7 +117,7 @@ def create_llm(cfg: AssistantConfig):
raise ValueError(f"不支持的 LLM 接口类型: {cfg.llm_interface_type}")
extra_body = cfg.llm_values.get("extraBody")
extra = {"extra_body": extra_body} if isinstance(extra_body, dict) else {}
return OpenAILLMService(
service = OpenAILLMService(
api_key=_require(cfg.llm_api_key, "LLM apiKey"),
base_url=_require(cfg.llm_base_url, "LLM apiUrl"),
settings=OpenAILLMService.Settings(
@@ -125,6 +125,15 @@ def create_llm(cfg: AssistantConfig):
extra=extra,
),
)
# Pipecat represents late async-tool results with the newer `developer`
# role. Most OpenAI-compatible providers (including DeepSeek) only accept
# system/assistant/user/tool. This flag uses Pipecat's built-in adapter to
# downgrade developer messages before sending the request. Official or
# otherwise compatible endpoints can opt in through the resource values.
service.supports_developer_role = (
cfg.llm_values.get("supportsDeveloperRole") is True
)
return service
def create_tts(cfg: AssistantConfig):

View File

@@ -35,6 +35,7 @@ from services.pipecat.realtime_tools import (
RealtimeToolDispatcher,
RealtimeToolSession,
)
from services.realtime.protocol import RealtimeProviderControlFrame
DEFAULT_STEPFUN_REALTIME_URL = "wss://api.stepfun.com/v1/realtime"
SpeechStartedHandler = Callable[[], Awaitable[None]]
@@ -69,6 +70,7 @@ class StepFunRealtimeService(AIService):
self._prefix_padding_ms = prefix_padding_ms
self._silence_duration_ms = silence_duration_ms
self._energy_awakeness_threshold = energy_awakeness_threshold
self._turn_detection_enabled = True
self._warned_input_sample_rate = False
self._websocket = None
self._receive_task: asyncio.Task | None = None
@@ -115,6 +117,15 @@ class StepFunRealtimeService(AIService):
async def process_frame(self, frame: Frame, direction: FrameDirection) -> None:
await super().process_frame(frame, direction)
if isinstance(frame, RealtimeProviderControlFrame):
if frame.action == "commit_audio":
await self.commit_audio_buffer()
elif frame.action == "clear_audio":
await self.clear_audio_buffer()
else:
await self.request_response()
return
if isinstance(frame, InputAudioRawFrame):
if (
frame.sample_rate != self._input_sample_rate
@@ -183,6 +194,23 @@ class StepFunRealtimeService(AIService):
)
await self._send_event({"type": "response.create"})
async def commit_audio_buffer(self) -> None:
await self._send_event({"type": "input_audio_buffer.commit"})
async def clear_audio_buffer(self) -> None:
await self._send_event({"type": "input_audio_buffer.clear"})
async def update_turn_detection(self, config: dict[str, Any] | None) -> None:
self._turn_detection_enabled = config is not None
if self._session_ready.is_set():
await self._send_event(
{
"type": "session.update",
"session": {"turn_detection": self._turn_detection_config()},
},
wait_until_ready=False,
)
async def wait_for_response_boundary(self) -> None:
"""Wait until StepFun has finished or cancelled the current response."""
await self._response_done.wait()
@@ -386,12 +414,7 @@ class StepFunRealtimeService(AIService):
"voice": self._voice,
"input_audio_format": "pcm16",
"output_audio_format": "pcm16",
"turn_detection": {
"type": "server_vad",
"prefix_padding_ms": self._prefix_padding_ms,
"silence_duration_ms": self._silence_duration_ms,
"energy_awakeness_threshold": self._energy_awakeness_threshold,
},
"turn_detection": self._turn_detection_config(),
"tools": [tool.provider_schema() for tool in self._tools],
"tool_choice": "auto",
},
@@ -399,6 +422,16 @@ class StepFunRealtimeService(AIService):
wait_until_ready=False,
)
def _turn_detection_config(self) -> dict[str, Any] | None:
if not self._turn_detection_enabled:
return None
return {
"type": "server_vad",
"prefix_padding_ms": self._prefix_padding_ms,
"silence_duration_ms": self._silence_duration_ms,
"energy_awakeness_threshold": self._energy_awakeness_threshold,
}
async def update_instructions(self, instructions: str) -> None:
"""Refresh model instructions without rebuilding the realtime session."""
self._instructions = instructions

View File

@@ -14,15 +14,68 @@ from pipecat.transports.base_transport import TransportParams
# WebRTC
from pipecat.transports.smallwebrtc.connection import SmallWebRTCConnection
from pipecat.transports.smallwebrtc.transport import SmallWebRTCTransport
from pipecat.transports.smallwebrtc.transport import (
SmallWebRTCOutputTransport,
SmallWebRTCTransport,
)
# 裸 WS 音频流
from pipecat.transports.websocket.fastapi import (
FastAPIWebsocketOutputTransport,
FastAPIWebsocketTransport,
FastAPIWebsocketParams,
)
from pipecat.serializers.base_serializer import FrameSerializer
from pipecat.serializers.protobuf import ProtobufFrameSerializer
from services.pipecat.call_lifecycle import FixedSpeechPlaybackMarkerFrame
class _PlaybackMarkerOutputMixin:
"""Resolve fixed-speech markers after preceding audio has been sent."""
async def write_transport_frame(self, frame):
if isinstance(frame, FixedSpeechPlaybackMarkerFrame):
await frame.completion.mark_played()
return
await super().write_transport_frame(frame)
class _WebRTCOutputTransport(
_PlaybackMarkerOutputMixin,
SmallWebRTCOutputTransport,
):
pass
class _WebRTCTransport(SmallWebRTCTransport):
def output(self) -> SmallWebRTCOutputTransport:
if not self._output:
self._output = _WebRTCOutputTransport(
self._client,
self._params,
name=self._input_name,
)
return self._output
class _WebsocketOutputTransport(
_PlaybackMarkerOutputMixin,
FastAPIWebsocketOutputTransport,
):
pass
class _WebsocketTransport(FastAPIWebsocketTransport):
def __init__(self, websocket: WebSocket, params: FastAPIWebsocketParams):
super().__init__(websocket=websocket, params=params)
self._output = _WebsocketOutputTransport(
self,
self._client,
self._params,
name=self._output_name,
)
def _base_params(*, video_in_enabled: bool = False) -> dict:
"""两种 transport 共享的音频参数。"""
@@ -41,7 +94,7 @@ def build_webrtc_transport(
*,
video_in_enabled: bool = False,
) -> SmallWebRTCTransport:
return SmallWebRTCTransport(
return _WebRTCTransport(
webrtc_connection=connection,
params=TransportParams(**_base_params(video_in_enabled=video_in_enabled)),
)
@@ -51,10 +104,33 @@ def build_ws_transport(websocket: WebSocket) -> FastAPIWebsocketTransport:
"""裸 WS 输出。序列化用 protobuf(自定义客户端用同款解码);
若对接电话商,把 serializer 换成对应的 TwilioFrameSerializer 等即可。
"""
return FastAPIWebsocketTransport(
return build_serialized_ws_transport(
websocket,
serializer=ProtobufFrameSerializer(),
)
def build_serialized_ws_transport(
websocket: WebSocket,
*,
serializer: FrameSerializer,
sample_rate: int | None = None,
) -> FastAPIWebsocketTransport:
"""Build a text/binary WS transport without coupling it to one protocol."""
sample_rates = (
{
"audio_in_sample_rate": sample_rate,
"audio_out_sample_rate": sample_rate,
}
if sample_rate
else {}
)
return _WebsocketTransport(
websocket=websocket,
params=FastAPIWebsocketParams(
serializer=ProtobufFrameSerializer(),
serializer=serializer,
**_base_params(),
**sample_rates,
),
)

View File

@@ -20,7 +20,10 @@ from pipecat.turns.user_stop import (
SpeechTimeoutUserTurnStopStrategy,
TurnAnalyzerUserTurnStopStrategy,
)
from pipecat.turns.user_turn_strategies import UserTurnStrategies
from pipecat.turns.user_turn_strategies import (
ExternalUserTurnStrategies,
UserTurnStrategies,
)
DEFAULT_VAD = {
@@ -126,3 +129,13 @@ class ConfigurableLLMUserAggregator(LLMUserAggregator):
enable_interruptions=enable_interruptions,
)
await self._user_turn_controller.update_strategies(strategies)
async def apply_external_turn_control(self, enabled: bool) -> None:
"""Switch between client-controlled PTT and the configured VAD."""
strategies = (
ExternalUserTurnStrategies()
if enabled
else self._params.user_turn_strategies
)
await self._user_turn_controller.update_strategies(strategies)

View File

@@ -0,0 +1 @@
"""Post-call structured analysis, independent from the realtime pipeline."""

View File

@@ -0,0 +1,196 @@
"""Extract configured structured fields from one completed conversation."""
from __future__ import annotations
import json
from datetime import UTC, datetime
from typing import Any
import httpx
from db.models import ConversationMessage, ConversationSession, ModelResource
from db.session import SessionLocal
from sqlalchemy import select
from services.webhooks.events import enqueue_analysis_completed
ANALYSIS_TIMEOUT_SECONDS = 60.0
MAX_TRANSCRIPT_CHARS = 120_000
def _endpoint(base_url: str, path: str) -> str:
return f"{base_url.rstrip('/')}/{path.lstrip('/')}"
def _json_schema(fields: list[dict[str, Any]]) -> dict[str, Any]:
properties: dict[str, Any] = {}
for field in fields:
field_type = str(field.get("type") or "string")
schema: dict[str, Any] = {
"description": str(field.get("description") or ""),
}
if field_type == "enum":
schema.update(
{
"type": ["string", "null"],
"enum": [*(field.get("enum_values") or []), None],
}
)
else:
schema["type"] = [field_type, "null"]
properties[str(field["name"])] = schema
return {
"type": "object",
"properties": properties,
"required": list(properties),
"additionalProperties": False,
}
def _transcript(messages: list[ConversationMessage]) -> str:
lines = [
f"[{message.role}] {message.content.strip()}"
for message in messages
if message.content_type == "text" and message.content.strip()
]
transcript = "\n".join(lines)
if len(transcript) <= MAX_TRANSCRIPT_CHARS:
return transcript
return "[较早内容已截断]\n" + transcript[-MAX_TRANSCRIPT_CHARS:]
def _parse_json_content(content: object) -> dict[str, Any]:
text = str(content or "").strip()
if text.startswith("```"):
lines = text.splitlines()
if lines and lines[0].startswith("```"):
lines = lines[1:]
if lines and lines[-1].strip() == "```":
lines = lines[:-1]
text = "\n".join(lines).strip()
parsed = json.loads(text)
if not isinstance(parsed, dict):
raise ValueError("分析模型必须返回 JSON 对象")
return parsed
def _validated_result(
raw: dict[str, Any], fields: list[dict[str, Any]]
) -> dict[str, Any]:
result: dict[str, Any] = {}
for field in fields:
name = str(field["name"])
field_type = str(field.get("type") or "string")
value = raw.get(name)
valid = value is None
if field_type == "string":
valid = valid or isinstance(value, str)
elif field_type == "boolean":
valid = valid or isinstance(value, bool)
elif field_type == "integer":
valid = valid or (isinstance(value, int) and not isinstance(value, bool))
elif field_type == "number":
valid = valid or (
isinstance(value, (int, float)) and not isinstance(value, bool)
)
elif field_type == "enum":
valid = valid or (
isinstance(value, str)
and value in list(field.get("enum_values") or [])
)
result[name] = value if valid else None
return result
async def _request_analysis(
resource: ModelResource,
fields: list[dict[str, Any]],
transcript: str,
) -> dict[str, Any]:
values = resource.values or {}
secrets = resource.secrets or {}
api_url = str(values.get("apiUrl") or "")
api_key = str(secrets.get("apiKey") or "")
model_id = str(values.get("modelId") or "")
if resource.interface_type != "openai-llm":
raise ValueError(f"分析暂不支持模型接口:{resource.interface_type}")
if not api_url or not api_key or not model_id:
raise ValueError("分析模型资源缺少 apiUrl、apiKey 或 modelId")
schema = _json_schema(fields)
system_prompt = (
"你是通话关键信息提取器。只能使用对话中明确出现的信息,禁止猜测、"
"补全或编造。无法确定的字段必须返回 null。严格按照给定 JSON Schema "
"返回一个 JSON 对象,不要输出解释或 Markdown。\n\nJSON Schema:\n"
+ json.dumps(schema, ensure_ascii=False)
)
async with httpx.AsyncClient(timeout=ANALYSIS_TIMEOUT_SECONDS) as client:
response = await client.post(
_endpoint(api_url, "chat/completions"),
headers={"Authorization": f"Bearer {api_key}"},
json={
"model": model_id,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": transcript},
],
"temperature": 0,
"stream": False,
"response_format": {"type": "json_object"},
},
)
response.raise_for_status()
payload = response.json()
choices = payload.get("choices") if isinstance(payload, dict) else None
if not isinstance(choices, list) or not choices:
raise ValueError("分析模型没有返回 choices")
message = choices[0].get("message") if isinstance(choices[0], dict) else None
content = message.get("content") if isinstance(message, dict) else None
return _validated_result(_parse_json_content(content), fields)
async def analyze_conversation(conversation_id: str) -> None:
"""Analyze one claimed conversation and persist its terminal state."""
try:
async with SessionLocal() as session:
conversation = await session.get(ConversationSession, conversation_id)
if not conversation or conversation.analysis_status != "processing":
return
data = dict(conversation.analysis_data or {})
plan = data.get("plan") if isinstance(data.get("plan"), dict) else {}
fields = plan.get("fields") if isinstance(plan.get("fields"), list) else []
resource_id = str(plan.get("model_resource_id") or "")
resource = await session.get(ModelResource, resource_id)
if not resource or not resource.enabled or resource.capability != "LLM":
raise ValueError("分析模型不存在、未启用或不是 LLM 资源")
messages = (
await session.execute(
select(ConversationMessage)
.where(ConversationMessage.session_id == conversation_id)
.order_by(ConversationMessage.sequence)
)
).scalars().all()
transcript = _transcript(list(messages))
if not transcript:
raise ValueError("会话没有可分析的文本转写")
result = await _request_analysis(resource, list(fields), transcript)
async with SessionLocal() as session:
conversation = await session.get(ConversationSession, conversation_id)
if not conversation:
return
data = dict(conversation.analysis_data or {})
data["result"] = result
data["completedAt"] = datetime.now(UTC).isoformat()
conversation.analysis_data = data
conversation.analysis_status = "completed"
conversation.analysis_error = ""
await enqueue_analysis_completed(session, conversation, result)
await session.commit()
except Exception as exc:
async with SessionLocal() as session:
conversation = await session.get(ConversationSession, conversation_id)
if conversation:
conversation.analysis_status = "failed"
conversation.analysis_error = str(exc)[:2048]
await session.commit()

View File

@@ -0,0 +1,59 @@
"""Small PostgreSQL-backed worker for post-call analysis."""
from __future__ import annotations
import asyncio
from db.models import ConversationSession
from db.session import SessionLocal
from loguru import logger
from services.post_call.analyzer import analyze_conversation
from sqlalchemy import select, update
POLL_INTERVAL_SECONDS = 2.0
async def recover_interrupted_analyses() -> None:
"""Return work interrupted by a previous process shutdown to the queue."""
async with SessionLocal() as session:
await session.execute(
update(ConversationSession)
.where(ConversationSession.analysis_status == "processing")
.values(analysis_status="pending", analysis_error="")
)
await session.commit()
async def claim_pending_analysis() -> str | None:
async with SessionLocal() as session:
row = (
await session.execute(
select(ConversationSession)
.where(ConversationSession.analysis_status == "pending")
.order_by(ConversationSession.ended_at)
.with_for_update(skip_locked=True)
.limit(1)
)
).scalar_one_or_none()
if not row:
return None
row.analysis_status = "processing"
row.analysis_error = ""
await session.commit()
return row.id
async def run_analysis_worker() -> None:
logger.info("通话后分析 worker 已启动")
while True:
try:
conversation_id = await claim_pending_analysis()
if conversation_id:
await analyze_conversation(conversation_id)
continue
except asyncio.CancelledError:
raise
except Exception as exc:
logger.error(f"通话后分析 worker 暂时不可用:{exc}")
await asyncio.sleep(POLL_INTERVAL_SECONDS)

View File

@@ -0,0 +1,2 @@
"""Protocol-neutral helpers for realtime transports."""

View File

@@ -0,0 +1,195 @@
"""Resolve and validate an assistant before creating a media connection."""
from __future__ import annotations
from typing import Any
from db.session import SessionLocal
from models import AssistantConfig
from services.config_resolver import resolve_runtime_config
from services.node_specs import graph_references
from services.runtime_variables import prepare_dynamic_config
from services.workflow_engine import WorkflowEngine
async def resolve_assistant_config(
assistant_id: str,
*,
dynamic_variables: dict[str, Any] | None = None,
) -> AssistantConfig:
async with SessionLocal() as session:
config = await resolve_runtime_config(session, assistant_id)
return prepare_dynamic_config(
config,
dynamic_variables or {},
assistant_id=assistant_id,
)
def validate_visual_runtime(config: AssistantConfig) -> bool:
"""Return the authoritative video-input permission or fail before connect."""
if config.type == "workflow":
return WorkflowEngine(config.graph).uses_vision()
vision_enabled = config.vision_enabled
if not vision_enabled:
return False
has_native_vision = (
not config.vision_model_resource_id and config.llm_support_image_input
)
has_aux_vision_model = (
bool(config.vision_model_resource_id)
and config.vision_llm_support_image_input
)
if not (has_native_vision or has_aux_vision_model):
raise ValueError(
"当前模型不支持图片输入,请在模型资源中选择支持图片输入的视觉模型"
)
return True
def _require_values(labels: list[tuple[str, Any]]) -> None:
missing = [label for label, value in labels if not value]
if missing:
raise ValueError(f"助手运行配置不完整: {', '.join(missing)}")
def _validate_voice_resource(
capability: str,
*,
interface_type: str,
values: dict[str, Any],
secrets: dict[str, Any],
) -> None:
labels: list[tuple[str, Any]] = []
if capability == "ASR":
if interface_type not in {"openai-asr", "dashscope-asr", "xfyun-asr"}:
raise ValueError(f"不支持的 ASR 接口类型: {interface_type}")
if interface_type == "xfyun-asr":
labels.extend(
(f"ASR {key}", secrets.get(key))
for key in ("appId", "apiKey", "apiSecret")
)
else:
labels.extend(
[
("ASR modelId", values.get("modelId")),
("ASR apiUrl", values.get("apiUrl")),
("ASR apiKey", secrets.get("apiKey")),
]
)
elif capability == "TTS":
if interface_type not in {
"openai-tts",
"dashscope-tts",
"xfyun-tts",
"xfyun-super-tts",
}:
raise ValueError(f"不支持的 TTS 接口类型: {interface_type}")
labels.append(("TTS voice", values.get("voice")))
if interface_type in {"xfyun-tts", "xfyun-super-tts"}:
labels.extend(
(f"TTS {key}", secrets.get(key))
for key in ("appId", "apiKey", "apiSecret")
)
else:
labels.extend(
[
("TTS modelId", values.get("modelId")),
("TTS apiUrl", values.get("apiUrl")),
("TTS apiKey", secrets.get("apiKey")),
]
)
elif capability == "LLM":
if interface_type not in {"openai-llm", "dashscope-llm"}:
raise ValueError(f"不支持的 LLM 接口类型: {interface_type}")
labels.extend(
[
("LLM modelId", values.get("modelId")),
("LLM apiUrl", values.get("apiUrl")),
("LLM apiKey", secrets.get("apiKey")),
]
)
_require_values(labels)
def validate_runtime_requirements(config: AssistantConfig) -> None:
"""Reject structurally incomplete assistants before media negotiation."""
if config.type not in {"prompt", "workflow", "dify", "fastgpt"}:
raise ValueError(f"当前助手类型不支持 Realtime API: {config.type}")
if config.runtimeMode == "realtime":
if config.type not in {"prompt", "workflow"}:
raise ValueError(f"助手类型 {config.type} 不支持 realtime 运行模式")
if config.realtime_interface_type not in {
"qwen-audio-realtime",
"stepfun-realtime",
}:
raise ValueError(
f"不支持的 Realtime 接口类型: {config.realtime_interface_type}"
)
_require_values(
[
("Realtime interfaceType", config.realtime_interface_type),
("Realtime modelId", config.realtimeModel),
("Realtime apiUrl", config.realtime_base_url),
("Realtime apiKey", config.realtime_api_key),
],
)
return
_validate_voice_resource(
"ASR",
interface_type=config.stt_interface_type,
values=config.stt_values,
secrets=config.stt_secrets,
)
_validate_voice_resource(
"TTS",
interface_type=config.tts_interface_type,
values=config.tts_values,
secrets=config.tts_secrets,
)
if config.type == "prompt":
_validate_voice_resource(
"LLM",
interface_type=config.llm_interface_type,
values=config.llm_values,
secrets=config.llm_secrets,
)
elif config.type == "dify":
_require_values(
[
("Dify apiUrl", config.dify_api_url),
("Dify apiKey", config.dify_api_key),
],
)
elif config.type == "fastgpt":
_require_values(
[
("FastGPT apiUrl", config.fastgpt_api_url),
("FastGPT apiKey", config.fastgpt_api_key),
],
)
if config.type != "workflow":
return
references = graph_references(config.graph)
missing_models = references["model_resources"] - set(
config.workflow_model_resources
)
missing_knowledge = references["knowledge_bases"] - set(
config.workflow_knowledge_bases
)
if missing_models or missing_knowledge:
missing = sorted([*missing_models, *missing_knowledge])
raise ValueError(f"Workflow 引用了不可用资源: {', '.join(missing)}")
for resource in config.workflow_model_resources.values():
if resource.capability in {"ASR", "TTS", "LLM"}:
_validate_voice_resource(
resource.capability,
interface_type=resource.interface_type,
values=resource.values,
secrets=resource.secrets,
)

View File

@@ -0,0 +1,109 @@
"""Shared ownership of peer connections and their pipeline tasks."""
from __future__ import annotations
import asyncio
from collections.abc import Coroutine
from typing import Any
from loguru import logger
active_connections: set[object] = set()
pipeline_tasks: set[asyncio.Task[None]] = set()
connection_tasks: dict[object, asyncio.Task[None]] = {}
DEFAULT_CLOSE_GRACE_SECONDS = 10.0
def _consume_pipeline_result(task: asyncio.Task[None], connection: object) -> None:
pipeline_tasks.discard(task)
if connection_tasks.get(connection) is task:
connection_tasks.pop(connection, None)
try:
error = task.exception()
except asyncio.CancelledError:
logger.info(f"Realtime pipeline 已取消: task={task.get_name()}")
return
if error is not None:
logger.opt(exception=error).error(
f"Realtime pipeline 异常结束: task={task.get_name()}"
)
def start_pipeline_task(
connection: object,
coroutine: Coroutine[Any, Any, None],
*,
protocol: str,
) -> asyncio.Task[None]:
connection_id = str(getattr(connection, "pc_id", id(connection)))
task = asyncio.create_task(
coroutine,
name=f"{protocol}-pipeline:{connection_id}",
)
active_connections.add(connection)
pipeline_tasks.add(task)
connection_tasks[connection] = task
task.add_done_callback(
lambda completed, connection=connection: _consume_pipeline_result(
completed,
connection,
)
)
return task
async def wait_for_pipeline_close(
task: asyncio.Task[None] | None,
*,
connection_id: str,
timeout: float = DEFAULT_CLOSE_GRACE_SECONDS,
) -> None:
if task is None:
return
try:
await asyncio.wait_for(asyncio.shield(task), timeout=timeout)
return
except TimeoutError:
logger.warning(
f"Realtime pipeline 关闭超过 {timeout:g} 秒,执行取消: "
f"connection_id={connection_id}"
)
except asyncio.CancelledError:
raise
except Exception:
return
task.cancel()
done, _pending = await asyncio.wait({task}, timeout=timeout)
if not done:
logger.error(f"Realtime pipeline 取消后仍未退出: connection_id={connection_id}")
async def shutdown_active_sessions(
*,
timeout: float = DEFAULT_CLOSE_GRACE_SECONDS,
) -> None:
connections = list(active_connections)
if connections:
await asyncio.gather(
*(connection.disconnect() for connection in connections),
return_exceptions=True,
)
active_connections.difference_update(connections)
tasks = list(pipeline_tasks)
if not tasks:
return
done, pending = await asyncio.wait(tasks, timeout=timeout)
for task in pending:
task.cancel()
cancelled: set[asyncio.Task[None]] = set()
if pending:
cancelled, stuck = await asyncio.wait(pending, timeout=timeout)
if stuck:
logger.error(f"应用关闭时仍有 {len(stuck)} 个 Realtime pipeline 未退出")
logger.info(
f"Realtime 会话清理完成: normal={len(done)} cancelled={len(cancelled)}"
)

View File

@@ -0,0 +1,42 @@
"""Neutral extension point between a transport protocol and the voice pipeline."""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Literal, Protocol
from pipecat.frames.frames import Frame, SystemFrame
from pipecat.processors.frame_processor import FrameProcessor
@dataclass(frozen=True)
class PipelineProtocolRuntime:
"""Operations a wire-protocol adapter may request from a running pipeline."""
queue_frame: Callable[[Frame], Awaitable[None]]
set_external_turn_control: Callable[[bool], Awaitable[None]]
set_response_interruption: Callable[[bool], Awaitable[None]]
commit_audio: Callable[[], Awaitable[None]]
clear_audio: Callable[[], Awaitable[None]]
request_response: Callable[[], Awaitable[None]]
cancel_response: Callable[[], Awaitable[None]]
@dataclass
class RealtimeProviderControlFrame(SystemFrame):
"""Ordered control sent to a speech-to-speech provider after prior input."""
action: Literal["commit_audio", "clear_audio", "request_response"]
class PipelineProtocolAdapter(Protocol):
"""A protocol adapter only translates frames; it does not own the assistant."""
def input_processors(self) -> list[FrameProcessor]: ...
def inference_processors(self) -> list[FrameProcessor]: ...
def output_processors(self) -> list[FrameProcessor]: ...
async def bind(self, runtime: PipelineProtocolRuntime) -> None: ...

View File

@@ -0,0 +1 @@
"""Fixed-text test execution domain."""

View File

@@ -0,0 +1,24 @@
"""Structured failures shared by the text runner and batch orchestrator."""
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class TestExecutionError(RuntimeError):
code: str
message: str
stage: str
retryable: bool = False
def __post_init__(self) -> None:
RuntimeError.__init__(self, self.message)
def as_dict(self) -> dict[str, object]:
return {
"code": self.code,
"message": self.message,
"stage": self.stage,
"retryable": self.retryable,
}

View File

@@ -0,0 +1,374 @@
"""Deterministic and LLM-backed assertions for text test results."""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from typing import Any
import httpx
from models import RuntimeModelResource
from services.test_runs.errors import TestExecutionError
from services.test_runs.text_runner import RawToolCall, RawTurnResult, TextCaseResult
from test_schemas import (
BatchEvaluationResult,
BatchTurnResult,
FixedInputTurn,
OverallCriterion,
ReplyExpectedBehavior,
TestCaseDefinition,
ToolCallExpectedBehavior,
ToolParamAssertion,
)
@dataclass(frozen=True)
class EvaluatedCase:
turns: list[BatchTurnResult]
overall_criteria: list[BatchEvaluationResult]
@property
def passed(self) -> bool:
evaluations = [
item
for turn in self.turns
for item in turn.evaluations
] + self.overall_criteria
return all(item.status == "pass" for item in evaluations)
class LLMJudge:
"""Small OpenAI-compatible JSON judge using a dedicated model resource."""
def __init__(
self,
resource: RuntimeModelResource,
*,
timeout_seconds: float = 60,
):
self._resource = resource
self._timeout_seconds = timeout_seconds
async def evaluate(
self,
*,
criteria: str,
subject: str,
context: str,
) -> tuple[bool, str, str]:
values = self._resource.values or {}
secrets = self._resource.secrets or {}
base_url = str(values.get("apiUrl") or "").rstrip("/")
api_key = str(secrets.get("apiKey") or "")
model = str(values.get("modelId") or "")
if not base_url or not api_key or not model:
raise TestExecutionError(
code="EVALUATOR_NOT_CONFIGURED",
message="LLM 评估所需的模型资源未完整配置",
stage="evaluation",
)
prompt = (
"你是严格的自动化测试评估器。只根据提供的内容判断标准是否满足。\n"
"必须只输出一个 JSON 对象,不要输出 Markdown\n"
'{"passed":true或false,"reason":"简短中文理由","actual":"实际表现摘要"}\n\n'
f"评估对象:{subject}\n"
f"评估标准:{criteria}\n\n"
f"待评估内容:\n{context}"
)
payload: dict[str, Any] = {
"model": model,
"messages": [{"role": "user", "content": prompt}],
"temperature": 0,
}
extra_body = values.get("extraBody")
if isinstance(extra_body, dict):
payload.update(
{
key: value
for key, value in extra_body.items()
if key not in {"model", "messages"}
}
)
try:
async with httpx.AsyncClient(timeout=self._timeout_seconds) as client:
response = await client.post(
f"{base_url}/chat/completions",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json=payload,
)
response.raise_for_status()
body = response.json()
content = body["choices"][0]["message"]["content"]
if isinstance(content, list):
content = "".join(
str(part.get("text") or "")
for part in content
if isinstance(part, dict)
)
parsed = self._parse_json(str(content or ""))
return (
bool(parsed.get("passed")),
str(parsed.get("reason") or "LLM 未提供判断理由"),
str(parsed.get("actual") or ""),
)
except TestExecutionError:
raise
except (httpx.HTTPError, KeyError, IndexError, TypeError, ValueError) as exc:
raise TestExecutionError(
code="EVALUATOR_REQUEST_FAILED",
message=f"LLM 评估失败: {exc}",
stage="evaluation",
retryable=True,
) from exc
@staticmethod
def _parse_json(value: str) -> dict[str, Any]:
text = value.strip()
if text.startswith("```"):
text = re.sub(r"^```(?:json)?\s*", "", text)
text = re.sub(r"\s*```$", "", text)
start = text.find("{")
end = text.rfind("}")
if start < 0 or end < start:
raise ValueError("评估模型没有返回 JSON")
parsed = json.loads(text[start : end + 1])
if (
not isinstance(parsed, dict)
or not isinstance(parsed.get("passed"), bool)
):
raise ValueError("评估模型返回格式不正确")
return parsed
class EvaluationEngine:
def __init__(self, judge_resource: RuntimeModelResource):
self._judge = LLMJudge(judge_resource)
async def evaluate(
self,
definition: TestCaseDefinition,
execution: TextCaseResult,
) -> EvaluatedCase:
raw_by_id = {turn.id: turn for turn in execution.turns}
turns: list[BatchTurnResult] = []
for index, expected_turn in enumerate(definition.turns):
raw = raw_by_id.get(expected_turn.id) or RawTurnResult(
id=expected_turn.id,
index=index,
user_input=expected_turn.user_input,
assistant_reply="",
tool_calls=[],
)
evaluations = [
await self._evaluate_behavior(behavior, raw)
for behavior in expected_turn.behaviors
]
turns.append(
BatchTurnResult(
id=raw.id,
index=index,
user_input=raw.user_input,
assistant_reply=raw.assistant_reply,
tool_calls=[item.as_result() for item in raw.tool_calls],
evaluations=evaluations,
)
)
transcript = self._format_transcript(execution, definition)
overall = [
await self._evaluate_overall(criterion, transcript)
for criterion in definition.overall_criteria
]
return EvaluatedCase(turns=turns, overall_criteria=overall)
async def _evaluate_behavior(
self,
behavior: ReplyExpectedBehavior | ToolCallExpectedBehavior,
turn: RawTurnResult,
) -> BatchEvaluationResult:
if behavior.type == "reply":
return await self._evaluate_reply(behavior, turn.assistant_reply)
return await self._evaluate_tool(behavior, turn.tool_calls)
async def _evaluate_reply(
self,
behavior: ReplyExpectedBehavior,
reply: str,
) -> BatchEvaluationResult:
if behavior.assertion_type == "llm":
passed, reason, actual = await self._judge.evaluate(
criteria=behavior.llm_criteria,
subject="当前轮助手回复",
context=reply or "(助手没有生成文字回复)",
)
return BatchEvaluationResult(
id=behavior.id,
label="回复 · LLM 判断",
kind="reply",
status="pass" if passed else "fail",
expected=behavior.llm_criteria,
actual=actual or reply,
reason="" if passed else reason,
)
normalized_reply = reply.casefold()
matches = [keyword.casefold() in normalized_reply for keyword in behavior.keywords]
matched = all(matches) if behavior.keyword_match_mode == "all" else any(matches)
passed = not matched if behavior.negate_keywords else matched
relation = (
"不应包含"
if behavior.negate_keywords
else "应包含全部"
if behavior.keyword_match_mode == "all"
else "应至少包含其一"
)
return BatchEvaluationResult(
id=behavior.id,
label="回复 · 关键词",
kind="reply",
status="pass" if passed else "fail",
expected=f"{relation}{''.join(behavior.keywords)}",
actual=reply or "(无文字回复)",
reason="" if passed else "实际回复未满足关键词规则。",
)
async def _evaluate_tool(
self,
behavior: ToolCallExpectedBehavior,
calls: list[RawToolCall],
) -> BatchEvaluationResult:
matching_calls = [
call for call in calls if call.function_name == behavior.function_name
]
count = len(matching_calls)
if behavior.expectation == "not_called":
passed = count == 0
return BatchEvaluationResult(
id=behavior.id,
label=f"工具 · {behavior.function_name}",
kind="tool_call",
status="pass" if passed else "fail",
expected=f"不应调用 {behavior.function_name}",
actual=f"实际调用 {count}{behavior.function_name}",
reason="" if passed else "检测到本轮不应发生的工具调用。",
)
count_passed = count >= behavior.min_calls and (
behavior.max_calls is None or count <= behavior.max_calls
)
params_passed = True
parameter_reason = ""
if count_passed and behavior.param_assertions:
params_passed = False
reasons: list[str] = []
for call in matching_calls:
call_passed, call_reason = await self._call_matches_parameters(
call, behavior.param_assertions
)
if call_passed:
params_passed = True
break
reasons.append(call_reason)
parameter_reason = "".join(item for item in reasons if item)
passed = count_passed and params_passed
count_text = (
f"{behavior.min_calls} 次以上"
if behavior.max_calls is None
else f"{behavior.min_calls}"
if behavior.min_calls == behavior.max_calls
else f"{behavior.min_calls}{behavior.max_calls}"
)
reason = ""
if not count_passed:
reason = "工具调用次数不符合要求。"
elif not params_passed:
reason = parameter_reason or "没有一次工具调用满足全部参数断言。"
return BatchEvaluationResult(
id=behavior.id,
label=f"工具 · {behavior.function_name}",
kind="tool_call",
status="pass" if passed else "fail",
expected=f"应调用 {behavior.function_name} {count_text}",
actual=f"实际调用 {count}{behavior.function_name}",
reason=reason,
)
async def _call_matches_parameters(
self,
call: RawToolCall,
assertions: list[ToolParamAssertion],
) -> tuple[bool, str]:
arguments = call.arguments if isinstance(call.arguments, dict) else {}
for assertion in assertions:
if assertion.name not in arguments:
return False, f"缺少参数 {assertion.name}"
actual = arguments[assertion.name]
if assertion.match_mode == "exact":
try:
expected: Any = json.loads(assertion.value)
except json.JSONDecodeError:
expected = assertion.value
if actual != expected and str(actual) != assertion.value:
return False, f"参数 {assertion.name} 与期望值不一致"
elif assertion.match_mode == "regex":
actual_text = (
actual
if isinstance(actual, str)
else json.dumps(actual, ensure_ascii=False, default=str)
)
if re.search(assertion.value, actual_text) is None:
return False, f"参数 {assertion.name} 未匹配正则表达式"
else:
passed, reason, _actual = await self._judge.evaluate(
criteria=assertion.value,
subject=f"工具参数 {assertion.name}",
context=json.dumps(actual, ensure_ascii=False, default=str),
)
if not passed:
return False, reason
return True, ""
async def _evaluate_overall(
self,
criterion: OverallCriterion,
transcript: str,
) -> BatchEvaluationResult:
passed, reason, actual = await self._judge.evaluate(
criteria=criterion.criteria,
subject="完整测试对话",
context=transcript,
)
return BatchEvaluationResult(
id=criterion.id,
label=criterion.name,
kind="overall",
status="pass" if passed else "fail",
expected=criterion.criteria,
actual=actual or "已评估完整对话",
reason="" if passed else reason,
)
@staticmethod
def _format_transcript(
execution: TextCaseResult,
definition: TestCaseDefinition,
) -> str:
if execution.transcript:
labels = {"user": "User", "assistant": "Agent"}
return "\n".join(
f"{labels.get(item['role'], item['role'])}: {item['content']}"
for item in execution.transcript
)
raw_by_id = {turn.id: turn for turn in execution.turns}
lines: list[str] = []
for turn in definition.turns:
raw = raw_by_id.get(turn.id)
lines.append(f"User: {turn.user_input}")
lines.append(f"Agent: {raw.assistant_reply if raw else ''}")
return "\n".join(lines)

View File

@@ -0,0 +1,85 @@
"""Test-only tool executor that never reaches external side effects."""
from __future__ import annotations
import asyncio
import json
from typing import Any
from models import RuntimeTool
from services.runtime_variables import DynamicVariableStore
from services.tool_executor import ToolExecutionError, ToolExecutor
from test_schemas import ToolCallExpectedBehavior
class MockToolExecutor(ToolExecutor):
"""Resolve the active turn's configured mocks and reject everything else."""
def __init__(self, store: DynamicVariableStore):
super().__init__(store)
self._mocks_by_tool_id: dict[str, ToolCallExpectedBehavior] = {}
self._mocks_by_function: dict[str, ToolCallExpectedBehavior] = {}
self.unmocked_calls: list[str] = []
def set_turn_behaviors(
self,
behaviors: list[ToolCallExpectedBehavior],
) -> None:
self._mocks_by_tool_id = {item.tool_id: item for item in behaviors}
self._mocks_by_function = {item.function_name: item for item in behaviors}
self.unmocked_calls = []
async def execute(
self,
tool: RuntimeTool,
arguments: dict[str, Any] | None = None,
*,
result_assignments: dict[str, str] | None = None,
) -> dict[str, Any]:
self.register_secrets(tool)
behavior = self._mocks_by_tool_id.get(tool.id) or self._mocks_by_function.get(
tool.function_name
)
if behavior is None:
self.unmocked_calls.append(tool.function_name)
raise ToolExecutionError(
f"UNMOCKED_TOOL_CALL: 工具 {tool.function_name} 未配置 Mock 返回值"
)
delay_seconds = behavior.mock_response.delay_ms / 1000
if delay_seconds:
await asyncio.sleep(delay_seconds)
payload = json.loads(behavior.mock_response.body)
status = "ok" if behavior.mock_response.outcome == "success" else "error"
if isinstance(payload, dict):
result: dict[str, Any] = dict(payload)
result["status"] = status
else:
result = {"status": status, "data": payload}
if result.get("status") != "ok":
return {**result, "updated_variables": []}
return self._apply_result_assignments(
tool,
result,
result_assignments=result_assignments,
)
class TextClientToolPort:
"""Automatically acknowledge built-in message stages in text tests."""
async def call(
self,
function_name: str,
arguments: dict[str, Any],
**_kwargs: Any,
) -> dict[str, Any]:
if function_name == "show_message":
return {
"status": "ok",
"data": {"action": "confirmed", "arguments": arguments},
}
raise ToolExecutionError(
f"UNMOCKED_CLIENT_TOOL: 客户端工具 {function_name} 不能脱离 Mock 执行"
)

View File

@@ -0,0 +1,410 @@
"""In-process MVP scheduler for persisted batch test runs."""
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
from datetime import UTC, datetime
from loguru import logger
from sqlalchemy import select
from db.models import ModelResource, TestCase, TestRun, TestRunCase
from db.session import SessionLocal
from models import RuntimeModelResource
from services.config_resolver import resolve_runtime_config
from services.test_runs.errors import TestExecutionError
from services.test_runs.evaluator import EvaluationEngine
from services.test_runs.text_runner import TextPipelineRunner
from test_schemas import TestCaseDefinition
@dataclass
class _RunControl:
stop_event: asyncio.Event = field(default_factory=asyncio.Event)
reason: str | None = None
case_tasks: set[asyncio.Task[None]] = field(default_factory=set)
class TestRunOrchestrator:
"""Run cases concurrently while keeping PostgreSQL as the source of truth."""
def __init__(self) -> None:
self._run_tasks: dict[str, asyncio.Task[None]] = {}
self._controls: dict[str, _RunControl] = {}
def start(self, run_id: str) -> None:
current = self._run_tasks.get(run_id)
if current is not None and not current.done():
return
control = _RunControl()
self._controls[run_id] = control
task = asyncio.create_task(self._execute_run(run_id, control), name=f"test-run-{run_id}")
self._run_tasks[run_id] = task
task.add_done_callback(lambda _task: self._forget(run_id))
async def cancel(self, run_id: str) -> None:
control = self._controls.get(run_id)
if control is None:
return
control.reason = "manual"
control.stop_event.set()
for task in tuple(control.case_tasks):
task.cancel()
async def shutdown(self) -> None:
tasks = [task for task in self._run_tasks.values() if not task.done()]
for task in tasks:
task.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
self._run_tasks.clear()
self._controls.clear()
def _forget(self, run_id: str) -> None:
self._run_tasks.pop(run_id, None)
self._controls.pop(run_id, None)
async def _execute_run(self, run_id: str, control: _RunControl) -> None:
try:
async with SessionLocal() as session:
run = await session.get(TestRun, run_id)
if run is None:
return
run.status = "running"
run.started_at = run.started_at or datetime.now(UTC)
await session.commit()
try:
cfg = await resolve_runtime_config(session, str(run.assistant_id or ""))
except Exception as exc:
await self._fail_run_setup(run_id, str(exc))
return
config = dict(run.config or {})
evaluator_resource_id = str(
config.get("evaluatorModelResourceId") or ""
)
evaluator_row = await session.get(
ModelResource,
evaluator_resource_id,
)
if (
evaluator_row is None
or evaluator_row.capability != "LLM"
or not evaluator_row.enabled
):
await self._fail_run_setup(
run_id,
"评估模型不存在、已停用或不是 LLM 资源",
)
return
evaluator_resource = RuntimeModelResource(
id=evaluator_row.id,
name=evaluator_row.name,
capability=evaluator_row.capability,
interface_type=evaluator_row.interface_type,
values=evaluator_row.values or {},
secrets=evaluator_row.secrets or {},
support_image_input=bool(evaluator_row.support_image_input),
)
rows = (
await session.execute(
select(TestRunCase)
.where(TestRunCase.run_id == run_id)
.order_by(TestRunCase.position)
)
).scalars().all()
semaphore = asyncio.Semaphore(int(config.get("concurrency") or 3))
tasks = {
asyncio.create_task(
self._execute_case(
run_id,
row.id,
cfg,
evaluator_resource,
config,
semaphore,
control,
),
name=f"test-run-case-{row.id}",
)
for row in rows
}
control.case_tasks = tasks
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
await self._finish_run(run_id, control)
except asyncio.CancelledError:
await self._interrupt_run(run_id)
raise
except Exception as exc: # noqa: BLE001 - background task must persist failure
logger.exception(f"批量测试运行失败: run_id={run_id}: {exc}")
await self._fail_run_setup(run_id, str(exc))
async def _execute_case(
self,
run_id: str,
run_case_id: str,
cfg,
evaluator_resource: RuntimeModelResource,
config: dict,
semaphore: asyncio.Semaphore,
control: _RunControl,
) -> None:
async with semaphore:
if control.stop_event.is_set():
await self._mark_skipped(run_case_id)
return
async with SessionLocal() as session:
row = await session.get(TestRunCase, run_case_id)
if row is None:
return
snapshot = dict(row.case_snapshot or {})
definition = TestCaseDefinition.model_validate(snapshot)
max_attempts = row.max_attempts
for attempt in range(1, max_attempts + 1):
if control.stop_event.is_set():
await self._mark_skipped(run_case_id)
return
await self._mark_running(run_case_id, attempt)
try:
timeout_seconds = int(
config.get("timeoutSecs")
or config.get("timeout_secs")
or 60
)
async with asyncio.timeout(timeout_seconds):
execution = await TextPipelineRunner().run(
cfg.model_copy(deep=True),
definition,
timeout_seconds=timeout_seconds,
)
evaluated = await EvaluationEngine(evaluator_resource).evaluate(
definition,
execution,
)
except asyncio.CancelledError:
await self._mark_skipped(run_case_id)
raise
except TimeoutError:
error = TestExecutionError(
code="CASE_TIMEOUT",
message=f"测试用例执行与评估超过 {timeout_seconds}",
stage="pipeline",
retryable=True,
)
if attempt < max_attempts and not control.stop_event.is_set():
continue
await self._mark_error(run_case_id, error)
if str(
config.get("errorStrategy")
or config.get("error_strategy")
) == "stop_on_error":
await self._request_stop(run_id, control, "execution_error")
return
except TestExecutionError as exc:
if exc.retryable and attempt < max_attempts and not control.stop_event.is_set():
continue
await self._mark_error(run_case_id, exc)
if str(config.get("errorStrategy") or config.get("error_strategy")) == "stop_on_error":
await self._request_stop(run_id, control, "execution_error")
return
except Exception as exc: # noqa: BLE001 - normalize unknown runtime errors
error = TestExecutionError(
code="UNEXPECTED_EXECUTION_ERROR",
message=str(exc) or type(exc).__name__,
stage="pipeline",
retryable=False,
)
await self._mark_error(run_case_id, error)
if str(config.get("errorStrategy") or config.get("error_strategy")) == "stop_on_error":
await self._request_stop(run_id, control, "execution_error")
return
status = "pass" if evaluated.passed else "fail"
await self._mark_evaluated(run_case_id, status, evaluated)
if status == "fail" and str(
config.get("failureStrategy") or config.get("failure_strategy")
) == "stop_on_fail":
await self._request_stop(run_id, control, "assertion_failure")
return
async def _request_stop(
self,
run_id: str,
control: _RunControl,
reason: str,
) -> None:
if control.reason is None:
control.reason = reason
control.stop_event.set()
async with SessionLocal() as session:
run = await session.get(TestRun, run_id)
if run is not None and run.stop_reason is None:
run.stop_reason = reason
await session.commit()
async def _mark_running(self, run_case_id: str, attempt: int) -> None:
async with SessionLocal() as session:
row = await session.get(TestRunCase, run_case_id)
if row is None:
return
row.status = "running"
row.attempt_count = attempt
row.started_at = row.started_at or datetime.now(UTC)
row.finished_at = None
row.execution_error = None
await session.commit()
async def _mark_evaluated(self, run_case_id: str, status: str, evaluated) -> None:
async with SessionLocal() as session:
row = await session.get(TestRunCase, run_case_id)
if row is None:
return
row.status = status
row.result = {
"turns": [
item.model_dump(mode="json", by_alias=True)
for item in evaluated.turns
],
"overallCriteria": [
item.model_dump(mode="json", by_alias=True)
for item in evaluated.overall_criteria
],
}
row.execution_error = None
row.finished_at = datetime.now(UTC)
if row.test_case_id:
test_case = await session.get(TestCase, row.test_case_id)
if test_case is not None:
test_case.last_result = status
await session.commit()
async def _mark_error(
self,
run_case_id: str,
error: TestExecutionError,
) -> None:
async with SessionLocal() as session:
row = await session.get(TestRunCase, run_case_id)
if row is None:
return
row.status = "error"
row.execution_error = error.as_dict()
row.finished_at = datetime.now(UTC)
if row.test_case_id:
test_case = await session.get(TestCase, row.test_case_id)
if test_case is not None:
test_case.last_result = "fail"
await session.commit()
async def _mark_skipped(self, run_case_id: str) -> None:
async with SessionLocal() as session:
row = await session.get(TestRunCase, run_case_id)
if row is None or row.status in {"pass", "fail", "error", "skipped"}:
return
row.status = "skipped"
row.finished_at = datetime.now(UTC)
await session.commit()
async def _finish_run(self, run_id: str, control: _RunControl) -> None:
async with SessionLocal() as session:
run = await session.get(TestRun, run_id)
if run is None:
return
waiting = (
await session.execute(
select(TestRunCase).where(
TestRunCase.run_id == run_id,
TestRunCase.status.in_(["waiting", "running"]),
)
)
).scalars().all()
for row in waiting:
row.status = "skipped"
row.finished_at = datetime.now(UTC)
manual = bool(run.cancel_requested or control.reason == "manual")
run.status = "cancelled" if manual else "completed"
run.stop_reason = "manual" if manual else control.reason or run.stop_reason
run.finished_at = datetime.now(UTC)
await session.commit()
async def _fail_run_setup(self, run_id: str, message: str) -> None:
error = TestExecutionError(
code="RUN_SETUP_FAILED",
message=message or "批量测试初始化失败",
stage="pipeline",
retryable=False,
)
async with SessionLocal() as session:
run = await session.get(TestRun, run_id)
if run is None:
return
rows = (
await session.execute(
select(TestRunCase).where(
TestRunCase.run_id == run_id,
TestRunCase.status.in_(["waiting", "running"]),
)
)
).scalars().all()
now = datetime.now(UTC)
for row in rows:
row.status = "error"
row.execution_error = error.as_dict()
row.finished_at = now
run.status = "completed"
run.stop_reason = "execution_error"
run.finished_at = now
await session.commit()
async def _interrupt_run(self, run_id: str) -> None:
await _mark_run_interrupted(run_id, "后端服务停止,运行已中断")
async def _mark_run_interrupted(run_id: str, message: str) -> None:
async with SessionLocal() as session:
run = await session.get(TestRun, run_id)
if run is None or run.status not in {"queued", "running"}:
return
rows = (
await session.execute(
select(TestRunCase).where(
TestRunCase.run_id == run_id,
TestRunCase.status.in_(["waiting", "running"]),
)
)
).scalars().all()
now = datetime.now(UTC)
for row in rows:
if row.status == "running":
row.status = "error"
row.execution_error = {
"code": "SERVER_RESTARTED",
"message": message,
"stage": "pipeline",
"retryable": True,
}
else:
row.status = "skipped"
row.finished_at = now
run.status = "completed"
run.stop_reason = "execution_error"
run.finished_at = now
await session.commit()
async def recover_interrupted_test_runs() -> None:
async with SessionLocal() as session:
run_ids = (
await session.execute(
select(TestRun.id).where(TestRun.status.in_(["queued", "running"]))
)
).scalars().all()
for run_id in run_ids:
await _mark_run_interrupted(str(run_id), "后端服务重启,运行已中断")
test_run_orchestrator = TestRunOrchestrator()

View File

@@ -0,0 +1,687 @@
"""Transportless Pipecat runner for one persisted fixed-text test case."""
from __future__ import annotations
import asyncio
import json
import time
from dataclasses import dataclass, field
from typing import Any, Awaitable, Callable
from uuid import uuid4
from loguru import logger
from models import AssistantConfig
from pipecat.adapters.schemas.function_schema import FunctionSchema
from pipecat.adapters.schemas.tools_schema import ToolsSchema
from pipecat.frames.frames import (
BotStoppedSpeakingFrame,
CancelFrame,
EndFrame,
ErrorFrame,
FunctionCallInProgressFrame,
FunctionCallResultFrame,
LLMContextFrame,
LLMFullResponseEndFrame,
LLMFullResponseStartFrame,
LLMMessagesAppendFrame,
ManuallySwitchServiceFrame,
TTSSpeakFrame,
)
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.worker import PipelineParams, PipelineWorker
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.aggregators.llm_response_universal import (
LLMContextAggregatorPair,
)
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from pipecat.services.llm_service import FunctionCallParams
from pipecat.workers.runner import WorkerRunner
from db.session import SessionLocal
from services.brains import BrainRuntime, build_brain
from services.knowledge import search as search_knowledge
from services.pipecat.call_lifecycle import (
CallEndCoordinator,
FixedSpeechPlaybackMarkerFrame,
)
from services.pipecat.processors import (
KnowledgeRetrievalProcessor,
UserTurnRoutingProcessor,
)
from services.pipecat.service_factory import config_with_resource
from services.pipecat.workflow_services import build_workflow_llm_switcher
from services.test_runs.errors import TestExecutionError
from services.test_runs.mock_tools import MockToolExecutor, TextClientToolPort
from test_schemas import ContextTurn, FixedInputTurn, TestCaseDefinition
IDLE_SETTLE_SECONDS = 0.2
WORKER_START_TIMEOUT_SECONDS = 5.0
INTERNAL_CANCEL_REASON = "text_test_case_complete"
AUTOMATIC_KNOWLEDGE_HINT = (
"你已连接内部知识库。系统会在每轮用户问题前自动提供相关资料;"
"回答资料事实时只根据检索内容,资料不足要明确说明。"
)
ON_DEMAND_KNOWLEDGE_HINT = (
"你已连接内部知识库。当用户问题涉及可能存在于业务知识库中的事实时,"
"先调用 search_knowledge_base 检索;回答资料事实时只根据检索内容,"
"资料不足要明确说明。"
)
@dataclass
class RawToolCall:
id: str
function_name: str
arguments: Any
result: Any = None
outcome: str = "error"
duration_ms: int = 0
_started_at: float = field(default_factory=time.monotonic, repr=False)
def as_result(self) -> dict[str, Any]:
return {
"id": self.id,
"functionName": self.function_name,
"argumentsJson": json.dumps(
self.arguments, ensure_ascii=False, indent=2, default=str
),
"resultJson": json.dumps(
self.result, ensure_ascii=False, indent=2, default=str
),
"outcome": self.outcome,
"durationMs": self.duration_ms,
}
@dataclass
class RawTurnResult:
id: str
index: int
user_input: str
assistant_reply: str
tool_calls: list[RawToolCall]
@dataclass
class TextCaseResult:
turns: list[RawTurnResult]
transcript: list[dict[str, str]]
@dataclass
class _ResponseWindow:
active_llm: int = 0
active_assistant: int = 0
pending_tools: set[str] = field(default_factory=set)
activity_count: int = 0
last_activity_at: float = field(default_factory=time.monotonic)
def touch(self) -> None:
self.activity_count += 1
self.last_activity_at = time.monotonic()
@property
def idle(self) -> bool:
return self.active_llm == 0 and self.active_assistant == 0 and not self.pending_tools
class _TextCaptureProcessor(FrameProcessor):
"""Capture assistant/tool output and expose a reliable response boundary."""
def __init__(self, context: LLMContext):
super().__init__()
self.context = context
self.window = _ResponseWindow()
self.outputs: list[str] = []
self.tool_calls: list[RawToolCall] = []
self._tool_by_id: dict[str, RawToolCall] = {}
self.error: TestExecutionError | None = None
self.on_fixed_speech_complete: Callable[[], Awaitable[None]] | None = None
async def process_frame(self, frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if isinstance(frame, TTSSpeakFrame):
text = frame.text.strip()
if text:
self.outputs.append(text)
if frame.append_to_context:
self.context.add_message({"role": "assistant", "content": text})
self.window.touch()
if self.on_fixed_speech_complete is not None:
await self.on_fixed_speech_complete()
return
if isinstance(frame, FixedSpeechPlaybackMarkerFrame):
await frame.completion.mark_played()
self.window.touch()
return
if isinstance(frame, LLMFullResponseStartFrame):
self.window.active_llm += 1
self.window.touch()
elif isinstance(frame, LLMFullResponseEndFrame):
self.window.active_llm = max(0, self.window.active_llm - 1)
self.window.touch()
await self.push_frame(frame, direction)
# There is no TTS/output transport. This releases provider/tool
# follow-up logic that normally waits for a speaking boundary.
await self.push_frame(BotStoppedSpeakingFrame(), FrameDirection.UPSTREAM)
return
elif isinstance(frame, FunctionCallInProgressFrame):
call = RawToolCall(
id=frame.tool_call_id,
function_name=frame.function_name,
arguments=frame.arguments or {},
)
self.tool_calls.append(call)
self._tool_by_id[call.id] = call
self.window.pending_tools.add(call.id)
self.window.touch()
elif isinstance(frame, FunctionCallResultFrame):
call = self._tool_by_id.get(frame.tool_call_id)
if call is None:
call = RawToolCall(
id=frame.tool_call_id,
function_name=frame.function_name,
arguments=frame.arguments or {},
)
self.tool_calls.append(call)
self._tool_by_id[call.id] = call
call.result = frame.result
call.duration_ms = max(0, round((time.monotonic() - call._started_at) * 1000))
status = frame.result.get("status") if isinstance(frame.result, dict) else None
call.outcome = "error" if status in {"error", "failed", "timeout"} else "success"
self.window.pending_tools.discard(call.id)
self.window.touch()
elif isinstance(frame, ErrorFrame):
self.error = TestExecutionError(
code="PIPELINE_ERROR",
message=frame.error or "Pipeline 执行失败",
stage="model" if frame.processor else "pipeline",
retryable=True,
)
self.window.touch()
elif isinstance(frame, (EndFrame, CancelFrame)):
self.window.touch()
await self.push_frame(frame, direction)
def _context_messages(context_turns: list[ContextTurn]) -> list[dict[str, Any]]:
messages: list[dict[str, Any]] = []
latest_call_by_name: dict[str, str] = {}
for index, turn in enumerate(context_turns):
if turn.role in {"agent", "user"}:
messages.append(
{
"role": "assistant" if turn.role == "agent" else "user",
"content": turn.content,
}
)
continue
call_id = turn.tool_call_id or latest_call_by_name.get(turn.tool_name or "")
call_id = call_id or f"context_tool_{index}_{uuid4().hex[:8]}"
if turn.role == "tool_call":
latest_call_by_name[turn.tool_name or ""] = call_id
messages.append(
{
"role": "assistant",
"tool_calls": [
{
"id": call_id,
"type": "function",
"function": {
"name": turn.tool_name,
"arguments": json.dumps(
json.loads(turn.content), ensure_ascii=False
),
},
}
],
}
)
else:
result_payload = json.loads(turn.content)
if turn.is_error:
result_payload = (
{**result_payload, "status": "error"}
if isinstance(result_payload, dict)
else {"status": "error", "data": result_payload}
)
messages.append(
{
"role": "tool",
"tool_call_id": call_id,
"content": json.dumps(result_payload, ensure_ascii=False),
}
)
return messages
async def _wait_for_quiescence(
capture: _TextCaptureProcessor,
runner_task: asyncio.Task[None],
activity_marker: int,
*,
require_activity: bool = True,
) -> None:
while True:
if capture.error is not None:
raise capture.error
if runner_task.done():
await runner_task
return
has_activity = capture.window.activity_count > activity_marker
settled = time.monotonic() - capture.window.last_activity_at >= IDLE_SETTLE_SECONDS
if capture.window.idle and settled and (has_activity or not require_activity):
return
await asyncio.sleep(0.05)
class TextPipelineRunner:
"""Execute all fixed input turns in one isolated, transportless pipeline."""
async def run(
self,
cfg: AssistantConfig,
definition: TestCaseDefinition,
*,
timeout_seconds: int,
) -> TextCaseResult:
try:
async with asyncio.timeout(timeout_seconds):
return await self._run(cfg, definition)
except TimeoutError as exc:
raise TestExecutionError(
code="PIPELINE_TIMEOUT",
message=f"测试用例执行超过 {timeout_seconds}",
stage="pipeline",
retryable=True,
) from exc
async def _run(
self,
cfg: AssistantConfig,
definition: TestCaseDefinition,
) -> TextCaseResult:
if cfg.runtimeMode != "pipeline":
raise TestExecutionError(
code="UNSUPPORTED_RUNTIME_MODE",
message="第一版文本测试只支持 Pipeline 运行模式",
stage="pipeline",
)
if cfg.type not in {"prompt", "workflow"}:
raise TestExecutionError(
code="UNSUPPORTED_ASSISTANT_TYPE",
message=f"第一版文本测试暂不支持 {cfg.type} 类型助手",
stage="pipeline",
)
brain = build_brain(cfg)
seed_messages = _context_messages(definition.context_turns)
knowledge_config = cfg.knowledge_retrieval_config or {}
knowledge_mode = str(knowledge_config.get("mode") or "automatic")
def with_knowledge_hint(text: str) -> str:
if cfg.type != "prompt" or not cfg.knowledge_base_id:
return text
hint = (
AUTOMATIC_KNOWLEDGE_HINT
if knowledge_mode == "automatic"
else ON_DEMAND_KNOWLEDGE_HINT
)
return "\n\n".join(part for part in (text, hint) if part)
system_prompt = with_knowledge_hint(brain.system_prompt(cfg))
context = LLMContext(
messages=(
seed_messages
if cfg.type == "workflow"
else [{"role": "system", "content": system_prompt}, *seed_messages]
)
)
graph_settings = cfg.graph.get("settings") or {}
default_resource = cfg.workflow_model_resources.get(
str(graph_settings.get("defaultLlmResourceId") or "")
)
llm_cfg = (
config_with_resource(cfg, default_resource)
if cfg.type == "workflow" and default_resource is not None
else cfg
)
llm = brain.build_llm(llm_cfg, context)
llm_services: dict[str, FrameProcessor] = {}
current_llm_service: FrameProcessor = llm
if cfg.type == "workflow":
llm, llm_services, current_llm_service = build_workflow_llm_switcher(cfg, llm)
aggregators = LLMContextAggregatorPair(context)
user_aggregator = aggregators.user()
assistant_aggregator = aggregators.assistant()
capture = _TextCaptureProcessor(context)
knowledge = KnowledgeRetrievalProcessor(
cfg.knowledge_base_id if knowledge_mode == "automatic" else None,
top_n=int(knowledge_config.get("top_n", knowledge_config.get("topN", 5))),
score_threshold=float(
knowledge_config.get(
"score_threshold", knowledge_config.get("scoreThreshold", 0.0)
)
),
)
pipeline = Pipeline(
[
user_aggregator,
UserTurnRoutingProcessor(brain),
knowledge,
llm,
capture,
assistant_aggregator,
]
)
worker = PipelineWorker(
pipeline,
params=PipelineParams(enable_metrics=False),
enable_rtvi=False,
enable_turn_tracking=False,
idle_timeout_secs=None,
)
runner = WorkerRunner(handle_sigint=False, check_dangling_tasks=False)
worker_started = asyncio.Event()
@worker.event_handler("on_pipeline_started")
async def on_pipeline_started(_worker, _frame):
worker_started.set()
executor_holder: dict[str, MockToolExecutor] = {}
def create_test_executor(store) -> MockToolExecutor:
executor = MockToolExecutor(store)
executor_holder["executor"] = executor
return executor
async def queue_call_end(reason: str) -> None:
logger.debug(f"文本测试收到会话结束请求: {reason}")
await worker.queue_frame(EndFrame(reason=reason))
call_end = CallEndCoordinator(queue_call_end)
async def finish_fixed_speech_if_ending() -> None:
if call_end.ending:
await call_end.finish()
capture.on_fixed_speech_complete = finish_fixed_speech_if_ending
current_service = current_llm_service
async def switch_services(
llm_resource_id: str | None,
_asr_resource_id: str | None,
_tts_resource_id: str | None,
) -> None:
nonlocal current_service
target = (
llm_services.get(llm_resource_id)
if llm_resource_id
else current_llm_service
)
if target is None:
raise ValueError(f"Workflow LLM 资源未加载:{llm_resource_id}")
if target is current_service:
return
await worker.queue_frame(ManuallySwitchServiceFrame(service=target))
current_service = target
def set_system_prompt(text: str) -> None:
messages = context.get_messages()
text = with_knowledge_hint(text)
if messages and messages[0].get("role") == "system":
messages[0] = {"role": "system", "content": text}
else:
messages.insert(0, {"role": "system", "content": text})
knowledge_schema: FunctionSchema | None = None
if (
cfg.type == "prompt"
and cfg.knowledge_base_id
and knowledge_mode == "on_demand"
):
knowledge_schema = FunctionSchema(
name="search_knowledge_base",
description=(
"在当前助手绑定的知识库中检索资料。"
f"知识库:{cfg.knowledge_base_name}"
f"{cfg.knowledge_base_description}"
),
properties={
"query": {
"type": "string",
"description": "完整问题或检索关键词",
}
},
required=["query"],
)
async def search_bound_knowledge(params: FunctionCallParams) -> None:
query = str(params.arguments.get("query") or "").strip()
if not query:
await params.result_callback(
{"status": "error", "message": "检索问题为空"}
)
return
try:
async with SessionLocal() as session:
results = await search_knowledge(
session,
cfg.knowledge_base_id or "",
query,
top_k=int(
knowledge_config.get(
"top_n",
knowledge_config.get("topN", 5),
)
),
score_threshold=float(
knowledge_config.get(
"score_threshold",
knowledge_config.get("scoreThreshold", 0.0),
)
),
)
await params.result_callback(
{"status": "ok", "results": results}
)
except Exception as exc: # noqa: BLE001 - return tool errors to LLM
logger.warning(f"文本测试知识库检索失败: {exc}")
await params.result_callback(
{"status": "error", "message": "知识库检索暂时不可用"}
)
llm.register_function("search_knowledge_base", search_bound_knowledge)
def set_tools(schemas=None) -> None:
visible = list(schemas or [])
if knowledge_schema is not None:
visible.append(knowledge_schema)
if visible:
context.set_tools(ToolsSchema(standard_tools=visible))
else:
context.set_tools()
await brain.setup(
cfg,
BrainRuntime(
context=context,
llm=llm,
queue_frame=worker.queue_frame,
set_system_prompt=set_system_prompt,
set_tools=set_tools,
call_end=call_end,
session_id=f"test_{uuid4().hex}",
client_tools=TextClientToolPort(),
worker=worker,
context_aggregator=aggregators,
transport=None,
switch_services=switch_services,
set_knowledge_scope=knowledge.set_scope,
set_vision_scope=lambda _scope: None,
vision_function=None,
set_input_enabled=lambda _enabled: None,
apply_turn_config=lambda _enabled, _config: asyncio.sleep(0),
flow_global_functions=[],
tool_executor_factory=create_test_executor,
),
)
executor = executor_holder["executor"]
@assistant_aggregator.event_handler("on_assistant_turn_started")
async def on_assistant_turn_started(_aggregator):
capture.window.active_assistant += 1
capture.window.touch()
await brain.on_assistant_text_start(uuid4().hex)
@assistant_aggregator.event_handler("on_assistant_turn_stopped")
async def on_assistant_turn_stopped(_aggregator, message):
content = str(message.content or "").strip()
capture.window.active_assistant = max(
0, capture.window.active_assistant - 1
)
if content:
capture.outputs.append(content)
capture.window.touch()
await brain.on_assistant_text_end(
uuid4().hex,
content,
bool(getattr(message, "interrupted", False)),
)
if call_end.ending:
# Text tests have no audio transport. A fully aggregated
# response is therefore the equivalent of playback ending.
await call_end.finish()
all_tool_behaviors = [
behavior
for turn in definition.turns
for behavior in turn.behaviors
if behavior.type == "tool_call"
]
executor.set_turn_behaviors(all_tool_behaviors)
runner_task: asyncio.Task[None] | None = None
try:
await brain.run_preflight()
if executor.unmocked_calls:
raise TestExecutionError(
code="UNMOCKED_TOOL_CALL",
message="启动阶段调用了未配置 Mock 的工具: "
+ "".join(executor.unmocked_calls),
stage="tool",
)
await runner.add_workers(worker)
runner_task = asyncio.create_task(
runner.run(), name=f"text-test-{uuid4().hex[:12]}"
)
await asyncio.wait_for(
worker_started.wait(), timeout=WORKER_START_TIMEOUT_SECONDS
)
marker = capture.window.activity_count
await brain.on_connected(greeting_pending=False)
await brain.on_client_ready()
await _wait_for_quiescence(
capture,
runner_task,
marker,
require_activity=False,
)
# Startup messages initialize the session but are not a scripted
# user turn result in the fixed-input editor contract.
capture.outputs.clear()
capture.tool_calls.clear()
results: list[RawTurnResult] = []
for index, turn in enumerate(definition.turns):
if worker.has_finished():
raise TestExecutionError(
code="PIPELINE_ENDED_EARLY",
message=f"Pipeline 在第 {index + 1} 轮输入前已经结束",
stage="pipeline",
retryable=False,
)
result = await self._run_turn(
worker,
runner_task,
capture,
executor,
turn,
index,
)
results.append(result)
transcript = [
{
"role": str(message.get("role") or ""),
"content": str(message.get("content") or ""),
}
for message in context.get_messages()
if message.get("role") in {"user", "assistant"}
and str(message.get("content") or "").strip()
]
return TextCaseResult(turns=results, transcript=transcript)
except TestExecutionError:
raise
except asyncio.CancelledError:
raise
except Exception as exc:
raise TestExecutionError(
code="PIPELINE_EXECUTION_FAILED",
message=str(exc) or type(exc).__name__,
stage="model",
retryable=True,
) from exc
finally:
if not worker.has_finished():
await worker.cancel(reason=INTERNAL_CANCEL_REASON)
if runner_task is not None:
await asyncio.gather(runner_task, return_exceptions=True)
async def _run_turn(
self,
worker: PipelineWorker,
runner_task: asyncio.Task[None],
capture: _TextCaptureProcessor,
executor: MockToolExecutor,
turn: FixedInputTurn,
index: int,
) -> RawTurnResult:
tool_behaviors = [
behavior for behavior in turn.behaviors if behavior.type == "tool_call"
]
executor.set_turn_behaviors(tool_behaviors)
activity_marker = capture.window.activity_count
output_marker = len(capture.outputs)
tool_marker = len(capture.tool_calls)
await worker.queue_frame(
LLMMessagesAppendFrame(
messages=[{"role": "user", "content": turn.user_input}],
run_llm=True,
)
)
await _wait_for_quiescence(capture, runner_task, activity_marker)
if executor.unmocked_calls:
raise TestExecutionError(
code="UNMOCKED_TOOL_CALL",
message="本轮调用了未配置 Mock 的工具: "
+ "".join(executor.unmocked_calls),
stage="tool",
)
return RawTurnResult(
id=turn.id,
index=index,
user_input=turn.user_input,
assistant_reply="\n\n".join(capture.outputs[output_marker:]).strip(),
tool_calls=list(capture.tool_calls[tool_marker:]),
)

View File

@@ -0,0 +1 @@
"""Global webhook delivery helpers."""

View File

@@ -0,0 +1,86 @@
"""Serialize, sign and send one webhook event."""
from __future__ import annotations
from dataclasses import dataclass
import hashlib
import hmac
import json
import time
from typing import Any
import httpx
from services.webhooks.security import validate_webhook_url
WEBHOOK_TIMEOUT_SECONDS = 10.0
@dataclass(frozen=True)
class DeliveryResult:
ok: bool
status_code: int | None
latency_ms: int
error: str = ""
def canonical_json(payload: dict[str, Any]) -> bytes:
return json.dumps(
payload,
ensure_ascii=False,
separators=(",", ":"),
sort_keys=True,
).encode("utf-8")
def webhook_signature(secret: str, timestamp: str, body: bytes) -> str:
signed = timestamp.encode("ascii") + b"." + body
digest = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
return f"v1={digest}"
def should_retry(status_code: int | None) -> bool:
return status_code is None or status_code in {408, 429} or status_code >= 500
async def deliver_webhook(
*,
url: str,
secret: str,
event_id: str,
payload: dict[str, Any],
) -> DeliveryResult:
"""Deliver without following redirects; callers decide whether to retry."""
safe_url = await validate_webhook_url(url)
body = canonical_json(payload)
timestamp = str(int(time.time()))
headers = {
"Content-Type": "application/json",
"X-AIVideo-Event-Id": event_id,
"X-AIVideo-Timestamp": timestamp,
"X-AIVideo-Signature": webhook_signature(secret, timestamp, body),
}
started = time.monotonic()
try:
async with httpx.AsyncClient(
timeout=WEBHOOK_TIMEOUT_SECONDS,
follow_redirects=False,
) as client:
response = await client.post(safe_url, content=body, headers=headers)
latency_ms = round((time.monotonic() - started) * 1000)
if 200 <= response.status_code < 300:
return DeliveryResult(True, response.status_code, latency_ms)
return DeliveryResult(
False,
response.status_code,
latency_ms,
f"目标服务返回 HTTP {response.status_code}",
)
except httpx.HTTPError as exc:
return DeliveryResult(
False,
None,
round((time.monotonic() - started) * 1000),
str(exc)[:2048],
)

View File

@@ -0,0 +1,69 @@
"""Build durable webhook events in the same transaction as domain updates."""
from __future__ import annotations
from datetime import UTC, datetime
import uuid
from typing import Any
from db.models import ConversationSession, SiteSetting, WebhookDelivery
from sqlalchemy.ext.asyncio import AsyncSession
ANALYSIS_WEBHOOK_KEY = "analysis_webhook"
ANALYSIS_COMPLETED_EVENT = "call.analysis.completed"
def analysis_completed_payload(
conversation: ConversationSession,
result: dict[str, Any],
*,
event_id: str,
timestamp: datetime,
) -> dict[str, Any]:
return {
"id": event_id,
"event": ANALYSIS_COMPLETED_EVENT,
"timestamp": timestamp.isoformat(),
"data": {
"conversationId": conversation.id,
"assistantId": conversation.assistant_id,
"assistantName": conversation.assistant_name,
"analysis": result,
},
}
async def enqueue_analysis_completed(
session: AsyncSession,
conversation: ConversationSession,
result: dict[str, Any],
) -> WebhookDelivery | None:
setting = await session.get(SiteSetting, ANALYSIS_WEBHOOK_KEY)
values = dict(setting.value or {}) if setting else {}
secrets = dict(setting.secrets or {}) if setting else {}
url = str(values.get("url") or "")
secret = str(secrets.get("secret") or "")
if not values.get("enabled") or not url or not secret:
return None
event_id = f"evt_{uuid.uuid4().hex}"
now = datetime.now(UTC)
delivery = WebhookDelivery(
id=f"dlv_{uuid.uuid4().hex}",
event_id=event_id,
event_type=ANALYSIS_COMPLETED_EVENT,
conversation_id=conversation.id,
payload=analysis_completed_payload(
conversation,
result,
event_id=event_id,
timestamp=now,
),
url=url,
secrets={"secret": secret},
status="pending",
next_attempt_at=now,
)
session.add(delivery)
return delivery

View File

@@ -0,0 +1,61 @@
"""Validate webhook destinations before every outbound request."""
from __future__ import annotations
import asyncio
import ipaddress
import socket
from urllib.parse import urlsplit
class UnsafeWebhookUrl(ValueError):
"""Raised when a URL could reach local or otherwise non-public infrastructure."""
def __init__(self, message: str, *, retryable: bool = False):
super().__init__(message)
self.retryable = retryable
def _public_ip(address: str) -> bool:
try:
return ipaddress.ip_address(address).is_global
except ValueError:
return False
async def validate_webhook_url(url: str) -> str:
"""Require HTTPS and ensure every currently resolved address is public."""
value = url.strip()
parsed = urlsplit(value)
if parsed.scheme != "https" or not parsed.hostname:
raise UnsafeWebhookUrl("Webhook URL 必须是完整的 HTTPS 地址")
if parsed.username or parsed.password:
raise UnsafeWebhookUrl("Webhook URL 不能包含用户名或密码")
try:
port = parsed.port or 443
except ValueError as exc:
raise UnsafeWebhookUrl("Webhook URL 端口无效") from exc
try:
direct_ip = ipaddress.ip_address(parsed.hostname)
except ValueError:
direct_ip = None
if direct_ip is not None:
if not direct_ip.is_global:
raise UnsafeWebhookUrl("Webhook URL 不能指向内网或本机地址")
return value
try:
records = await asyncio.to_thread(
socket.getaddrinfo,
parsed.hostname,
port,
type=socket.SOCK_STREAM,
)
except OSError as exc:
raise UnsafeWebhookUrl("Webhook 域名暂时无法解析", retryable=True) from exc
addresses = {str(record[4][0]) for record in records}
if not addresses or any(not _public_ip(address) for address in addresses):
raise UnsafeWebhookUrl("Webhook 域名解析到了内网或非公网地址")
return value

View File

@@ -0,0 +1,120 @@
"""PostgreSQL-backed webhook delivery worker with bounded retries."""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from db.models import WebhookDelivery
from db.session import SessionLocal
from loguru import logger
from services.webhooks.delivery import DeliveryResult, deliver_webhook, should_retry
from services.webhooks.security import UnsafeWebhookUrl
from sqlalchemy import or_, select, update
POLL_INTERVAL_SECONDS = 2.0
MAX_ATTEMPTS = 5
RETRY_DELAYS_SECONDS = (30, 120, 600, 3600)
@dataclass(frozen=True)
class ClaimedDelivery:
id: str
event_id: str
url: str
secret: str
payload: dict
async def recover_interrupted_webhooks() -> None:
async with SessionLocal() as session:
await session.execute(
update(WebhookDelivery)
.where(WebhookDelivery.status == "processing")
.values(status="pending", next_attempt_at=datetime.now(UTC))
)
await session.commit()
async def claim_pending_webhook() -> ClaimedDelivery | None:
now = datetime.now(UTC)
async with SessionLocal() as session:
row = (
await session.execute(
select(WebhookDelivery)
.where(
WebhookDelivery.status == "pending",
or_(
WebhookDelivery.next_attempt_at.is_(None),
WebhookDelivery.next_attempt_at <= now,
),
)
.order_by(WebhookDelivery.next_attempt_at, WebhookDelivery.created_at)
.with_for_update(skip_locked=True)
.limit(1)
)
).scalar_one_or_none()
if not row:
return None
row.status = "processing"
await session.commit()
return ClaimedDelivery(
id=row.id,
event_id=row.event_id,
url=row.url,
secret=str((row.secrets or {}).get("secret") or ""),
payload=dict(row.payload or {}),
)
async def finish_delivery(delivery_id: str, result: DeliveryResult) -> None:
async with SessionLocal() as session:
row = await session.get(WebhookDelivery, delivery_id)
if not row or row.status != "processing":
return
row.attempt_count += 1
row.last_status_code = result.status_code
row.last_error = result.error[:2048]
if result.ok:
row.status = "delivered"
row.delivered_at = datetime.now(UTC)
row.next_attempt_at = None
elif should_retry(result.status_code) and row.attempt_count < MAX_ATTEMPTS:
delay_index = min(row.attempt_count - 1, len(RETRY_DELAYS_SECONDS) - 1)
row.status = "pending"
row.next_attempt_at = datetime.now(UTC) + timedelta(
seconds=RETRY_DELAYS_SECONDS[delay_index]
)
else:
row.status = "failed"
row.next_attempt_at = None
await session.commit()
async def run_webhook_worker() -> None:
logger.info("通话分析 Webhook worker 已启动")
while True:
try:
delivery = await claim_pending_webhook()
if delivery:
try:
result = await deliver_webhook(
url=delivery.url,
secret=delivery.secret,
event_id=delivery.event_id,
payload=delivery.payload,
)
except UnsafeWebhookUrl as exc:
# DNS outages are transient; policy violations are permanent.
result = DeliveryResult(
False, None if exc.retryable else 400, 0, str(exc)
)
await finish_delivery(delivery.id, result)
continue
except asyncio.CancelledError:
raise
except Exception as exc:
logger.error(f"Webhook worker 暂时不可用:{exc}")
await asyncio.sleep(POLL_INTERVAL_SECONDS)

View File

@@ -22,7 +22,9 @@ from services.message_stage import (
MessageDisplaySpec,
MessageStageRunner,
MessageStageSpec,
confirmation_context_message,
)
from services.pipecat.call_lifecycle import playback_marker_for
from services.pipecat.realtime_tools import RealtimeTool, RealtimeToolResult
from services.runtime_variables import DynamicVariableError, DynamicVariableStore
from services.system_tools import state_update_properties, system_tool_kind
@@ -99,6 +101,18 @@ class RealtimeWorkflowOutput(WorkflowOutput):
content,
suppress_transcript=True,
)
playback_marker = playback_marker_for(playback_completion)
if playback_marker is not None:
async def finish_playback_tracking() -> None:
# The provider boundary is emitted after its final audio frame.
# Queueing the marker then places it behind that audio at output.
if provider_completion is not None:
await provider_completion
await self._runtime.queue_frame(playback_marker)
playback_marker.completion.mark_queued()
await playback_completion
return asyncio.create_task(finish_playback_tracking())
# Message playback policy and deterministic continuation must use the
# transport boundary. Provider response.done only means generation
# has finished; audio may still be buffered at the output transport.
@@ -880,7 +894,17 @@ class WorkflowRealtimeController:
node_id=node_id,
code="workflow_message_error",
)
return result.succeeded
return False
context_message = confirmation_context_message(
result,
source=f"workflow-message:{node_id}",
)
if context_message is not None:
await self._runtime.realtime.send_text(
context_message["content"],
run_immediately=False,
)
return True
async def _enter_action(self, node_id: str) -> bool:
self._state.enter(node_id, WorkflowStatus.RUNNING_ACTION)
@@ -983,12 +1007,20 @@ class WorkflowRealtimeController:
return
self._runtime.call_end.begin("workflow_completed")
if message:
self._runtime.call_end.arm_after_speech()
completion = await self._output.speak(
message,
source="workflow-end-speech",
node_id=node_id,
)
arm_tracked = getattr(
self._runtime.call_end,
"arm_after_tracked_speech",
None,
)
if callable(arm_tracked):
await arm_tracked()
else:
self._runtime.call_end.arm_after_speech()
if completion:
await completion
else:

View File

@@ -38,6 +38,14 @@ AUTH_TOKEN_EXPIRE_MINUTES = int(os.getenv("AUTH_TOKEN_EXPIRE_MINUTES", "1440"))
AUTH_COOKIE_SECURE = os.getenv("AUTH_COOKIE_SECURE", "false").lower() == "true"
AUTH_COOKIE_SAMESITE = os.getenv("AUTH_COOKIE_SAMESITE", "lax")
# ---- Public Realtime API auth ----
# Keep public integration credentials separate from the admin login cookie.
# Production deployments should set a dedicated random secret.
REALTIME_TOKEN_SECRET = os.getenv("REALTIME_TOKEN_SECRET", AUTH_SECRET_KEY)
REALTIME_CLIENT_SECRET_TTL_SECONDS = int(
os.getenv("REALTIME_CLIENT_SECRET_TTL_SECONDS", "60")
)
# ---- WebRTC STUN / TURN ----
# Override STUN_URL in remote deployments to use the colocated coturn server
# instead of waiting for a public STUN service that may be unreachable.
@@ -52,7 +60,7 @@ TURN_PASSWORD = os.getenv("TURN_PASSWORD", "")
TURN_CREDENTIAL_TTL = int(os.getenv("TURN_CREDENTIAL_TTL", "86400"))
# ---- S3-compatible object storage (RustFS in local compose) ----
S3_ENDPOINT_URL = os.getenv("S3_ENDPOINT_URL", "http://localhost:9000")
S3_ENDPOINT_URL = os.getenv("S3_ENDPOINT_URL", "http://127.0.0.1:9000")
S3_ACCESS_KEY = os.getenv("S3_ACCESS_KEY", "rustfsadmin")
S3_SECRET_KEY = os.getenv("S3_SECRET_KEY", "rustfsadmin")
S3_BUCKET = os.getenv("S3_BUCKET", "ai-video")

386
backend/test_schemas.py Normal file
View File

@@ -0,0 +1,386 @@
"""Contracts for persisted text test cases and batch execution results."""
from __future__ import annotations
import json
import re
from datetime import datetime
from typing import Annotated, Any, Literal, Union
from pydantic import Field, field_validator, model_validator
from schemas import CamelModel
TestCaseInputMode = Literal[
"fixed_script_text",
"fixed_script_turn_voice",
"fixed_script_continuous_voice",
"user_sim_text",
"user_sim_voice",
]
ContextRole = Literal["agent", "user", "tool_call", "tool_result"]
EvaluationKind = Literal["reply", "tool_call", "overall"]
EvaluationStatus = Literal["pass", "fail"]
CaseStatus = Literal["waiting", "running", "pass", "fail", "error", "skipped"]
RunStatus = Literal["queued", "running", "completed", "cancelled"]
ErrorStage = Literal["pipeline", "model", "tool", "evaluation"]
StopReason = Literal["manual", "assertion_failure", "execution_error"]
class ContextTurn(CamelModel):
role: ContextRole
content: str = Field(max_length=20_000)
tool_name: str | None = Field(default=None, max_length=128)
tool_call_id: str | None = Field(default=None, max_length=128)
is_error: bool | None = None
@model_validator(mode="after")
def validate_content(self):
self.content = self.content.strip()
self.tool_call_id = (self.tool_call_id or "").strip() or None
if not self.content:
raise ValueError("上下文内容不能为空")
if self.role in {"tool_call", "tool_result"}:
self.tool_name = (self.tool_name or "").strip()
if not self.tool_name:
raise ValueError("工具上下文必须填写工具名称")
try:
payload = json.loads(self.content)
except json.JSONDecodeError as exc:
raise ValueError("工具上下文必须是有效 JSON") from exc
if self.role == "tool_call" and not isinstance(payload, dict):
raise ValueError("Tool Call 参数必须是 JSON 对象")
return self
class ToolParamAssertion(CamelModel):
name: str = Field(min_length=1, max_length=128)
match_mode: Literal["exact", "regex", "llm"]
value: str = Field(min_length=1, max_length=4_000)
@field_validator("name", "value")
@classmethod
def strip_required_text(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("参数名称和值不能为空")
return value
@model_validator(mode="after")
def validate_regex(self):
if self.match_mode == "regex":
try:
re.compile(self.value)
except re.error as exc:
raise ValueError(f"正则表达式无效: {exc}") from exc
return self
class ToolMockResponse(CamelModel):
outcome: Literal["success", "error"] = "success"
body: str = Field(default='{"status":"ok"}', max_length=100_000)
delay_ms: int = Field(default=0, ge=0, le=60_000)
@model_validator(mode="after")
def validate_json_body(self):
if not self.body.strip():
raise ValueError("Mock 工具返回值不能为空")
try:
json.loads(self.body)
except json.JSONDecodeError as exc:
raise ValueError("Mock 工具返回值必须是有效 JSON") from exc
return self
class ReplyExpectedBehavior(CamelModel):
id: str = Field(min_length=1, max_length=128)
type: Literal["reply"] = "reply"
assertion_type: Literal["keyword", "llm"]
keywords: list[str] = Field(default_factory=list, max_length=50)
keyword_match_mode: Literal["any", "all"] = "any"
negate_keywords: bool = False
llm_criteria: str = Field(default="", max_length=4_000)
@model_validator(mode="after")
def validate_assertion(self):
self.keywords = [item.strip() for item in self.keywords if item.strip()]
self.llm_criteria = self.llm_criteria.strip()
if self.assertion_type == "keyword" and not self.keywords:
raise ValueError("关键词断言至少需要一个关键词")
if self.assertion_type == "llm" and not self.llm_criteria:
raise ValueError("LLM 判断要求不能为空")
return self
class ToolCallExpectedBehavior(CamelModel):
id: str = Field(min_length=1, max_length=128)
type: Literal["tool_call"] = "tool_call"
tool_id: str = Field(min_length=1, max_length=40)
function_name: str = Field(min_length=1, max_length=128)
expectation: Literal["called", "not_called"]
min_calls: int = Field(default=1, ge=0, le=100)
max_calls: int | None = Field(default=None, ge=0, le=100)
param_assertions: list[ToolParamAssertion] = Field(default_factory=list, max_length=50)
mock_response: ToolMockResponse = Field(default_factory=ToolMockResponse)
@model_validator(mode="after")
def validate_call_expectation(self):
self.tool_id = self.tool_id.strip()
self.function_name = self.function_name.strip()
if not self.tool_id or not self.function_name:
raise ValueError("必须选择有效的工具")
if self.expectation == "called" and self.min_calls < 1:
raise ValueError("应调用工具时最少调用次数必须大于 0")
if self.max_calls is not None and self.max_calls < self.min_calls:
raise ValueError("最多调用次数不能小于最少调用次数")
return self
ExpectedBehavior = Annotated[
Union[ReplyExpectedBehavior, ToolCallExpectedBehavior],
Field(discriminator="type"),
]
class FixedInputTurn(CamelModel):
id: str = Field(min_length=1, max_length=128)
user_input: str = Field(min_length=1, max_length=20_000)
behaviors: list[ExpectedBehavior] = Field(default_factory=list, max_length=100)
@field_validator("user_input")
@classmethod
def strip_user_input(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("用户输入不能为空")
return value
class OverallCriterion(CamelModel):
id: str = Field(min_length=1, max_length=128)
type: Literal["llm"] = "llm"
name: str = Field(min_length=1, max_length=80)
criteria: str = Field(min_length=1, max_length=2_000)
@field_validator("name", "criteria")
@classmethod
def strip_criterion(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("整体评估标准不能为空")
return value
class TestCaseDefinition(CamelModel):
context_turns: list[ContextTurn] = Field(default_factory=list, max_length=100)
turns: list[FixedInputTurn] = Field(min_length=1, max_length=100)
overall_criteria: list[OverallCriterion] = Field(default_factory=list, max_length=50)
@model_validator(mode="after")
def validate_has_expectation(self):
if not any(turn.behaviors for turn in self.turns) and not self.overall_criteria:
raise ValueError("至少配置一项有效的预期行为或整体评估标准")
return self
@model_validator(mode="after")
def validate_tool_context_pairs(self):
pending: list[tuple[str | None, str]] = []
seen_ids: set[str] = set()
for index, turn in enumerate(self.context_turns):
if turn.role == "tool_call":
call_id = (turn.tool_call_id or "").strip() or None
if call_id and call_id in seen_ids:
raise ValueError(f"上下文第 {index + 1} 条 Tool Call ID 重复")
if call_id:
seen_ids.add(call_id)
pending.append((call_id, turn.tool_name or ""))
continue
if turn.role != "tool_result":
continue
result_id = (turn.tool_call_id or "").strip() or None
matched_index = -1
for pending_index in range(len(pending) - 1, -1, -1):
call_id, tool_name = pending[pending_index]
if result_id:
matches = call_id == result_id
else:
matches = tool_name == turn.tool_name
if matches:
matched_index = pending_index
break
if matched_index < 0:
raise ValueError(
f"上下文第 {index + 1} 条 Tool Result 没有匹配的 Tool Call"
)
_call_id, tool_name = pending.pop(matched_index)
if tool_name != turn.tool_name:
raise ValueError(
f"上下文第 {index + 1} 条 Tool Result 的工具名称不匹配"
)
if pending:
names = "".join(tool_name for _call_id, tool_name in pending)
raise ValueError(f"上下文 Tool Call 缺少对应的 Tool Result{names}")
return self
class TestSuiteCreate(CamelModel):
name: str = Field(min_length=1, max_length=128)
description: str = Field(default="", max_length=2_048)
@field_validator("name")
@classmethod
def strip_name(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("测试集名称不能为空")
return value
class TestSuiteUpdate(TestSuiteCreate):
pass
class TestSuiteOut(CamelModel):
id: str
name: str
description: str
case_count: int = 0
passed_count: int = 0
run_count: int = 0
updated_at: datetime
class TestCaseWrite(CamelModel):
name: str = Field(min_length=1, max_length=128)
description: str = Field(default="", max_length=2_048)
input_mode: TestCaseInputMode = "fixed_script_text"
context_turns: list[ContextTurn] = Field(default_factory=list, max_length=100)
turns: list[FixedInputTurn] = Field(min_length=1, max_length=100)
overall_criteria: list[OverallCriterion] = Field(default_factory=list, max_length=50)
@field_validator("name")
@classmethod
def strip_name(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("测试用例名称不能为空")
return value
@model_validator(mode="after")
def validate_mvp_definition(self):
if self.input_mode != "fixed_script_text":
raise ValueError("第一版只支持固定脚本 · 文字")
TestCaseDefinition(
context_turns=self.context_turns,
turns=self.turns,
overall_criteria=self.overall_criteria,
)
return self
def definition(self) -> TestCaseDefinition:
return TestCaseDefinition(
context_turns=self.context_turns,
turns=self.turns,
overall_criteria=self.overall_criteria,
)
class TestCaseOut(TestCaseWrite):
id: str
suite_id: str
last_result: Literal["pass", "fail", "not_run"]
sort_order: int
updated_at: datetime
class TestCaseOrderIn(CamelModel):
case_ids: list[str] = Field(min_length=1, max_length=1_000)
class TestCaseBulkDeleteIn(CamelModel):
case_ids: list[str] = Field(min_length=1, max_length=1_000)
class BatchRunConfig(CamelModel):
concurrency: int = Field(default=3, ge=1, le=20)
timeout_secs: int = Field(default=60, ge=1, le=600)
failure_strategy: Literal["continue", "stop_on_fail"] = "continue"
error_retry_count: int = Field(default=0, ge=0, le=3)
error_strategy: Literal["continue", "stop_on_error"] = "continue"
class BatchRunCreate(CamelModel):
assistant_id: str = Field(min_length=1, max_length=40)
evaluator_model_resource_id: str = Field(min_length=1, max_length=40)
case_ids: list[str] = Field(min_length=1, max_length=1_000)
config: BatchRunConfig = Field(default_factory=BatchRunConfig)
title: str | None = Field(default=None, max_length=256)
@field_validator("case_ids")
@classmethod
def unique_case_ids(cls, values: list[str]) -> list[str]:
normalized = list(dict.fromkeys(value.strip() for value in values if value.strip()))
if not normalized:
raise ValueError("至少选择一个测试用例")
return normalized
class BatchExecutionError(CamelModel):
code: str
message: str
stage: ErrorStage
retryable: bool
class BatchEvaluationResult(CamelModel):
id: str
label: str
kind: EvaluationKind
status: EvaluationStatus
expected: str
actual: str
reason: str = ""
class BatchToolCallRecord(CamelModel):
id: str
function_name: str
arguments_json: str
result_json: str
outcome: Literal["success", "error"]
duration_ms: int = 0
class BatchTurnResult(CamelModel):
id: str
index: int
user_input: str
assistant_reply: str
tool_calls: list[BatchToolCallRecord] = Field(default_factory=list)
evaluations: list[BatchEvaluationResult] = Field(default_factory=list)
class BatchRunCaseOut(CamelModel):
id: str
name: str
status: CaseStatus
turns: list[BatchTurnResult] = Field(default_factory=list)
overall_criteria: list[BatchEvaluationResult] = Field(default_factory=list)
attempt_count: int
max_attempts: int
execution_error: BatchExecutionError | None = None
class BatchRunSnapshotOut(CamelModel):
id: str
status: RunStatus
title: str
assistant_name: str
config: dict[str, Any]
cases: list[BatchRunCaseOut]
started_at: datetime
finished_at: datetime | None
stop_reason: StopReason | None

View File

@@ -31,6 +31,7 @@ from services.brains.dify_llm import (
)
from services.brains.workflow_brain import ConfiguredFlowManager, WorkflowBrain
from services.fixed_speech import FIXED_SPEECH_CONTEXT_MARKER
from services.message_stage import MESSAGE_CONFIRMATION_CONTEXT_MARKER
from services.runtime_variables import prepare_dynamic_config
from services.action_runtime import ActionOutcome, ActionStatus
from services.workflow.models import (
@@ -418,6 +419,7 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
confirmation_started = asyncio.Event()
user_confirmed = asyncio.Event()
client_calls = []
context = LLMContext(messages=[])
class FakeClientTools:
async def call(self, function_name, arguments, **options):
@@ -429,7 +431,7 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
await brain.setup(
cfg,
BrainRuntime(
context=LLMContext(messages=[]),
context=context,
llm=FakeLLM(),
queue_frame=queue_frame,
set_system_prompt=lambda _prompt: None,
@@ -458,6 +460,13 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(input_states, [False])
self.assertEqual(called_tool_ids, [])
self.assertFalse(
any(
MESSAGE_CONFIRMATION_CONTEXT_MARKER
in str(message.get("content") or "")
for message in context.get_messages()
)
)
self.assertEqual(client_calls[0][0], "show_message")
self.assertFalse(client_calls[0][1]["dismissible"])
self.assertTrue(client_calls[0][2]["interrupt_on_result"])
@@ -481,6 +490,17 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
await opening_task
self.assertEqual(input_states, [False, True])
self.assertEqual(called_tool_ids, ["opening_data"])
confirmation_messages = [
message
for message in context.get_messages()
if MESSAGE_CONFIRMATION_CONTEXT_MARKER
in str(message.get("content") or "")
]
self.assertEqual(len(confirmation_messages), 1)
self.assertEqual(confirmation_messages[0]["role"], "user")
self.assertIn("点击“确认”", confirmation_messages[0]["content"])
self.assertIn("弹窗标题:重要提示", confirmation_messages[0]["content"])
self.assertIn("弹窗内容:请确认已阅读。", confirmation_messages[0]["content"])
self.assertEqual(
sum(isinstance(frame, LLMRunFrame) for frame in queued),
1,
@@ -500,6 +520,14 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
# Replayed client-ready must not execute startup actions twice.
await brain.on_client_ready()
self.assertEqual(brain._actions.execute.await_count, 1)
self.assertEqual(
sum(
MESSAGE_CONFIRMATION_CONTEXT_MARKER
in str(message.get("content") or "")
for message in context.get_messages()
),
1,
)
self.assertEqual(
sum(isinstance(frame, LLMRunFrame) for frame in queued),
1,
@@ -2277,10 +2305,106 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
result = await message_task
self.assertTrue(result.succeeded)
self.assertEqual(result.action, "confirmed")
self.assertIsNotNone(result.confirmation)
self.assertEqual(result.confirmation.title, "重要提示")
self.assertEqual(result.confirmation.message, "请核对客户信息。")
self.assertEqual(result.confirmation.confirm_label, "确认")
self.assertFalse(call_end.playback_completion.done())
self.assertEqual(input_states, [False, True])
self.assertEqual(events[-1], "message_completed")
async def test_confirmation_message_is_forwarded_to_next_agent_context(self):
graph = {
"specVersion": 3,
"settings": {},
"nodes": [
{"id": "start", "type": "start", "data": {}},
{
"id": "message",
"type": "message",
"data": {
"title": "办理提示",
"message": "确认后开始办理。",
"confirmLabel": "继续办理",
"completionPolicy": "confirmation",
},
},
{
"id": "agent",
"type": "agent",
"data": {"prompt": "收集事故信息"},
},
],
"edges": [
{
"id": "start-message",
"source": "start",
"target": "message",
"data": {"mode": "always"},
},
{
"id": "message-agent",
"source": "message",
"target": "agent",
"data": {"mode": "always"},
},
],
}
brain = WorkflowBrain(graph)
class FakeManager:
def __init__(self):
self.current_node = None
self.configs = []
async def initialize(self, config):
self.current_node = config["name"]
self.configs.append(config)
async def set_node_from_config(self, config):
self.current_node = config["name"]
self.configs.append(config)
class FakeClientTools:
async def call(self, *_args, **_kwargs):
return {"status": "ok", "data": {"action": "confirmed"}}
manager = FakeManager()
client_tools = FakeClientTools()
brain._runtime = BrainRuntime(
context=LLMContext(messages=[]),
llm=FakeLLM(),
queue_frame=noop_queue_frame,
set_system_prompt=lambda _prompt: None,
set_tools=lambda _tools: None,
call_end=FakeCallEnd(),
client_tools=client_tools,
set_input_enabled=lambda _enabled: None,
)
brain._manager = manager
brain._message_stages.set_client_tools(client_tools)
await brain.on_connected()
for _ in range(10):
await asyncio.sleep(0)
if manager.current_node == "agent":
break
self.assertEqual(manager.current_node, "agent")
confirmation_messages = [
message
for message in manager.configs[-1]["task_messages"]
if MESSAGE_CONFIRMATION_CONTEXT_MARKER
in str(message.get("content") or "")
]
self.assertEqual(len(confirmation_messages), 1)
self.assertEqual(confirmation_messages[0]["role"], "user")
self.assertIn("点击“继续办理”", confirmation_messages[0]["content"])
self.assertIn(
"事件来源workflow-message:message",
confirmation_messages[0]["content"],
)
async def test_speech_only_message_waits_for_transport_playback(self):
brain = WorkflowBrain(
{

View File

@@ -3,8 +3,15 @@ from __future__ import annotations
import asyncio
import unittest
from pipecat.frames.frames import BotStartedSpeakingFrame, BotStoppedSpeakingFrame
from services.pipecat.call_lifecycle import CallEndCoordinator
from pipecat.frames.frames import (
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
InterruptionFrame,
)
from services.pipecat.call_lifecycle import (
CallEndCoordinator,
playback_marker_for,
)
class CallEndCoordinatorTest(unittest.IsolatedAsyncioTestCase):
@@ -65,17 +72,68 @@ class CallEndCoordinatorTest(unittest.IsolatedAsyncioTestCase):
self.coordinator.begin("workflow_completed")
await self.coordinator.arm_after_tracked_speech()
await self.coordinator.observe(BotStartedSpeakingFrame())
await self.coordinator.observe(BotStoppedSpeakingFrame())
first_marker = playback_marker_for(first_completion)
second_marker = playback_marker_for(second_completion)
self.assertIsNotNone(first_marker)
self.assertIsNotNone(second_marker)
await first_marker.completion.mark_played()
self.assertTrue(first_completion.done())
self.assertFalse(second_completion.done())
self.assertEqual(self.reasons, [])
await self.coordinator.observe(BotStartedSpeakingFrame())
await self.coordinator.observe(BotStoppedSpeakingFrame())
await second_marker.completion.mark_played()
self.assertTrue(second_completion.done())
self.assertEqual(self.reasons, ["workflow_completed"])
async def test_previous_speech_stop_does_not_complete_fixed_speech(self):
await self.coordinator.observe(BotStartedSpeakingFrame())
completion = self.coordinator.track_speech()
marker = playback_marker_for(completion)
self.coordinator.begin("workflow_completed")
await self.coordinator.arm_after_tracked_speech()
await self.coordinator.observe(BotStoppedSpeakingFrame())
self.assertFalse(completion.done())
self.assertEqual(self.reasons, [])
await marker.completion.mark_played()
self.assertEqual(self.reasons, ["workflow_completed"])
async def test_delayed_previous_speech_boundary_cannot_claim_marker(self):
completion = self.coordinator.track_speech()
marker = playback_marker_for(completion)
self.coordinator.begin("workflow_completed")
await self.coordinator.arm_after_tracked_speech()
await self.coordinator.observe(BotStartedSpeakingFrame())
await self.coordinator.observe(BotStoppedSpeakingFrame())
self.assertFalse(completion.done())
self.assertEqual(self.reasons, [])
await marker.completion.mark_played()
self.assertEqual(self.reasons, ["workflow_completed"])
async def test_interruption_completes_marker_already_in_output_queue(self):
completion = self.coordinator.track_speech()
marker = playback_marker_for(completion)
marker.completion.mark_queued()
await self.coordinator.observe(InterruptionFrame())
self.assertTrue(completion.done())
async def test_interruption_does_not_complete_marker_not_yet_queued(self):
completion = self.coordinator.track_speech()
marker = playback_marker_for(completion)
await self.coordinator.observe(InterruptionFrame())
self.assertFalse(completion.done())
marker.completion.mark_queued()
await marker.completion.mark_played()
self.assertTrue(completion.done())
if __name__ == "__main__":
unittest.main()

View File

@@ -9,6 +9,45 @@ from services.conversation_history import ConversationRecorder
class ConversationRecorderTest(unittest.IsolatedAsyncioTestCase):
async def test_completed_conversation_queues_enabled_analysis(self):
conversation = SimpleNamespace(
status="active",
ended_at=None,
message_count=0,
analysis_status="none",
analysis_data={},
analysis_error="",
)
class FakeSession:
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return None
async def get(self, _model, _session_id):
return conversation
async def commit(self):
return None
plan = {
"enabled": True,
"model_resource_id": "model_001",
"fields": [{"name": "customer_name", "type": "string"}],
}
recorder = ConversationRecorder("conv_test", plan)
recorder._sequence = 1
with patch(
"services.conversation_history.SessionLocal",
return_value=FakeSession(),
):
await recorder._finish(status="completed")
self.assertEqual(conversation.analysis_status, "pending")
self.assertEqual(conversation.analysis_data, {"plan": plan})
async def test_finish_waits_for_database_cleanup_when_cancelled(self):
recorder = ConversationRecorder("conv_test")
started = asyncio.Event()
@@ -122,6 +161,9 @@ class ConversationRecorderTest(unittest.IsolatedAsyncioTestCase):
def add(self, value):
self.added.append(value)
async def flush(self):
return None
async def get(self, _model, _session_id):
return conversation

View File

@@ -0,0 +1,203 @@
from __future__ import annotations
import asyncio
import unittest
from types import SimpleNamespace
from models import AssistantConfig
from pipecat.frames.frames import (
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
TTSSpeakFrame,
)
import services.brains # Initialize the brain registry before realtime imports.
from services.fixed_speech import FixedSpeechOutput
from services.message_stage import MESSAGE_CONFIRMATION_CONTEXT_MARKER
from services.pipecat.call_lifecycle import (
CallEndCoordinator,
FixedSpeechPlaybackMarkerFrame,
)
from services.pipecat.transports import build_ws_transport
from services.runtime_variables import DynamicVariableStore
from services.workflow.realtime import WorkflowRealtimeController
from services.workflow_engine import WorkflowEngine
class FixedSpeechPlaybackTest(unittest.IsolatedAsyncioTestCase):
async def test_pipeline_speech_queues_marker_immediately_after_tts(self):
queued = []
async def queue_end(_reason: str) -> None:
pass
async def queue_frame(frame) -> None:
queued.append(frame)
call_end = CallEndCoordinator(queue_end)
output = FixedSpeechOutput(
DynamicVariableStore({}),
SimpleNamespace(call_end=call_end, queue_frame=queue_frame),
)
completion = await output.speak(
"固定结束语",
source="test",
record_history=False,
)
self.assertIsInstance(queued[0], TTSSpeakFrame)
self.assertIsInstance(queued[1], FixedSpeechPlaybackMarkerFrame)
self.assertIs(queued[1].completion, completion)
async def test_websocket_output_resolves_marker(self):
async def queue_end(_reason: str) -> None:
pass
call_end = CallEndCoordinator(queue_end)
completion = call_end.track_speech()
marker = FixedSpeechPlaybackMarkerFrame(completion=completion)
websocket = SimpleNamespace(headers={})
output = build_ws_transport(websocket).output()
await output.write_transport_frame(marker)
self.assertTrue(completion.done())
async def test_realtime_end_ignores_unrelated_speech_boundaries(self):
graph = {
"specVersion": 3,
"settings": {},
"nodes": [
{"id": "start", "type": "start", "data": {}},
{
"id": "end",
"type": "end",
"data": {"message": "感谢来电,再见。", "scope": "session"},
},
],
"edges": [],
}
reasons = []
queued = []
async def queue_end(reason: str) -> None:
reasons.append(reason)
async def queue_frame(frame) -> None:
queued.append(frame)
class FakeRealtime:
def __init__(self):
self.provider_completion = None
async def update_session(self, _instructions, _tools):
pass
async def speak_fixed(self, _text, *, suppress_transcript=True):
self.provider_completion = (
asyncio.get_running_loop().create_future()
)
return self.provider_completion
call_end = CallEndCoordinator(queue_end)
realtime = FakeRealtime()
controller = WorkflowRealtimeController(
cfg=AssistantConfig(type="workflow", graph=graph),
engine=WorkflowEngine(graph),
store=DynamicVariableStore({}),
runtime=SimpleNamespace(
realtime=realtime,
queue_frame=queue_frame,
call_end=call_end,
session_id="test-session",
client_tools=None,
set_input_enabled=lambda _enabled: None,
capture_image=None,
),
)
end_task = asyncio.create_task(controller._enter_end("end"))
while realtime.provider_completion is None:
await asyncio.sleep(0)
await call_end.observe(BotStartedSpeakingFrame())
await call_end.observe(BotStoppedSpeakingFrame())
self.assertEqual(reasons, [])
self.assertFalse(end_task.done())
realtime.provider_completion.set_result(None)
marker = None
while marker is None:
await asyncio.sleep(0)
marker = next(
(
frame
for frame in queued
if isinstance(frame, FixedSpeechPlaybackMarkerFrame)
),
None,
)
await marker.completion.mark_played()
await end_task
self.assertEqual(reasons, ["workflow_completed"])
async def test_realtime_confirmation_is_appended_without_running_model(self):
graph = {
"specVersion": 3,
"settings": {},
"nodes": [
{
"id": "message",
"type": "message",
"data": {
"title": "办理提示",
"message": "确认后开始办理。",
"confirmLabel": "继续办理",
"completionPolicy": "confirmation",
},
}
],
"edges": [],
}
appended = []
input_states = []
class FakeRealtime:
async def send_text(self, text, *, run_immediately=True):
appended.append((text, run_immediately))
class FakeClientTools:
async def call(self, *_args, **_kwargs):
return {"status": "ok", "data": {"action": "confirmed"}}
async def queue_frame(_frame):
pass
controller = WorkflowRealtimeController(
cfg=AssistantConfig(type="workflow", graph=graph),
engine=WorkflowEngine(graph),
store=DynamicVariableStore({}),
runtime=SimpleNamespace(
realtime=FakeRealtime(),
queue_frame=queue_frame,
call_end=SimpleNamespace(ending=False),
session_id="test-session",
client_tools=FakeClientTools(),
set_input_enabled=input_states.append,
capture_image=None,
),
)
succeeded = await controller._enter_message("message")
self.assertTrue(succeeded)
self.assertEqual(input_states, [False, True])
self.assertEqual(len(appended), 1)
self.assertFalse(appended[0][1])
self.assertIn(MESSAGE_CONFIRMATION_CONTEXT_MARKER, appended[0][0])
self.assertIn("点击“继续办理”", appended[0][0])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,81 @@
from __future__ import annotations
import unittest
from pydantic import ValidationError
from schemas import AnalysisConfig
from services.post_call.analyzer import (
_json_schema,
_parse_json_content,
_validated_result,
)
FIELDS = [
{
"id": "intent",
"name": "customer_intent",
"type": "enum",
"description": "客户意向",
"enum_values": ["high", "low"],
},
{
"id": "follow_up",
"name": "need_follow_up",
"type": "boolean",
"description": "是否需要跟进",
"enum_values": [],
},
]
class AnalysisConfigTest(unittest.TestCase):
def test_enabled_analysis_requires_model_and_fields(self):
with self.assertRaises(ValidationError):
AnalysisConfig(enabled=True)
def test_field_names_must_be_unique(self):
field = {
"id": "one",
"name": "customer_name",
"type": "string",
"description": "客户姓名",
}
with self.assertRaises(ValidationError):
AnalysisConfig(
enabled=True,
model_resource_id="model_001",
fields=[field, {**field, "id": "two"}],
)
class StructuredExtractionTest(unittest.TestCase):
def test_schema_marks_every_field_nullable_and_required(self):
schema = _json_schema(FIELDS)
self.assertEqual(
schema["required"], ["customer_intent", "need_follow_up"]
)
self.assertEqual(
schema["properties"]["customer_intent"]["enum"],
["high", "low", None],
)
def test_invalid_values_are_normalized_to_null(self):
result = _validated_result(
{"customer_intent": "medium", "need_follow_up": "yes"},
FIELDS,
)
self.assertEqual(
result,
{"customer_intent": None, "need_follow_up": None},
)
def test_json_parser_accepts_fenced_model_output(self):
self.assertEqual(
_parse_json_content('```json\n{"need_follow_up": true}\n```'),
{"need_follow_up": True},
)
if __name__ == "__main__":
unittest.main()

View File

@@ -4,13 +4,61 @@ import unittest
from unittest.mock import patch
from models import AssistantConfig
from pipecat.processors.aggregators.llm_context import LLMContext
from services.pipecat.service_factory import (
HTTP_TTS_STOP_FRAME_TIMEOUT_S,
WEBSOCKET_TTS_STOP_FRAME_TIMEOUT_S,
create_llm,
create_tts,
)
class LLMServiceFactoryTest(unittest.TestCase):
def test_openai_compatible_llm_converts_async_tool_developer_result(self):
service = create_llm(
AssistantConfig(
llm_interface_type="openai-llm",
model="deepseek-chat",
llm_api_key="test-key",
llm_base_url="https://llm.example.test/v1",
)
)
context = LLMContext(
messages=[
{"role": "system", "content": "你是助手"},
{
"role": "developer",
"content": '{"type":"async_tool","status":"finished"}',
},
{"role": "user", "content": "ok"},
]
)
params = service.get_llm_adapter().get_llm_invocation_params(
context,
convert_developer_to_user=not service.supports_developer_role,
)
self.assertEqual(
[message["role"] for message in params["messages"]],
["system", "user", "user"],
)
self.assertNotIn("developer", str(params["messages"]))
def test_provider_can_explicitly_enable_developer_role(self):
service = create_llm(
AssistantConfig(
llm_interface_type="openai-llm",
model="gpt-compatible",
llm_api_key="test-key",
llm_base_url="https://llm.example.test/v1",
llm_values={"supportsDeveloperRole": True},
)
)
self.assertTrue(service.supports_developer_role)
class TTSServiceFactoryTest(unittest.TestCase):
def test_http_tts_keeps_wider_audio_chunk_timeout(self):
config = AssistantConfig(

View File

@@ -8,6 +8,7 @@ from pipecat.frames.frames import (
FunctionCallResultFrame,
FunctionCallsStartedFrame,
)
from pipecat.services.llm_service import FunctionCallResultProperties
from services.pipecat.processors import ToolInterruptionUserMuteStrategy
from services.tool_policy import policy_for_tool
@@ -143,6 +144,106 @@ class ToolInterruptionStrategyTests(unittest.IsolatedAsyncioTestCase):
await strategy.process_frame(BotStoppedSpeakingFrame())
)
async def test_async_tool_only_result_releases_without_followup_speech(self):
strategy = ToolInterruptionUserMuteStrategy(
{"set_photo_button_visible": "async"}
)
await strategy.process_frame(
FunctionCallsStartedFrame(
function_calls=[
SimpleNamespace(
function_name="set_photo_button_visible",
tool_call_id="call_4",
)
]
)
)
self.assertFalse(
await strategy.process_frame(
FunctionCallResultFrame(
function_name="set_photo_button_visible",
tool_call_id="call_4",
arguments={"visible": True},
result={"status": "ok"},
properties=FunctionCallResultProperties(run_llm=False),
)
)
)
self.assertFalse(strategy.is_muted)
async def test_async_tool_only_result_waits_for_active_preamble(self):
strategy = ToolInterruptionUserMuteStrategy(
{"set_photo_button_visible": "async"}
)
await strategy.process_frame(BotStartedSpeakingFrame())
await strategy.process_frame(
FunctionCallsStartedFrame(
function_calls=[
SimpleNamespace(
function_name="set_photo_button_visible",
tool_call_id="call_5",
)
]
)
)
self.assertTrue(
await strategy.process_frame(
FunctionCallResultFrame(
function_name="set_photo_button_visible",
tool_call_id="call_5",
arguments={"visible": True},
result={"status": "ok"},
properties=FunctionCallResultProperties(run_llm=False),
)
)
)
self.assertFalse(
await strategy.process_frame(BotStoppedSpeakingFrame())
)
async def test_async_intermediate_result_does_not_release_mute(self):
strategy = ToolInterruptionUserMuteStrategy(
{"set_photo_button_visible": "async"}
)
await strategy.process_frame(
FunctionCallsStartedFrame(
function_calls=[
SimpleNamespace(
function_name="set_photo_button_visible",
tool_call_id="call_6",
)
]
)
)
self.assertTrue(
await strategy.process_frame(
FunctionCallResultFrame(
function_name="set_photo_button_visible",
tool_call_id="call_6",
arguments={"visible": True},
result={"status": "pending"},
properties=FunctionCallResultProperties(
run_llm=False,
is_final=False,
),
)
)
)
self.assertFalse(
await strategy.process_frame(
FunctionCallResultFrame(
function_name="set_photo_button_visible",
tool_call_id="call_6",
arguments={"visible": True},
result={"status": "ok"},
properties=FunctionCallResultProperties(run_llm=False),
)
)
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,82 @@
from __future__ import annotations
import hashlib
import hmac
from types import SimpleNamespace
import unittest
from unittest.mock import patch
from services.webhooks.delivery import (
canonical_json,
should_retry,
webhook_signature,
)
from services.webhooks.events import analysis_completed_payload
from services.webhooks.security import UnsafeWebhookUrl, validate_webhook_url
class WebhookSecurityTest(unittest.IsolatedAsyncioTestCase):
async def test_requires_https(self):
with self.assertRaises(UnsafeWebhookUrl):
await validate_webhook_url("http://hooks.example.com/events")
async def test_rejects_loopback_ip(self):
with self.assertRaises(UnsafeWebhookUrl):
await validate_webhook_url("https://127.0.0.1/events")
async def test_rejects_hostname_resolving_to_private_ip(self):
records = [(2, 1, 6, "", ("10.0.0.8", 443))]
with patch("services.webhooks.security.socket.getaddrinfo", return_value=records):
with self.assertRaises(UnsafeWebhookUrl):
await validate_webhook_url("https://hooks.example.com/events")
async def test_accepts_hostname_resolving_to_public_ip(self):
records = [(2, 1, 6, "", ("8.8.8.8", 443))]
with patch("services.webhooks.security.socket.getaddrinfo", return_value=records):
result = await validate_webhook_url("https://hooks.example.com/events")
self.assertEqual(result, "https://hooks.example.com/events")
class WebhookDeliveryTest(unittest.TestCase):
def test_signature_uses_timestamp_dot_raw_body(self):
body = canonical_json({"b": 2, "a": "中文"})
expected = hmac.new(
b"secret",
b"1700000000." + body,
hashlib.sha256,
).hexdigest()
self.assertEqual(
webhook_signature("secret", "1700000000", body),
f"v1={expected}",
)
def test_only_transient_responses_are_retried(self):
for status in (None, 408, 429, 500, 503):
self.assertTrue(should_retry(status))
for status in (200, 301, 400, 401, 404):
self.assertFalse(should_retry(status))
def test_analysis_event_has_stable_envelope(self):
conversation = SimpleNamespace(
id="conv_001",
assistant_id="asst_001",
assistant_name="销售助手",
)
from datetime import UTC, datetime
payload = analysis_completed_payload(
conversation, # type: ignore[arg-type]
{"customer_intent": "high"},
event_id="evt_001",
timestamp=datetime(2026, 8, 7, tzinfo=UTC),
)
self.assertEqual(payload["id"], "evt_001")
self.assertEqual(payload["event"], "call.analysis.completed")
self.assertEqual(payload["data"]["conversationId"], "conv_001")
self.assertEqual(
payload["data"]["analysis"], {"customer_intent": "high"}
)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://mintlify.com/docs.json",
"name": "AI 视频助手",
"name": "AI 视频助手 · 开发者文档",
"theme": "mint",
"colors": {
"primary": "#1b2741",
@@ -45,12 +45,8 @@
"navigation": {
"groups": [
{
"group": "开始使用",
"pages": ["index", "quickstart"]
},
{
"group": "产品与对接",
"pages": ["features", "integrations"]
"group": "开始与对接",
"pages": ["index", "integrations", "realtime-protocol"]
}
]
}

View File

@@ -40,6 +40,10 @@ make db-seed
保存后,从助手详情页打开语音预览即可测试。
## 4. 对接 Realtime 协议
- [Realtime 兼容协议设计](/realtime-protocol):了解后端提供的 OpenAI Realtime 兼容 WebRTC、WebSocket、鉴权与 Interactive Media 扩展协议。
<Note>
浏览器麦克风仅在 localhost 或 HTTPS 下可用。局域网、远程环境请按仓库中的 `deploy/README.md` 配置 HTTPS 与 TURN。
</Note>

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,693 @@
---
title: Realtime 兼容协议设计
description: 面向集成方的 OpenAI Realtime 兼容 WebRTC、WebSocket 与扩展事件协议。
icon: radio
---
# Realtime 兼容协议设计
> 当前实现版本为 `Interactive Media Realtime Extensions v1`。OpenAI 兼容接口与现有 `/api/webrtc/offer`、`/ws/voice`、`/ws/stream`、RTVI 消息并行提供RTVI 不是待删除的迁移接口。
## 设计目标
AI 视频助手以 OpenAI Realtime 协议作为对外接口,同时保留助手、知识库、工具、工作流、动态变量和视频输入等现有能力。
协议适配层只负责连接、事件和 Pipecat Frame 之间的转换,不引入第二套语音运行时:
```text
OpenAI Realtime 客户端
│ WebRTC / WebSocket
Realtime 协议适配层
│ Pipecat transport / frame
现有 run_pipeline()
├── Prompt / Workflow / External Agent
├── ASR / LLM / TTS / Realtime Model
├── Knowledge / System、HTTP、Client、MCP Tools
└── History / Handoff / End Call
```
协议分为两层:
| 层级 | 约束 | 面向对象 |
| --- | --- | --- |
| OpenAI Realtime Core | 保持标准事件名称、字段含义和生命周期,不改变标准事件语义 | 任意 OpenAI Realtime 兼容客户端 |
| `x.interactive_media.*` Extensions | 所有项目扩展均有命名空间和能力协商 | 了解 Interactive Media 扩展的客户端 |
普通兼容客户端不声明扩展能力时,服务端不会发送 `x.interactive_media.*` 事件。它仍可使用音频、文本、图片、转写、回复流、打断和函数调用,但不会获得工作流节点、动态变量、连续视频等项目专属状态。
## 接口概览
假设服务地址为 `https://api.example.com`
| 接口 | 用途 | 推荐客户端 |
| --- | --- | --- |
| `POST /v1/realtime/client_secrets` | 创建短期、绑定助手的临时令牌 | 浏览器或移动端 |
| `POST /v1/realtime/calls` | 用 SDP Offer 创建 WebRTC 会话并返回 SDP Answer | 浏览器、移动端 |
| `wss://api.example.com/v1/realtime?model=assistant:<id>` | 创建 WebSocket Realtime 会话 | 服务端、话务网关、自定义客户端 |
`model` 使用 `assistant:<id>`,其中 `id` 是当前数据库中的助手 ID例如
```text
assistant:asst_xxx
```
这里的 `model` 代表一个完整助手,而不是直接选择底层模型。助手绑定的系统提示词、模型凭证、知识库、服务端工具、工作流和运行模式仍由服务端控制。
### 鉴权
- 长期 API Key 仅供可信服务端使用,通过 `Authorization: Bearer <api-key>` 发送。
- 浏览器先由自己的业务后端创建临时令牌,再用临时令牌连接 Realtime 接口。
- 临时令牌绑定助手和会话配置,默认有效期为 60 秒;首版不保证单次使用,令牌只用于建连,建连后到期不会中止会话。
- 父长期 Key 被撤销或过期后,尚未使用的临时令牌立即失效。
- 管理后台 Cookie 不属于公开兼容协议,也不应作为第三方客户端的鉴权方式。
- 会话和事件不会返回底层模型密钥、助手系统提示词或工具密钥。
### 长期 API Key 管理
Realtime Key 存在数据库中,每个 Key 默认可访问当前及未来创建的全部助手。管理接口仅允许后台管理员 Cookie 或 Basic Auth 调用:
| 接口 | 行为 |
| --- | --- |
| `POST /api/realtime/api-keys` | 创建 Key完整 `sk-rt-...` 只在本次响应显示一次 |
| `GET /api/realtime/api-keys` | 查看名称、前缀、有效期、最后使用时间和状态,不返回密钥 |
| `DELETE /api/realtime/api-keys/{id}` | 逻辑撤销并保留审计记录 |
轮换方式为先创建新 Key再撤销旧 Key。服务端只保存使用部署级 pepper 计算的 HMAC-SHA256不保存可恢复的 Key 明文。
## 会话配置
首次建连配置以及后续 `session.update` 使用标准 Realtime session 结构:
```json
{
"type": "realtime",
"model": "assistant:asst_xxx",
"output_modalities": ["audio"],
"audio": {
"input": {
"format": {
"type": "audio/pcm",
"rate": 24000
},
"turn_detection": {
"type": "server_vad"
}
},
"output": {
"format": {
"type": "audio/pcm"
}
}
}
}
```
客户端可修改输出模态,以及输入端 Server VAD / Push-to-Talk 参数。服务端在 `session.updated` 中返回**实际生效**的配置。
以下内容始终以服务端助手配置为准:
- 助手身份和基础系统提示词;
- Pipeline 或 Realtime 运行模式;
- 底层模型资源及凭证;
- 知识库和服务端 System、HTTP、MCP 工具;
- 工作流图、结束条件和接管策略。
- voice、工具定义、系统提示词和每次回复的临时指令。
服务端不能静默接受一个实际未生效的字段。字段不支持、被锁定或没有权限时,应返回标准 `error` 事件。
## WebRTC
WebRTC 是浏览器和移动端实时音视频交互的首选通道。音频和可选视频走媒体轨道JSON 事件走 DataChannel。
### 建连方式一:统一接口
可信业务后端向 `/v1/realtime/calls` 发送 `multipart/form-data`
| 表单字段 | 类型 | 说明 |
| --- | --- | --- |
| `sdp` | string | 浏览器生成的 SDP Offer |
| `session` | JSON string | Realtime session 配置,必须选择一个已授权助手 |
请求使用长期 API Key 鉴权。成功响应为 `Content-Type: application/sdp` 的 SDP Answer。
```bash
curl -X POST "https://api.example.com/v1/realtime/calls" \
-H "Authorization: Bearer $REALTIME_API_KEY" \
-F 'sdp=<offer.sdp' \
-F 'session={"type":"realtime","model":"assistant:asst_xxx"}'
```
业务浏览器不应直接持有示例中的长期 API Key。它通常把 Offer 发给自己的业务后端,由后端完成上述调用。
### 建连方式二:临时令牌
1. 可信业务后端使用长期 API Key 调用 `/v1/realtime/client_secrets`。
2. 服务端返回绑定助手的短期令牌。
3. 浏览器创建 `RTCPeerConnection`、音频轨道和 `oai-events` DataChannel。
4. 浏览器以临时令牌调用 `/v1/realtime/calls`,请求体为原始 SDP Offer类型为 `application/sdp`。
5. 浏览器把响应中的 SDP Answer 设置为远端描述。
创建令牌的请求示例:
```json
{
"session": {
"type": "realtime",
"model": "assistant:asst_xxx",
"output_modalities": ["audio"]
}
}
```
浏览器侧的核心连接过程如下:
```js
const pc = new RTCPeerConnection({ iceServers });
const remoteAudio = new Audio();
remoteAudio.autoplay = true;
pc.ontrack = (event) => {
if (event.track.kind === "audio") {
remoteAudio.srcObject = event.streams[0];
}
};
const localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
pc.addTrack(localStream.getAudioTracks()[0], localStream);
const events = pc.createDataChannel("oai-events");
events.onmessage = (event) => {
const serverEvent = JSON.parse(event.data);
console.log(serverEvent);
};
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const answerSdp = await fetch("https://api.example.com/v1/realtime/calls", {
method: "POST",
headers: {
Authorization: `Bearer ${ephemeralToken}`,
"Content-Type": "application/sdp"
},
body: offer.sdp
}).then((response) => response.text());
await pc.setRemoteDescription({ type: "answer", sdp: answerSdp });
```
### WebRTC 数据分工
| 数据 | 通道 | 说明 |
| --- | --- | --- |
| 用户麦克风 | WebRTC audio track | 不再通过 JSON 重复发送音频块 |
| 助手语音 | WebRTC remote audio track | 浏览器直接播放,不依赖 `response.output_audio.delta` 拼接 |
| 标准和扩展事件 | `oai-events` DataChannel | 使用 UTF-8 JSON 文本,保持可靠、有序传输 |
| 用户摄像头 | 可选 WebRTC video track | 通过 `video_track` capability 协商,非 OpenAI 标准能力 |
摄像头轨道只在助手启用视觉、令牌允许且 SDP 协商成功时接收。普通 OpenAI 兼容客户端不添加视频轨道,仍可正常完成语音和文本会话。
切换摄像头、静音、音量控制和 `RTCPeerConnection.getStats()` 属于客户端 WebRTC 能力,不需要新增协议事件。切换设备时可使用 `RTCRtpSender.replaceTrack()`,无需重建会话。
## WebSocket
WebSocket 适合服务到服务、话务网关和希望自行处理音频缓冲的客户端。连接时在 query 中指定助手:
```text
wss://api.example.com/v1/realtime?model=assistant%3Aasst_xxx
```
可信服务端在握手请求中发送:
```http
Authorization: Bearer <api-key-or-ephemeral-token>
```
浏览器无法设置 `Authorization` 握手头时,可同时发送 `realtime` 与 `openai-insecure-api-key.<token>` WebSocket 子协议;服务端选择 `realtime`。这只适用于短期令牌,不应把长期 Key 放入浏览器。
连接成功后,客户端和服务端都只发送 UTF-8 JSON 文本帧。浏览器无法安全保存长期 API Key浏览器实时通话仍应优先使用 WebRTC。
### 文本输入
先创建用户消息,再请求生成回复:
```json
{
"event_id": "evt_text_001",
"type": "conversation.item.create",
"item": {
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": "请介绍办理流程"
}
]
}
}
```
```json
{
"event_id": "evt_response_001",
"type": "response.create"
}
```
### 音频输入和输出
WebSocket 音频以 Base64 编码放入标准事件:
```json
{
"event_id": "evt_audio_001",
"type": "input_audio_buffer.append",
"audio": "<base64-pcm-bytes>"
}
```
- 开启 Server VAD 时,服务端自动产生 `speech_started`、`speech_stopped` 并触发回复。
- 关闭 VAD 时,客户端发送 `input_audio_buffer.commit`,然后发送 `response.create`。
- 服务端通过 `response.output_audio.delta` 返回 Base64 音频块,并以 `response.output_audio.done` 结束。
- 助手文本或音频转写分别通过 `response.output_text.*` 和 `response.output_audio_transcript.*` 返回。
WebSocket MVP 不传输连续原始视频。视觉输入使用标准 `input_image`;需要连续摄像头的客户端使用 WebRTC 视频轨道。
### 图片输入
图片和文本可放在同一个用户消息中:
```json
{
"type": "conversation.item.create",
"item": {
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": "画面里有什么?"
},
{
"type": "input_image",
"image_url": "data:image/jpeg;base64,<base64-image>"
}
]
}
}
```
## Push-to-Talk
Push-to-Talk按住说话属于 OpenAI Realtime Core 兼容能力,不需要新增 `x.interactive_media.push_to_talk.*` 扩展事件。按钮是否按下、快捷键和麦克风 UI 状态由客户端本地管理,服务端只处理标准音频缓冲与回复事件。
### 关闭自动 VAD
客户端建立会话后先通过 `session.update` 关闭自动轮次检测:
```json
{
"event_id": "evt_ptt_config_001",
"type": "session.update",
"session": {
"type": "realtime",
"audio": {
"input": {
"turn_detection": null
}
}
}
}
```
关闭 VAD 后,服务端不会根据静音自动提交用户语音或创建回复。客户端必须在用户松开按钮时发送 `input_audio_buffer.commit`,然后发送 `response.create`。
### WebSocket 时序
WebSocket 的音频和控制事件在同一条有序连接上传输:
| 阶段 | 客户端行为 |
| --- | --- |
| 按下 | 开始在客户端录音;如果已有活动回复,发送 `response.cancel` |
| 打断播放 | 立即停止本地音频播放,并发送 `conversation.item.truncate`,用 `audio_end_ms` 删除用户未听到的内容 |
| 松开 | 通过一个或多个 `input_audio_buffer.append` 发送本次录音 |
| 提交 | 依次发送 `input_audio_buffer.commit` 和 `response.create` |
完整的松开事件序列如下:
```json
{
"event_id": "evt_ptt_audio_001",
"type": "input_audio_buffer.append",
"audio": "<base64-pcm-bytes>"
}
```
```json
{
"event_id": "evt_ptt_commit_001",
"type": "input_audio_buffer.commit"
}
```
```json
{
"event_id": "evt_ptt_response_001",
"type": "response.create"
}
```
如果用户在录音完成前取消本次输入,客户端应丢弃本地录音;已经发送到服务端的未提交音频则使用 `input_audio_buffer.clear` 清除。
### WebRTC 时序
WebRTC 的音频媒体轨道和 `oai-events` 控制事件属于不同通道,因此开始新一轮输入前必须显式清理旧缓冲:
| 阶段 | 客户端行为 |
| --- | --- |
| 按下 | 先发送 `input_audio_buffer.clear`,再开始或放开本地麦克风输入门控 |
| 打断生成 | 如果已有活动回复,发送 `response.cancel` |
| 打断播放 | 如果助手音频仍在播放,发送 `output_audio_buffer.clear`;服务端同时截断未播放的对话内容 |
| 松开 | 关闭本地麦克风输入门控,依次发送 `input_audio_buffer.commit` 和 `response.create` |
```js
function sendEvent(event) {
dataChannel.send(JSON.stringify(event));
}
function onPushDown() {
sendEvent({ type: "input_audio_buffer.clear" });
if (hasActiveResponse) {
sendEvent({ type: "response.cancel" });
}
if (isAssistantAudioPlaying) {
sendEvent({ type: "output_audio_buffer.clear" });
}
microphoneTrack.enabled = true;
}
function onPushUp() {
microphoneTrack.enabled = false;
sendEvent({ type: "input_audio_buffer.commit" });
sendEvent({ type: "response.create" });
}
```
客户端应处理指针移出、窗口失焦和权限撤销等情况,确保一次按下只产生一次提交。没有有效音频时不要发送 `input_audio_buffer.commit`。
### 保留 VAD、手动触发回复
如果只想由客户端决定何时生成回复,但仍希望服务端判断说话开始和结束,可以保留 VAD并关闭自动回复及自动打断
```json
{
"type": "session.update",
"session": {
"type": "realtime",
"audio": {
"input": {
"turn_detection": {
"type": "server_vad",
"interrupt_response": false,
"create_response": false
}
}
}
}
}
```
这种模式适合在提交前进行审核、输入校验或知识检索,不等同于严格的按住说话。
## 标准事件范围
首版兼容层至少支持以下 OpenAI Realtime 事件。
### 客户端发送
| 事件 | 用途 |
| --- | --- |
| `session.update` | 更新允许覆盖的会话配置 |
| `conversation.item.create` | 发送文本、整段音频、图片或函数结果 |
| `conversation.item.truncate` | 打断后删除未播放的助手内容 |
| `input_audio_buffer.append` | WebSocket 追加音频 |
| `input_audio_buffer.commit` | 无 VAD 时提交音频 |
| `input_audio_buffer.clear` | 清除未提交音频 |
| `output_audio_buffer.clear` | WebRTC/SIP 清除未播放音频并截断上下文 |
| `response.create` | 请求助手生成回复 |
| `response.cancel` | 取消正在生成的回复 |
### 服务端发送
| 事件组 | 事件 |
| --- | --- |
| 会话 | `session.created`、`session.updated`、`error` |
| 用户语音 | `input_audio_buffer.speech_started`、`input_audio_buffer.speech_stopped`、`input_audio_buffer.committed` |
| 用户转写 | `conversation.item.input_audio_transcription.completed` |
| 对话项 | `conversation.item.added`、`conversation.item.done` |
| 回复生命周期 | `response.created`、`response.output_item.added`、`response.output_item.done`、`response.done` |
| 文本 | `response.output_text.delta`、`response.output_text.done` |
| 音频 | `response.output_audio.delta`、`response.output_audio.done` |
| 音频转写 | `response.output_audio_transcript.delta`、`response.output_audio_transcript.done` |
| 函数调用 | `response.function_call_arguments.delta`、`response.function_call_arguments.done` |
WebRTC 中的助手音频本体走媒体轨道,因此客户端不应依赖 `response.output_audio.delta` 播放声音;音频生命周期和转写事件仍通过 `oai-events` 发送。
## 首版兼容矩阵
| 分类 | 状态 | 说明 |
| --- | --- | --- |
| WebRTC 音频、`oai-events` DataChannel | 支持 | 复用 SmallWebRTC 和现有 Pipeline |
| WebSocket 文本与 24 kHz PCM16 mono Base64 音频 | 支持 | 只接受 UTF-8 JSON 文本帧 |
| 文本轮次、Server VAD、PTT、取消和清空输出 | 支持 | `turn_detection: null` 时由 `commit` + `response.create` 结束轮次 |
| `input_image` Data URL | 条件支持 | 仅 Pipeline 模式且助手已授权视觉输入Realtime 模式明确报错 |
| Client Tool 标准 function call | 支持 | System、HTTP、MCP 工具仍在服务端内部执行 |
| `x.interactive_media.*` | 协商后支持 | 未协商的客户端不会收到扩展事件 |
| `model`、instructions、voice、工具和系统提示词 | 锁定 | 由助手配置决定,覆盖请求返回标准 `error` |
| WebSocket 其他采样率、压缩音频、连续视频 | 暂不支持 | WebRTC 音频由 SDP 协商;连续视频仅走 WebRTC |
| 公开助手别名、断线续传、并发回复、SIP | 暂不支持 | `model` 必须使用实际的 `assistant:asst_xxx` |
Qwen Realtime provider 只允许在 provider 会话首次配置前选择 PTT 或自动轮次检测建立连接后再次切换会返回明确错误。Pipeline 与支持动态更新的 Realtime provider 可在会话内切换。
## 工具调用
服务端 System、HTTP 和 MCP 工具在现有运行时内部执行。Client 工具使用标准函数调用协议,不再建立一套平行的 `client-tool-call` 公开协议。
服务端完成函数参数后发送标准函数调用事件;客户端执行函数并回传:
```json
{
"type": "conversation.item.create",
"item": {
"type": "function_call_output",
"call_id": "call_abc123",
"output": "{\"ok\":true,\"result\":{\"ticket_id\":\"T-1001\"}}"
}
}
```
随后发送 `response.create` 让助手基于工具结果继续当前轮次。`call_id` 在整个会话内唯一,客户端应原样返回。
## Interactive Media 扩展
### 能力协商
客户端收到 `session.created` 后主动声明所需扩展:
```json
{
"event_id": "evt_capabilities_001",
"type": "x.interactive_media.capabilities.update",
"capabilities": [
"workflow_events",
"dynamic_variables",
"handoff",
"video_track"
]
}
```
服务端返回实际接受的能力:
```json
{
"event_id": "evt_server_001",
"type": "x.interactive_media.capabilities.updated",
"capabilities": [
"workflow_events",
"dynamic_variables",
"handoff",
"video_track"
],
"rejected": []
}
```
服务端只发送已接受能力对应的扩展事件。扩展版本与 OpenAI Realtime Core 独立演进。
### 扩展事件目录
| 方向 | 事件 | 用途 |
| --- | --- | --- |
| Client → Server | `x.interactive_media.capabilities.update` | 请求扩展能力 |
| Server → Client | `x.interactive_media.capabilities.updated` | 返回允许的能力 |
| Client → Server | `x.interactive_media.session.variables.update` | 静默更新动态变量,不创建用户消息 |
| Server → Client | `x.interactive_media.session.variables.updated` | 确认变量更新状态 |
| Server → Client | `x.interactive_media.workflow.node_active` | 当前工作流节点变化 |
| Server → Client | `x.interactive_media.workflow.variables.updated` | 工作流变量变化 |
| Server → Client | `x.interactive_media.workflow.event` | 工作流业务事件 |
| Server → Client | `x.interactive_media.workflow.error` | 工作流执行失败 |
| Server → Client | `x.interactive_media.call.handoff_requested` | 助手请求人工接管 |
| Server → Client | `x.interactive_media.call.ended` | 会话结束及原因 |
动态变量更新示例:
```json
{
"event_id": "evt_variables_001",
"type": "x.interactive_media.session.variables.update",
"update_id": "update_001",
"variables": {
"user_name": "王先生",
"region": "上海"
}
}
```
```json
{
"event_id": "evt_server_002",
"type": "x.interactive_media.session.variables.updated",
"update_id": "update_001",
"status": "accepted"
}
```
工作流节点事件示例:
```json
{
"event_id": "evt_server_003",
"type": "x.interactive_media.workflow.node_active",
"nodeId": "collect_materials"
}
```
会话结束示例:
```json
{
"event_id": "evt_server_004",
"type": "x.interactive_media.call.ended",
"reason": "assistant_completed"
}
```
`x.interactive_media.call.ended` 发出后,服务端停止产生新回复并正常关闭媒体与信令通道。未启用扩展的客户端只观察到标准回复结束和连接关闭。
## 打断和回复并发
- 默认同一会话只允许一个活动回复。
- 用户开始说话且助手允许打断时,服务端取消当前生成并停止后续音频输出。
- 客户端可显式发送 `response.cancel`。
- WebSocket 客户端自行管理音频播放,因此停止播放后应发送 `conversation.item.truncate`,用 `audio_end_ms` 告诉服务端用户实际听到的位置。
- WebRTC/SIP 的输出音频由服务端缓冲VAD 打断时由服务端自动截断,显式 Push-to-Talk 打断使用 `output_audio_buffer.clear`。
- 助手配置禁止打断时,服务端继续当前回复,并对不允许的显式取消返回 `error`。
## 错误格式
协议错误使用标准 `error` 事件,不使用只存在于某个传输的自定义错误结构:
```json
{
"event_id": "evt_server_error_001",
"type": "error",
"error": {
"type": "invalid_request_error",
"code": "session_field_locked",
"message": "The assistant does not allow overriding instructions.",
"param": "session.instructions",
"event_id": "evt_update_001"
}
}
```
常见错误码包括:
| code | 含义 |
| --- | --- |
| `authentication_failed` | API Key 或临时令牌无效 |
| `assistant_not_found` | 助手不存在、未发布或不可访问 |
| `session_field_locked` | 客户端尝试覆盖服务端锁定配置 |
| `unsupported_event` | 当前协议版本不支持该事件 |
| `capability_not_enabled` | 未协商或无权使用某项扩展 |
| `response_in_progress` | 已有活动回复且当前请求不能并发执行 |
| `tool_result_timeout` | Client 工具未在规定时间内返回 |
| `internal_error` | 运行时出现不可恢复错误 |
错误只终止相关事件;只有鉴权失败、协议严重错误或运行时不可恢复时才关闭整个连接。
## 事件顺序和重连
- WebSocket 文本帧和 `oai-events` DataChannel 都按连接内的发送顺序处理。
- 客户端事件建议携带唯一 `event_id`;服务端错误通过 `error.event_id` 指回原事件。
- `session.created` 是连接建立后的第一个业务事件。
- 每个回复由 `response.created` 开始,以 `response.done` 结束Delta 必须携带所属的 response、item 和 content 索引。
- `Interactive Media Realtime Extensions v1` 不支持会话断线续传。重连会创建新会话;服务端历史记录仍按平台策略持久化。
- 客户端不得把收到的旧连接事件写入新连接。
## 现有能力映射
| 现有能力或旧事件 | 新协议 |
| --- | --- |
| WebRTC 麦克风与助手音频 | WebRTC audio track |
| 摄像头连续输入 | 可选 WebRTC video track + `video_track` capability |
| `user-input` 文本或图片 | `conversation.item.create` + `response.create` |
| `session-update` | `x.interactive_media.session.variables.update` |
| `transcript` 用户转写 | `conversation.item.input_audio_transcription.*` |
| `assistant-text-start/delta/end` | `response.output_text.*` 或 `response.output_audio_transcript.*` |
| `client-tool-call/result` | 标准 function call + `function_call_output` |
| `node-active` | `x.interactive_media.workflow.node_active` |
| `workflow-variables` | `x.interactive_media.workflow.variables.updated` |
| `workflow-event` | `x.interactive_media.workflow.event` |
| `call-ended` | `x.interactive_media.call.ended` |
| 知识库、服务端工具、工作流执行 | 继续在现有运行时内部完成 |
Pipeline 与 Realtime 两种助手运行模式对客户端使用同一套北向事件。协议适配层负责把不同内部模型事件归一化,客户端不需要根据运行模式切换协议。
原有 RTVI 入口和消息格式继续并行保留,当前前端不需要迁移;新兼容接口面向新的第三方集成。
## 传输选择
| 场景 | 推荐传输 | 原因 |
| --- | --- | --- |
| 浏览器实时语音、打断和摄像头 | WebRTC | 媒体自适应、播放简单、支持视频轨道 |
| 移动端实时语音 | WebRTC | 更适合不稳定网络和双向媒体 |
| 服务端 Agent 或批处理式实时流 | WebSocket | JSON 与音频缓冲可完全由调用方控制 |
| 电话网关 | WebSocket | 易于接入现有 PCM/PCMU 音频桥接 |
| 纯文本 Realtime | WebSocket | 无需建立媒体轨道 |
## 兼容性原则
1. 不修改标准事件的名称和含义。
2. 所有项目扩展使用 `x.interactive_media.*` 命名空间。
3. 扩展必须先协商再发送,普通客户端只看到标准事件。
4. 未支持字段明确报错,不静默伪装为成功。
5. OpenAI Realtime Core 和 Interactive Media 扩展分别进行版本管理。
6. 新协议只替换北向连接层,不改变现有 Pipecat 运行时和助手配置模型。
## 参考
- [OpenAI Realtime API with WebRTC](https://developers.openai.com/api/docs/guides/realtime-webrtc)
- [OpenAI Realtime API with WebSocket](https://developers.openai.com/api/docs/guides/realtime-websocket)
- [OpenAI Realtime conversations](https://developers.openai.com/api/docs/guides/realtime-conversations)
- [OpenAI Realtime Push-to-Talk](https://developers.openai.com/api/docs/guides/realtime-conversations#push-to-talk)

53
docs-user/docs.json Normal file
View File

@@ -0,0 +1,53 @@
{
"$schema": "https://mintlify.com/docs.json",
"name": "AI 视频助手 · 用户文档",
"theme": "mint",
"colors": {
"primary": "#1b2741",
"light": "#3a4a6b",
"dark": "#0c1426"
},
"logo": {
"light": "/logo-light.svg",
"dark": "/logo-dark.svg",
"href": "/"
},
"appearance": {
"default": "dark"
},
"fonts": {
"family": "Inter",
"weight": 400,
"heading": {
"family": "Cormorant Garamond",
"weight": 300
}
},
"icons": {
"library": "lucide"
},
"background": {
"decoration": "gradient",
"color": {
"light": "#f3f5fb",
"dark": "#070b16"
}
},
"styling": {
"eyebrows": "breadcrumbs",
"codeblocks": {
"theme": {
"light": "github-light",
"dark": "github-dark"
}
}
},
"navigation": {
"groups": [
{
"group": "产品",
"pages": ["index", "features"]
}
]
}
}

View File

@@ -1,22 +1,13 @@
---
title: AI 视频助手
title: 产品概览
description: 面向实时语音交互的助手管理与集成平台。
icon: video
---
# AI 视频助手
# 产品概览
在一个界面中配置模型、知识库、工具和助手,并通过 WebRTC 或 WebSocket 接入实时语音对话。
<CardGroup cols={2}>
<Card title="快速开始" icon="rocket" color="#3a4a6b" href="/quickstart">
本地启动服务并完成首次助手配置。
</Card>
<Card title="对接说明" icon="plug" color="#3a4a6b" href="/integrations">
了解模型、外部 Agent 与语音通道的接入方式。
</Card>
</CardGroup>
## 平台组成
- **助手**:定义提示词、运行模式、模型绑定及可选工作流。
@@ -24,3 +15,8 @@ icon: video
- **语音引擎**Pipecat 管线支持浏览器 WebRTC 与裸 WebSocket 音频流。
> 凭证只保存在模型资源或助手的服务端配置中;接口返回会自动打码。
## 下一步
- [核心功能](/features):了解助手类型、模型、知识库与工具。
- 本地启动与对接见仓库中的 `docs-developer/`(独立开发者文档站点)。

18
docs-user/logo-dark.svg Normal file
View File

@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 196 48" fill="none">
<defs>
<radialGradient id="sky" cx="0" cy="0" r="1" gradientTransform="matrix(31 0 0 31 14 10)" gradientUnits="userSpaceOnUse">
<stop stop-color="#5F86B8" stop-opacity=".75"/>
<stop offset="1" stop-color="#5F86B8" stop-opacity="0"/>
</radialGradient>
<radialGradient id="lavender" cx="0" cy="0" r="1" gradientTransform="matrix(28 0 0 28 38 39)" gradientUnits="userSpaceOnUse">
<stop stop-color="#8A78AD" stop-opacity=".68"/>
<stop offset="1" stop-color="#8A78AD" stop-opacity="0"/>
</radialGradient>
</defs>
<circle cx="24" cy="24" r="24" fill="#E8EDF9"/>
<circle cx="24" cy="24" r="24" fill="url(#sky)"/>
<circle cx="24" cy="24" r="24" fill="url(#lavender)"/>
<rect x="14" y="17.5" width="14" height="13" rx="2.25" stroke="#0C1426" stroke-width="2.5"/>
<path d="m28 21.25 7-4v13.5l-7-4z" stroke="#0C1426" stroke-width="2.5" stroke-linejoin="round"/>
<text x="61" y="31" fill="#E8EDF9" font-family="Cormorant Garamond, serif" font-size="24" font-weight="400">AI视频助手</text>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

18
docs-user/logo-light.svg Normal file
View File

@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 196 48" fill="none">
<defs>
<radialGradient id="sky" cx="0" cy="0" r="1" gradientTransform="matrix(31 0 0 31 14 10)" gradientUnits="userSpaceOnUse">
<stop stop-color="#A8C8E8" stop-opacity=".8"/>
<stop offset="1" stop-color="#A8C8E8" stop-opacity="0"/>
</radialGradient>
<radialGradient id="lavender" cx="0" cy="0" r="1" gradientTransform="matrix(28 0 0 28 38 39)" gradientUnits="userSpaceOnUse">
<stop stop-color="#C8B8E0" stop-opacity=".72"/>
<stop offset="1" stop-color="#C8B8E0" stop-opacity="0"/>
</radialGradient>
</defs>
<circle cx="24" cy="24" r="24" fill="#E8EDF9"/>
<circle cx="24" cy="24" r="24" fill="url(#sky)"/>
<circle cx="24" cy="24" r="24" fill="url(#lavender)"/>
<rect x="14" y="17.5" width="14" height="13" rx="2.25" stroke="#0C1426" stroke-width="2.5"/>
<path d="m28 21.25 7-4v13.5l-7-4z" stroke="#0C1426" stroke-width="2.5" stroke-linejoin="round"/>
<text x="61" y="31" fill="#0F1B33" font-family="Cormorant Garamond, serif" font-size="24" font-weight="400">AI视频助手</text>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -9,6 +9,9 @@
"version": "0.1.0",
"dependencies": {
"@daily-co/daily-js": "^0.90.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@pipecat-ai/client-js": "^1.12.0",
"@pipecat-ai/small-webrtc-transport": "^1.10.5",
"@xyflow/react": "^12.11.0",
@@ -477,6 +480,59 @@
"node": ">=22.14.0"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dotenvx/dotenvx": {
"version": "1.71.0",
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.71.0.tgz",

View File

@@ -10,6 +10,9 @@
},
"dependencies": {
"@daily-co/daily-js": "^0.90.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@pipecat-ai/client-js": "^1.12.0",
"@pipecat-ai/small-webrtc-transport": "^1.10.5",
"@xyflow/react": "^12.11.0",

View File

@@ -1,5 +1,5 @@
import { AssistantPage } from "@/components/pages/AssistantPage";
import { CreateAssistantPage } from "@/components/pages/CreateAssistantPage";
export default function Page() {
return <AssistantPage mode="choose" />;
return <CreateAssistantPage />;
}

View File

@@ -0,0 +1,10 @@
import { redirect } from "next/navigation";
type PageProps = {
params: Promise<{ id: string }>;
};
export default async function Page({ params }: PageProps) {
const { id } = await params;
redirect(`/assistants/templates?template=${encodeURIComponent(id)}`);
}

View File

@@ -0,0 +1,10 @@
import { TemplateLibraryPage } from "@/components/pages/TemplateLibraryPage";
type PageProps = {
searchParams: Promise<{ template?: string }>;
};
export default async function Page({ searchParams }: PageProps) {
const { template } = await searchParams;
return <TemplateLibraryPage initialTemplateId={template} />;
}

View File

@@ -180,7 +180,44 @@
@layer base {
* {
@apply border-border outline-ring/50;
/* Theme-aware scrollbars (light/dark navy tokens) */
scrollbar-width: thin;
scrollbar-color: color-mix(
in srgb,
var(--muted-soft) 48%,
transparent
)
transparent;
}
*::-webkit-scrollbar {
width: 10px;
height: 10px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background-color: color-mix(
in srgb,
var(--muted-soft) 42%,
transparent
);
border: 3px solid transparent;
border-radius: 999px;
background-clip: content-box;
}
*::-webkit-scrollbar-thumb:hover {
background-color: color-mix(
in srgb,
var(--muted-soft) 64%,
transparent
);
}
body {
@apply bg-background text-foreground;
letter-spacing: 0.01em;
@@ -261,42 +298,9 @@
}
}
/* Explicit opt-in alias; base layer already styles all scrollbars. */
.scrollbar-subtle {
scrollbar-width: thin;
scrollbar-color: color-mix(
in srgb,
var(--muted-soft) 48%,
transparent
)
transparent;
}
.scrollbar-subtle::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.scrollbar-subtle::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-subtle::-webkit-scrollbar-thumb {
background-color: color-mix(
in srgb,
var(--muted-soft) 42%,
transparent
);
border: 3px solid transparent;
border-radius: 999px;
background-clip: content-box;
}
.scrollbar-subtle::-webkit-scrollbar-thumb:hover {
background-color: color-mix(
in srgb,
var(--muted-soft) 64%,
transparent
);
}
/* 手机通话页「开始通话」引导光晕:柔和向外扩散的涟漪环 */

View File

@@ -0,0 +1,5 @@
import { BatchTestPage } from "@/components/pages/BatchTestPage";
export default function Page() {
return <BatchTestPage />;
}

View File

@@ -0,0 +1,19 @@
import { TestCasesPage } from "@/components/pages/TestCasesPage";
export default async function Page({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ case?: string }>;
}) {
const { id } = await params;
const { case: initialCaseId } = await searchParams;
return (
<TestCasesPage
mode="detail"
suiteId={id}
initialCaseId={initialCaseId}
/>
);
}

View File

@@ -0,0 +1,5 @@
import { TestCasesPage } from "@/components/pages/TestCasesPage";
export default function Page() {
return <TestCasesPage mode="create" />;
}

View File

@@ -0,0 +1,5 @@
import { TestCasesPage } from "@/components/pages/TestCasesPage";
export default function Page() {
return <TestCasesPage mode="list" />;
}

View File

@@ -1,5 +1,5 @@
import { TestPage } from "@/components/pages/TestPage";
import { redirect } from "next/navigation";
export default function Page() {
return <TestPage />;
redirect("/test/cases");
}

View File

@@ -0,0 +1,212 @@
"use client";
import { Plus, Trash2 } from "lucide-react";
import { ResourceSelectField, ToggleRow } from "@/components/assistant-editor/editor-controls";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type {
AnalysisConfig,
AnalysisField,
AnalysisFieldType,
} from "@/lib/api";
export type { AnalysisConfig, AnalysisField, AnalysisFieldType } from "@/lib/api";
const FIELD_TYPE_OPTIONS: Array<{
value: AnalysisFieldType;
label: string;
}> = [
{ value: "string", label: "string" },
{ value: "boolean", label: "boolean" },
{ value: "integer", label: "integer" },
{ value: "number", label: "number" },
{ value: "enum", label: "enum" },
];
function createField(): AnalysisField {
return {
id: crypto.randomUUID(),
name: "",
type: "string",
description: "",
enumValues: [],
};
}
type AnalysisConfigEditorProps = {
config: AnalysisConfig;
onChange: (config: AnalysisConfig) => void;
modelOptions: Array<{ value: string; label: string }>;
};
export function AnalysisConfigEditor({
config,
onChange,
modelOptions,
}: AnalysisConfigEditorProps) {
function patch(partial: Partial<AnalysisConfig>) {
onChange({ ...config, ...partial });
}
function updateField(id: string, partial: Partial<AnalysisField>) {
patch({
fields: config.fields.map((field) =>
field.id === id ? { ...field, ...partial } : field,
),
});
}
function removeField(id: string) {
patch({ fields: config.fields.filter((field) => field.id !== id) });
}
function addField() {
patch({ fields: [...config.fields, createField()] });
}
return (
<div className="space-y-3">
<ToggleRow
title="通话后分析"
hint="通话结束后,使用所选模型从对话中提取关键信息。"
checked={config.enabled}
onChange={(enabled) => patch({ enabled })}
/>
{config.enabled && (
<>
<ResourceSelectField
label="分析模型"
value={config.modelResourceId}
onChange={(modelResourceId) => patch({ modelResourceId })}
options={modelOptions}
noneLabel="请选择"
/>
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium text-foreground">
</div>
<p className="mt-0.5 text-xs leading-5 text-muted-foreground">
</p>
</div>
<Button
type="button"
variant="outline"
size="icon-sm"
className="shrink-0 border-hairline-strong text-muted-foreground hover:text-foreground"
onClick={addField}
aria-label="添加字段"
title="添加字段"
>
<Plus size={15} />
</Button>
</div>
{config.fields.length === 0 ? (
<div className="rounded-xl border border-dashed border-hairline-strong bg-canvas-soft px-4 py-5 text-center">
<div className="text-sm font-medium text-foreground">
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
customer_intentbooked
</p>
</div>
) : (
<div className="space-y-1.5">
{config.fields.map((field, index) => (
<div
key={field.id}
className="grid grid-cols-[minmax(0,1fr)_112px_minmax(0,1.2fr)_32px] items-center gap-2"
>
<Input
value={field.name}
onChange={(event) =>
updateField(field.id, { name: event.target.value })
}
placeholder="字段名,如 customer_intent"
aria-label={`字段 ${index + 1} 名称`}
className="h-9 border-hairline-strong bg-background"
/>
<Select
value={field.type}
onValueChange={(type: AnalysisFieldType) =>
updateField(field.id, {
type,
enumValues: type === "enum" ? field.enumValues : [],
})
}
>
<SelectTrigger
aria-label={`字段 ${index + 1} 类型`}
className="h-9 w-full border-hairline-strong bg-background"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIELD_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{field.type === "enum" ? (
<Input
value={field.enumValues.join(", ")}
onChange={(event) =>
updateField(field.id, {
enumValues: event.target.value
.split(",")
.map((value) => value.trim())
.filter(Boolean),
})
}
placeholder="枚举值,逗号分隔"
aria-label={`字段 ${index + 1} 枚举值`}
className="h-9 border-hairline-strong bg-background"
/>
) : (
<Input
value={field.description}
onChange={(event) =>
updateField(field.id, {
description: event.target.value,
})
}
placeholder="说明(可选)"
aria-label={`字段 ${index + 1} 说明`}
className="h-9 border-hairline-strong bg-background"
/>
)}
<Button
type="button"
size="icon-sm"
variant="ghost"
className="shrink-0 text-muted-soft hover:text-destructive"
onClick={() => removeField(field.id)}
aria-label={`删除字段 ${index + 1}`}
>
<Trash2 size={14} />
</Button>
</div>
))}
</div>
)}
</div>
</>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
"use client";
import type React from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import {
AudioLines,
Braces,
@@ -23,6 +23,12 @@ import {
X,
} from "lucide-react";
import {
AutoTestLivePanel,
AutoTestRunBar,
DebugModeTabs,
useAutoTestRunner,
} from "@/components/assistant-editor/debug-auto-test";
import { NetworkQualityIndicator } from "@/components/network-quality-indicator";
import { ClientMessageDialog } from "@/components/client-message-dialog";
import { AuraVisualizer } from "@/components/ui/aura-visualizer";
@@ -49,6 +55,7 @@ import { WaveformTimelinePanel } from "@/components/ui/waveform-timeline";
import { useCameraPreview, type CameraPreview } from "@/hooks/use-camera-preview";
import { usePhotoCaptureTool } from "@/hooks/use-photo-capture-tool";
import {
createUserInputId,
useVoicePreview,
type ChatMessage,
type ClientToolDefinition,
@@ -66,6 +73,7 @@ type VizStyle = "aura" | "nebula" | "bars" | "wave";
// 调试面板顶部主视图:聊天记录 / 视频流
type DebugView = "chat" | "video";
type DebugInputMode = "mic" | "text";
type DebugMode = "manual" | "auto";
type PendingDebugImage = {
file: File;
previewUrl: string;
@@ -174,6 +182,9 @@ export function DebugDrawer({
}) {
const preview = useVoicePreview(assistantId, onNodeActive);
const camera = useCameraPreview();
const autoTest = useAutoTestRunner();
const stopAutoTest = autoTest.stop;
const [debugMode, setDebugMode] = useState<DebugMode>("manual");
const [showTranscript, setShowTranscript] = useState(false);
const [vizStyle, setVizStyle] = useState<VizStyle>("aura");
const [view, setView] = useState<DebugView>("chat");
@@ -217,6 +228,20 @@ export function DebugDrawer({
[camera, preview],
);
// 切到自动测试时停掉手动会话,避免两边抢占麦克风/对话区
const handleModeChange = useCallback(
(mode: DebugMode) => {
setDebugMode(mode);
if (mode === "auto" && recording) {
preview.disconnect();
}
if (mode === "manual") {
stopAutoTest();
}
},
[preview, recording, stopAutoTest],
);
return (
<aside
className={overlay
@@ -239,14 +264,18 @@ export function DebugDrawer({
<div className="shrink-0 text-sm font-medium text-foreground">
</div>
<NetworkQualityIndicator
quality={preview.networkQuality}
status={preview.status}
/>
<DebugConnectionStatus
status={preview.status}
micWarning={preview.micWarning}
/>
{debugMode === "manual" && (
<>
<NetworkQualityIndicator
quality={preview.networkQuality}
status={preview.status}
/>
<DebugConnectionStatus
status={preview.status}
micWarning={preview.micWarning}
/>
</>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<ClientToolsPopover tools={preview.clientTools} />
@@ -260,7 +289,7 @@ export function DebugDrawer({
onChange={setDynamicVariableValues}
/>
)}
{SHOW_VOICE_VIZ && view === "chat" && (
{debugMode === "manual" && SHOW_VOICE_VIZ && view === "chat" && (
<>
{!showTranscript && (
<SegmentedIconGroup label="可视化样式">
@@ -296,28 +325,69 @@ export function DebugDrawer({
)}
</div>
</div>
<div className="shrink-0 border-b border-hairline px-5 py-2.5">
<div className="flex h-10 min-w-0 items-center rounded-[1.4rem] border border-hairline-strong bg-background px-2">
<CameraDeviceField camera={camera} onSelect={selectCamera} />
</div>
<div className="shrink-0 space-y-2.5 border-b border-hairline px-5 py-2.5">
<DebugModeTabs mode={debugMode} onChange={handleModeChange} />
{debugMode === "manual" && (
<div className="flex h-10 min-w-0 items-center rounded-[1.4rem] border border-hairline-strong bg-background px-2">
<CameraDeviceField camera={camera} onSelect={selectCamera} />
</div>
)}
</div>
<DebugVoicePanel
view={view}
onViewChange={setView}
showTranscript={showTranscript}
vizStyle={vizStyle}
assistantId={assistantId}
preview={preview}
camera={camera}
hasUnsavedChanges={hasUnsavedChanges}
vision={vision}
dynamicVariables={resolvedDynamicVariables}
dynamicVariablesError={dynamicVariablesError}
/>
{debugMode === "auto" ? (
<AutoTestPanel autoTest={autoTest} />
) : (
<DebugVoicePanel
view={view}
onViewChange={setView}
showTranscript={showTranscript}
vizStyle={vizStyle}
assistantId={assistantId}
preview={preview}
camera={camera}
hasUnsavedChanges={hasUnsavedChanges}
vision={vision}
dynamicVariables={resolvedDynamicVariables}
dynamicVariablesError={dynamicVariablesError}
/>
)}
</aside>
);
}
function AutoTestPanel({
autoTest,
}: {
autoTest: ReturnType<typeof useAutoTestRunner>;
}) {
const {
state,
selectedCase,
selectCase,
clearCase,
start,
stop,
rerun,
} = autoTest;
return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<AutoTestLivePanel testCase={selectedCase} state={state} />
</div>
<div className="shrink-0 border-t border-hairline bg-card p-3">
<AutoTestRunBar
state={state}
onSelectCase={selectCase}
onClearCase={clearCase}
onStart={start}
onStop={stop}
onRerun={rerun}
/>
</div>
</div>
);
}
function ClientToolsPopover({ tools }: { tools: ClientToolDefinition[] }) {
const [copiedToolName, setCopiedToolName] = useState<string | null>(null);
@@ -799,6 +869,7 @@ function DebugVoicePanel({
sendText,
sendUserInput,
appendUserImage,
removeUserImage,
connect,
disconnect,
audioRef,
@@ -866,6 +937,7 @@ function DebugVoicePanel({
setSendingInput(true);
setInputError("");
let assetToken = "";
let inputId = "";
try {
const imageUrl = await fileToDataUrl(pendingImage.file);
const asset = await inputAssetsApi.uploadImage(pendingImage.file);
@@ -878,11 +950,13 @@ function DebugVoicePanel({
});
const timestamp = new Date().toISOString();
const result = await sendUserInput(parts);
appendUserImage(result.inputId, imageUrl, timestamp, text);
inputId = createUserInputId();
appendUserImage(inputId, imageUrl, timestamp, text);
await sendUserInput(parts, { inputId });
setTextDraft("");
setPendingImage(null);
} catch (sendError) {
if (inputId) removeUserImage(inputId);
if (assetToken) {
void inputAssetsApi.remove(assetToken).catch(() => {});
}
@@ -1636,12 +1710,26 @@ function DebugTranscriptPanel({
recording?: boolean;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
// 新消息时滚到底部
useEffect(() => {
const scrollToBottom = useCallback(() => {
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [messages]);
}, []);
// Scroll when message list changes (before paint).
useLayoutEffect(() => {
scrollToBottom();
}, [messages, scrollToBottom]);
// Images load after the message row mounts; re-scroll when content height grows.
useEffect(() => {
const content = contentRef.current;
if (!content) return;
const observer = new ResizeObserver(() => scrollToBottom());
observer.observe(content);
return () => observer.disconnect();
}, [scrollToBottom]);
if (messages.length === 0) {
return (
@@ -1671,7 +1759,7 @@ function DebugTranscriptPanel({
ref={scrollRef}
className="scrollbar-subtle flex min-h-0 flex-1 flex-col overflow-y-auto px-5 py-4"
>
<div className="flex flex-col gap-4">
<div ref={contentRef} className="flex flex-col gap-4">
{messages.map((message) => {
const time = formatMessageTime(message.timestamp);
return message.role === "assistant" ? (
@@ -1707,6 +1795,7 @@ function DebugTranscriptPanel({
src={attachment.url}
alt={attachment.alt}
className="max-h-72 w-full rounded-[0.8rem] object-cover"
onLoad={scrollToBottom}
/>
))}
{message.content && (

View File

@@ -70,7 +70,13 @@ export function EditorBackButton({
);
}
export function AssistantIdentity({ assistantId }: { assistantId: string | null }) {
export function AssistantIdentity({
assistantId,
entityLabel = "助手",
}: {
assistantId: string | null;
entityLabel?: string;
}) {
const [copied, setCopied] = useState(false);
async function copyId() {
@@ -90,7 +96,9 @@ export function AssistantIdentity({ assistantId }: { assistantId: string | null
type="button"
onClick={() => void copyId()}
className="ml-1 flex h-7 w-7 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
aria-label={copied ? "助手 ID 已复制" : "复制助手 ID"}
aria-label={
copied ? `${entityLabel} ID 已复制` : `复制${entityLabel} ID`
}
title={copied ? "已复制" : "复制 ID"}
>
{copied ? <Check size={13} /> : <Copy size={13} />}

View File

@@ -1,16 +1,22 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
AnalysisConfigEditor,
} from "@/components/assistant-editor/analysis-config";
import {
Braces,
Bot,
Brain,
Bug,
ChartLine,
Database,
Loader2,
MessageSquareText,
MoreHorizontal,
Save,
Sparkles,
Trash2,
Wrench,
} from "lucide-react";
@@ -35,6 +41,12 @@ import { SectionCard } from "@/components/editor/section-card";
import { VisionConfigSection } from "@/components/editor/vision-config-section";
import { TurnConfigEditor } from "@/components/turn-config-editor";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { TopbarPortal } from "@/components/layout/topbar-portal";
import { Input } from "@/components/ui/input";
import {
@@ -86,6 +98,7 @@ const promptSections = [
{ id: "capabilities", label: "知识与工具" },
{ id: "interaction", label: "交互策略" },
{ id: "variables", label: "动态变量" },
{ id: "analysis", label: "分析" },
] as const;
type PromptSectionId = (typeof promptSections)[number]["id"];
@@ -106,6 +119,7 @@ type PromptEditorProps = {
tools: Tool[];
onBack: () => void;
onSave: () => void;
onDelete?: () => void | Promise<void>;
updateForm: <K extends keyof AssistantForm>(
key: K,
value: AssistantForm[K],
@@ -130,6 +144,7 @@ export function PromptEditor({
tools,
onBack,
onSave,
onDelete,
updateForm,
handlePromptVisionEnabledChange,
handlePromptModelChange,
@@ -146,11 +161,13 @@ export function PromptEditor({
capabilities: null,
interaction: null,
variables: null,
analysis: null,
});
const selectedAnchorRef = useRef<PromptSectionId | null>(null);
const [activeSection, setActiveSection] =
useState<PromptSectionId>("conversation");
const [debugOpen, setDebugOpen] = useState(true);
const [deleting, setDeleting] = useState(false);
useEffect(() => {
const container = scrollContainerRef.current;
@@ -263,6 +280,16 @@ export function PromptEditor({
});
}
async function handleDeleteSelect() {
if (!onDelete || deleting) return;
setDeleting(true);
try {
await onDelete();
} finally {
setDeleting(false);
}
}
return (
<>
<TopbarPortal>
@@ -274,6 +301,42 @@ export function PromptEditor({
onChange={(value) => updateForm("name", value)}
/>
<AssistantIdentity assistantId={assistantId} />
{onDelete && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon-sm"
className="border-hairline-strong text-muted-foreground hover:text-foreground"
disabled={!assistantId || deleting}
aria-label="更多操作"
>
{deleting ? (
<Loader2 size={15} className="animate-spin" />
) : (
<MoreHorizontal size={15} />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="w-32 min-w-32 rounded-xl border border-hairline bg-popover p-1"
>
<DropdownMenuItem
variant="destructive"
className="rounded-lg"
disabled={deleting}
onSelect={(event) => {
event.preventDefault();
window.setTimeout(() => void handleDeleteSelect(), 0);
}}
>
<Trash2 size={14} />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
@@ -691,6 +754,27 @@ export function PromptEditor({
/>
</SectionCard>
</section>
<section
ref={(element) => {
sectionRefs.current.analysis = element;
}}
className="scroll-mt-3 space-y-3"
>
<SectionCard
icon={<ChartLine size={15} />}
title="分析"
description="通话结束后自动提取关键信息"
>
<AnalysisConfigEditor
config={form.analysisConfig}
onChange={(analysisConfig) =>
updateForm("analysisConfig", analysisConfig)
}
modelOptions={llmOptions}
/>
</SectionCard>
</section>
</div>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import type {
AnalysisConfig,
DynamicVariableDefinition,
KnowledgeRetrievalConfig,
StartupConfig,
@@ -12,6 +13,7 @@ export type AssistantForm = {
greeting: string;
prompt: string;
dynamicVariableDefinitions: Record<string, DynamicVariableDefinition>;
analysisConfig: AnalysisConfig;
runtimeMode: RuntimeMode;
realtimeModel: string;
model: string;

Some files were not shown because too many files have changed in this diff Show More