-
Notifications
You must be signed in to change notification settings - Fork 159
feat: add voice transcript bridge and plugin dispatch infrastructure #1612
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,6 +13,7 @@ | |
|
|
||
| import mimetypes | ||
| import json | ||
| from collections.abc import Mapping | ||
| mimetypes.add_type("application/javascript", ".js") | ||
| import asyncio | ||
| import uuid | ||
|
|
@@ -1342,9 +1343,7 @@ def _check_agent_api_gate() -> Dict[str, Any]: | |
| try: | ||
| cm = get_config_manager() | ||
| ok, reasons = cm.is_agent_api_ready() | ||
| # 字段名保留 is_free_version(前端/下游 gate 消费者沿用),值取 agent 维度的 | ||
| # is_agent_free():判 agent 是否走内置免费模型,而非 core/assist 的版本免费。 | ||
| return {"ready": ok, "reasons": reasons, "is_free_version": cm.is_agent_free()} | ||
| return {"ready": ok, "reasons": reasons, "is_free_version": cm.is_free_version()} | ||
| except Exception as e: | ||
| return {"ready": False, "reasons": [f"Agent API check failed: {e}"], "is_free_version": False} | ||
|
|
||
|
|
@@ -1650,6 +1649,139 @@ async def _emit_agent_status_update(lanlan_name: Optional[str] = None) -> None: | |
| pass | ||
|
|
||
|
|
||
| VOICE_TRANSCRIPT_CUSTOM_EVENT_TYPE = "voice_transcript" | ||
| VOICE_TRANSCRIPT_CUSTOM_EVENT_TIMEOUT_SECONDS = 1.0 | ||
|
|
||
|
|
||
| def _voice_bridge_noop(reason: str, **extra: object) -> Dict[str, Any]: | ||
| return { | ||
| "action": "noop", | ||
| "reason": str(reason or "noop"), | ||
| **extra, | ||
| } | ||
|
|
||
|
|
||
| def _voice_transcript_request_has_text(event: Mapping[str, object] | None) -> bool: | ||
| if not isinstance(event, Mapping): | ||
| return False | ||
| return bool(str(event.get("transcript") or "").strip()) | ||
|
|
||
|
|
||
| def _voice_transcript_custom_event_args(event: Mapping[str, object]) -> Dict[str, object]: | ||
| metadata = event.get("metadata") | ||
| return { | ||
| "transcript": str(event.get("transcript") or "").strip(), | ||
| "lanlan_name": str(event.get("lanlan_name") or ""), | ||
| "metadata": dict(metadata) if isinstance(metadata, Mapping) else {}, | ||
| } | ||
|
|
||
|
|
||
| def _voice_bridge_action_from_dispatch_results(dispatch_results: object) -> Dict[str, Any]: | ||
| if not isinstance(dispatch_results, list) or not dispatch_results: | ||
| return _voice_bridge_noop("no_subscribers") | ||
|
|
||
| from plugin.plugins.study_companion.voice_contracts import ( | ||
| arbitrate_voice_transcript_results, | ||
| ) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When any Useful? React with 👍 / 👎. |
||
|
|
||
| arbitration_items: list[dict[str, object]] = [] | ||
| failure_count = 0 | ||
| for item in dispatch_results: | ||
| if not isinstance(item, Mapping): | ||
| continue | ||
| if not bool(item.get("success")): | ||
| failure_count += 1 | ||
| continue | ||
| result = item.get("result") | ||
| if not isinstance(result, Mapping): | ||
| continue | ||
| action = str(result.get("action") or "").strip() | ||
| if not action: | ||
| continue | ||
| payload: Dict[str, Any] = dict(result) | ||
| payload["action"] = action | ||
| plugin_id = str(item.get("plugin_id") or "").strip() | ||
| if plugin_id: | ||
| payload.setdefault("source_plugin", plugin_id) | ||
| source_event_id = str(item.get("event_id") or "").strip() | ||
| if source_event_id: | ||
| payload.setdefault("source_event_id", source_event_id) | ||
| arbitration_items.append( | ||
| { | ||
| "plugin_id": payload.get("source_plugin") or plugin_id, | ||
| "event_id": payload.get("source_event_id") or source_event_id, | ||
| "success": True, | ||
| "result": payload, | ||
| } | ||
| ) | ||
|
|
||
| if not arbitration_items: | ||
| return _voice_bridge_noop("no_handler_result", failures=failure_count) | ||
| payload = arbitrate_voice_transcript_results(arbitration_items) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 把仲裁逻辑从具体插件包里拆出来喵。 这里在 host 侧直接依赖 🤖 Prompt for AI Agents |
||
| if failure_count: | ||
| try: | ||
| existing_failures = int(payload.get("failures") or 0) | ||
| except (TypeError, ValueError): | ||
| existing_failures = 0 | ||
| payload["failures"] = existing_failures + failure_count | ||
| return payload | ||
|
|
||
|
|
||
| async def _dispatch_voice_transcript_custom_event( | ||
| event: Mapping[str, object], | ||
| ) -> Dict[str, Any]: | ||
| from plugin.server.application.plugins.dispatch_service import PluginDispatchService | ||
|
|
||
| dispatch_results = await PluginDispatchService().trigger_custom_event_subscribers( | ||
| event_type=VOICE_TRANSCRIPT_CUSTOM_EVENT_TYPE, | ||
| args=_voice_transcript_custom_event_args(event), | ||
| timeout=VOICE_TRANSCRIPT_CUSTOM_EVENT_TIMEOUT_SECONDS, | ||
| ) | ||
| return _voice_bridge_action_from_dispatch_results(dispatch_results) | ||
|
|
||
|
|
||
| async def _handle_voice_transcript_request(event: Dict[str, Any]) -> None: | ||
| event_id = str((event or {}).get("event_id") or "") | ||
| lanlan_name = (event or {}).get("lanlan_name") | ||
| result: Dict[str, Any] = _voice_bridge_noop("unavailable") | ||
|
|
||
| try: | ||
| if not _voice_transcript_request_has_text(event): | ||
| result = _voice_bridge_noop("empty_transcript") | ||
| elif not Modules.analyzer_enabled: | ||
| result = _voice_bridge_noop("agent_disabled") | ||
| elif not Modules.agent_flags.get("user_plugin_enabled", False): | ||
| result = _voice_bridge_noop("user_plugin_disabled") | ||
| else: | ||
| lifecycle_ready = bool(Modules.plugin_lifecycle_started) | ||
| if not lifecycle_ready: | ||
| lifecycle_ready = await _ensure_plugin_lifecycle_started() | ||
|
|
||
| if not lifecycle_ready: | ||
| result = _voice_bridge_noop("plugin_lifecycle_start_failed") | ||
| else: | ||
| result = await _dispatch_voice_transcript_custom_event(event) | ||
| except Exception as exc: | ||
| logger.debug( | ||
| "[VoiceBridge] plugin dispatch failed: event_id=%s lanlan=%s err=%s", | ||
| event_id, | ||
| lanlan_name, | ||
| exc, | ||
| ) | ||
| result = { | ||
| "action": "noop", | ||
| "reason": "dispatch_failed", | ||
| "error_type": type(exc).__name__, | ||
| } | ||
|
|
||
| await _emit_main_event( | ||
| "voice_bridge_result", | ||
| lanlan_name if isinstance(lanlan_name, str) else None, | ||
| event_id=event_id, | ||
| result=result, | ||
| ) | ||
|
|
||
|
|
||
| async def _on_session_event(event: Dict[str, Any]) -> None: | ||
| event_type = (event or {}).get("event_type") | ||
| if event_type == "agent_intent_restore_signal": | ||
|
|
@@ -1662,6 +1794,11 @@ async def _on_session_event(event: Dict[str, Any]) -> None: | |
| # has its own once-flag, so this is safe to spam. | ||
| await _maybe_restore_agent_intent() | ||
| return | ||
| if event_type == "voice_transcript_request": | ||
| task = asyncio.create_task(_handle_voice_transcript_request(event)) | ||
| Modules._background_tasks.add(task) | ||
| task.add_done_callback(Modules._background_tasks.discard) | ||
| return | ||
| if event_type == "analyze_request": | ||
| messages = event.get("messages", []) | ||
| lanlan_name = event.get("lanlan_name") | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -121,7 +121,7 @@ def _resolve_user_plugin_base() -> str: | |
| from fastapi.responses import JSONResponse, Response # noqa | ||
| from fastapi.staticfiles import StaticFiles # noqa | ||
| from main_logic import core as core, cross_server as cross_server # noqa | ||
| from main_logic.agent_event_bus import MainServerAgentBridge, notify_analyze_ack, set_main_bridge # noqa | ||
| from main_logic.agent_event_bus import MainServerAgentBridge, notify_analyze_ack, notify_voice_bridge_result, set_main_bridge # noqa | ||
| from fastapi.templating import Jinja2Templates # noqa | ||
| from dataclasses import dataclass # noqa | ||
| from typing import Any, Optional # noqa | ||
|
|
@@ -608,6 +608,13 @@ async def _handle_agent_event(event: dict): | |
| notify_analyze_ack(str(event.get("event_id") or "")) | ||
| return | ||
|
|
||
| if event_type == "voice_bridge_result": | ||
| notify_voice_bridge_result( | ||
| str(event.get("event_id") or ""), | ||
| event.get("result") if isinstance(event.get("result"), dict) else {}, | ||
| ) | ||
| return | ||
|
|
||
| # Agent status updates may be broadcast (lanlan_name omitted). | ||
| if event_type == "agent_status_update": | ||
| payload = { | ||
|
|
@@ -1641,7 +1648,6 @@ async def get_response(self, path, scope): | |
| from main_routers.workshop_router import router as workshop_router # noqa | ||
| from main_routers.cookies_login_router import router as cookies_login_router # noqa | ||
| from main_routers.game_router import router as game_router # noqa | ||
| from main_routers.card_assist_router import router as card_assist_router # noqa | ||
| from main_routers.debug_router import router as debug_router, start_watchdog as _start_debug_health_watchdog # noqa | ||
| from main_routers.shared_state import init_shared_state, set_steamworks_initializer # noqa | ||
|
|
||
|
|
@@ -1773,7 +1779,6 @@ async def proxy_user_plugin_market_bridge(request: Request, path: str = ""): | |
| app.include_router(music_router) | ||
| app.include_router(galgame_router) | ||
| app.include_router(game_router) | ||
| app.include_router(card_assist_router) | ||
| app.include_router(capture_router) | ||
|
Comment on lines
1786
to
1787
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the main app is started, the four Useful? React with 👍 / 👎. |
||
| app.include_router(cookies_login_router) # Cookies登录相关路由,放在最后以避免与其他API路由冲突 | ||
| app.include_router(debug_router) # 诊断观测:/api/debug/health(轻量、零侵入,详见 debug_router.py 头注释) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In configurations where the voice/core side is free but the Agent model has been switched to a paid/custom model, this now reports
is_free_version: trueeven though the Agent path is not using the built-in free agent model.ConfigManager.is_agent_api_ready()documents that Agent free/quota UI state is supposed to come fromis_agent_free(), and the main router still usescfg.is_agent_free()for the same field, so this endpoint can re-enable the free-agent quota/warning behavior incorrectly for paid Agent setups.Useful? React with 👍 / 👎.