Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 150 additions & 2 deletions app/agent_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

import mimetypes
import json
from collections.abc import Mapping
mimetypes.add_type("application/javascript", ".js")
import asyncio
import uuid
Expand Down Expand Up @@ -1342,8 +1343,6 @@ 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()}
except Exception as e:
return {"ready": False, "reasons": [f"Agent API check failed: {e}"], "is_free_version": False}
Expand Down Expand Up @@ -1650,6 +1649,141 @@ 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.server.application.plugins.voice_contracts import (
arbitrate_voice_transcript_results,
)

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)
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 asyncio.CancelledError:
raise
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":
Expand All @@ -1662,6 +1796,20 @@ 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":
seen_task = asyncio.create_task(
_emit_main_event(
"voice_bridge_request_seen",
event.get("lanlan_name") if isinstance(event.get("lanlan_name"), str) else None,
event_id=str(event.get("event_id") or ""),
)
)
Modules._background_tasks.add(seen_task)
seen_task.add_done_callback(Modules._background_tasks.discard)
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")
Expand Down
17 changes: 14 additions & 3 deletions app/main_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_request_seen, 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
Expand Down Expand Up @@ -608,6 +608,17 @@ 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

if event_type == "voice_bridge_request_seen":
notify_voice_bridge_request_seen(str(event.get("event_id") or ""))
return

# Agent status updates may be broadcast (lanlan_name omitted).
if event_type == "agent_status_update":
payload = {
Expand Down Expand Up @@ -1621,6 +1632,7 @@ async def get_response(self, path, scope):
# --- 初始化共享状态并挂载路由 ---
# 显式从各子模块导入 router,避免与包级模块导出产生同名遮蔽。
from main_routers.agent_router import router as agent_router # noqa
from main_routers.card_assist_router import router as card_assist_router # noqa
from main_routers.capture_router import router as capture_router # noqa
from main_routers.characters_router import router as characters_router # noqa
from main_routers.cloudsave_router import router as cloudsave_router # noqa
Expand All @@ -1641,7 +1653,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

Expand Down Expand Up @@ -1773,8 +1784,8 @@ 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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restore the card-assist router mount

When the main app is started, the four /api/card-assist/* endpoints defined in main_routers/card_assist_router.py are no longer included because the router import/include was removed from this router list. I checked the repo and these endpoints are still referenced by card-assist tests and the Character Card Manager feature, so users clicking the AI-assisted card authoring flow will now get 404s from the real app/main_server.py app rather than reaching the existing handlers.

Useful? React with 👍 / 👎.

app.include_router(card_assist_router)
app.include_router(cookies_login_router) # Cookies登录相关路由,放在最后以避免与其他API路由冲突
app.include_router(debug_router) # 诊断观测:/api/debug/health(轻量、零侵入,详见 debug_router.py 头注释)
app.include_router(pages_router) # 兜底路由需最后挂载
Expand Down
11 changes: 8 additions & 3 deletions frontend/plugin-manager/scripts/check-hosted-tsx.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, isAbsolute, join, resolve } from 'node:path'
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import process from 'node:process'
import ts from 'typescript'
Expand Down Expand Up @@ -189,8 +189,12 @@ function createCheckFile(entryPath, tempDir, index, surface, tomlPath) {
const stripped = source
.replace(/^\s*import[\s\S]*?from\s+['"](?:@neko\/plugin-ui|neko:ui)['"]\s*;?\s*/gm, '')
.replace(/^\s*import\s+['"](?:@neko\/plugin-ui|neko:ui)['"]\s*;?\s*/gm, '')
const checkPath = join(tempDir, `surface-${index}.tsx`)
const relativeEntryPath = relative(repoRoot, entryPath)
const checkPath = relativeEntryPath.startsWith('..') || isAbsolute(relativeEntryPath)
? join(tempDir, `surface-${index}.tsx`)
: join(tempDir, relativeEntryPath)
const prefixLines = 6
mkdirSync(dirname(checkPath), { recursive: true })
writeFileSync(
checkPath,
`/// <reference path="${hostedUiGlobalsPath}" />\nimport * as NekoUi from "@neko/plugin-ui";\nimport type { PluginSurfaceProps, HostedAction, JsonSchema, HostedApi } from "@neko/plugin-ui";\nconst { ${[
Expand Down Expand Up @@ -276,6 +280,7 @@ function main() {
target: ts.ScriptTarget.ES2020,
moduleResolution: ts.ModuleResolutionKind.Bundler,
baseUrl: repoRoot,
rootDirs: [tempDir, repoRoot],
paths: {
'@neko/plugin-ui': ['plugin/sdk/hosted-ui'],
},
Expand Down
Loading
Loading