From adc35b6458e3e84a454e6c2329232bd9a8a273ca Mon Sep 17 00:00:00 2001 From: Bifang <915779419@qq.com> Date: Tue, 29 Sep 2026 16:46:42 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E6=B5=8B=E8=AF=95=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=EF=BC=8C=E5=A4=8D=E7=94=A8=E6=97=A7VLLM=E5=90=AF=E5=8A=A8ASR?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 +- .pi-crg-mcp/README.md | 36 ---- .pi-crg-mcp/crg.cmd | 57 ----- .pi-crg-mcp/main.js | 16 -- .pi-crg-mcp/manifest.json | 33 --- .pi-crg-mcp/selfcheck.ps1 | 96 --------- scripts/test_qwen_ws_with_asr_demo.py | 294 ++++++++++++++++++++++++++ 7 files changed, 297 insertions(+), 240 deletions(-) delete mode 100644 .pi-crg-mcp/README.md delete mode 100644 .pi-crg-mcp/crg.cmd delete mode 100644 .pi-crg-mcp/main.js delete mode 100644 .pi-crg-mcp/manifest.json delete mode 100644 .pi-crg-mcp/selfcheck.ps1 create mode 100644 scripts/test_qwen_ws_with_asr_demo.py diff --git a/.gitignore b/.gitignore index 4e9f0d9..847e763 100644 --- a/.gitignore +++ b/.gitignore @@ -19,7 +19,7 @@ __pycache__/ *.py[cod] *$py.class *.so -crg-mcp-plugin +crg-mcp-plugin/ # Model files (downloaded) *.pt @@ -46,4 +46,5 @@ Thumbs.db *.tmp *.temp .cache/ -.code-review-graph/ \ No newline at end of file +.code-review-graph/ +.pi-crg-mcp/ \ No newline at end of file diff --git a/.pi-crg-mcp/README.md b/.pi-crg-mcp/README.md deleted file mode 100644 index b29041b..0000000 --- a/.pi-crg-mcp/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# Code Review Graph MCP - -Generated from the `agent-tool-basic` template. - -## Contributions - -- Agent tool `echo_text` - -## Develop - -1. Open the Plugins page and use **Load development plugin**, pointing at this - directory. PI-Desktop reloads the plugin whenever you save a file here. -2. Verify the contributions from the command palette. -3. Validate and package: - -```bash -pnpm pi-plugin check . -pnpm pi-plugin pack . -# writes dist/crg-mcp-0.1.0.piplug -``` - -Install the resulting `.piplug` from the Plugins page to test it the way a -user would. - -### Panel top drag band - -PI-Desktop reserves exactly a transparent 46px frameless drag band above panel -content and renders a minimal fixed three-button window-control capsule in its -top-right corner. Normal-flow content is offset automatically. The panel title, -toolbar, and every other visible surface belong to the plugin. Development -panels show a reminder that the top 46px is not clickable outside the capsule. -For `position: fixed` or `position: sticky` content, use -`top: var(--pi-plugin-titlebar-height, 46px)` and account for the same value -in viewport-height calculations. Add `-webkit-app-region: drag` to a -plugin-owned toolbar when it should move the window, and -`-webkit-app-region: no-drag` to controls inside it. diff --git a/.pi-crg-mcp/crg.cmd b/.pi-crg-mcp/crg.cmd deleted file mode 100644 index 4b695f5..0000000 --- a/.pi-crg-mcp/crg.cmd +++ /dev/null @@ -1,57 +0,0 @@ -@echo off -chcp 65001 >nul -setlocal - -rem --------------------------------------------------------------------------- -rem code-review-graph MCP launcher for PI-Desktop. -rem -rem The MCP host spawns this with a minimal environment (PATH, SystemRoot, -rem windir, TEMP, TMP, LANG plus the manifest's env block) and cwd = plugin -rem directory, with stdin/stdout used for JSON-RPC. Never write to stdout. -rem --------------------------------------------------------------------------- - -if defined CRG_HOME goto have_home -set "CRG_HOME=D:\github-project\code-review-graph" -:have_home - -rem code_review_graph/constants.py calls Path.home() at import time; without a -rem user profile the server dies with "Could not determine home directory.". -if defined USERPROFILE goto have_profile -set "USERPROFILE=C:\Users\%USERNAME%" -:have_profile -if defined HOMEDRIVE goto have_hd -set "HOMEDRIVE=C:" -:have_hd -if defined HOMEPATH goto have_hp -set "HOMEPATH=\Users\%USERNAME%" -:have_hp -if defined APPDATA set "APPDATA=%USERPROFILE%\AppData\Roaming" -if defined LOCALAPPDATA set "LOCALAPPDATA=%USERPROFILE%\AppData\Local" - -set "PYTHONUTF8=1" -set "PYTHONIOENCODING=utf-8" - -rem The editable install's .pth points at D:\github_project\... (underscore) -rem while the checkout lives at D:\github-project\... (hyphen), so the package -rem is only importable with the checkout explicitly on sys.path. -set "PYTHONPATH=%CRG_HOME%" - -rem The venv's code-review-graph.exe is a broken uv trampoline -rem ("failed to canonicalize script path"), so prefer a working interpreter. -set "CRG_PY=%CRG_HOME%\.venv\Scripts\python.exe" -if exist "%CRG_PY%" goto py_ready -set "CRG_PY=python" -:py_ready - -rem Upstream ships 30 tools; keep the surface small unless overridden. -if defined CRG_TOOLS goto tools_ready -set "CRG_TOOLS=build_or_update_graph_tool,run_postprocess_tool,get_minimal_context_tool,get_review_context_tool,get_impact_radius_tool,query_graph_tool,semantic_search_nodes_tool,detect_changes_tool,list_graph_stats_tool,get_affected_flows_tool" -:tools_ready - -if defined CRG_REPO goto repo_ready -set "CRG_REPO=%CRG_HOME%" -:repo_ready - -"%CRG_PY%" -m code_review_graph serve --repo "%CRG_REPO%" - -endlocal diff --git a/.pi-crg-mcp/main.js b/.pi-crg-mcp/main.js deleted file mode 100644 index 3930880..0000000 --- a/.pi-crg-mcp/main.js +++ /dev/null @@ -1,16 +0,0 @@ -/** - * crg-mcp — thin loader for the code-review-graph MCP bridge. - * - * All wiring lives in manifest.json under `contributes.mcpServers`: the host - * spawns `crg.cmd`, speaks MCP over its stdio, and publishes each upstream tool - * as `plugin_crg_mcp_crg_`. The host owns the client, the retry on the - * next call, and the tool lifecycle, so there is nothing to register here. - */ - -async function onLoad() { - // Intentionally empty: the MCP client is owned by the host. -} - -async function onUnload() {} - -module.exports = { onLoad, onUnload }; diff --git a/.pi-crg-mcp/manifest.json b/.pi-crg-mcp/manifest.json deleted file mode 100644 index 44bfe1f..0000000 --- a/.pi-crg-mcp/manifest.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "schemaVersion": 1, - "id": "crg-mcp", - "name": "Code Review Graph MCP", - "version": "1.0.0", - "description": "Bridges the locally deployed code-review-graph MCP server into the agent as native tools.", - "main": "main.js", - "contributes": { - "mcpServers": [ - { - "id": "crg", - "label": "Code Review Graph", - "transport": "stdio", - "command": "crg.cmd", - "env": { - "PYTHONPATH": "D:\\github-project\\code-review-graph", - "USERPROFILE": "C:\\Users\\admin", - "HOMEDRIVE": "C:", - "HOMEPATH": "\\Users\\admin" - } - } - ] - }, - "permissions": [ - "mcp.server.local" - ], - "engines": { - "piDesktop": ">=0.1.0" - }, - "activationEvents": [ - "onStartup" - ] -} diff --git a/.pi-crg-mcp/selfcheck.ps1 b/.pi-crg-mcp/selfcheck.ps1 deleted file mode 100644 index 5270319..0000000 --- a/.pi-crg-mcp/selfcheck.ps1 +++ /dev/null @@ -1,96 +0,0 @@ -<# - Self-check for the crg-mcp plugin. - - Drives crg.cmd exactly the way the PI-Desktop MCP host does — `cmd /c crg.cmd` - with piped stdio and cwd = this folder — then reports the MCP handshake, the - tool list, and one real tool call. Run it from PowerShell: - - powershell -NoProfile -ExecutionPolicy Bypass -File .\selfcheck.ps1 -#> -$ErrorActionPreference = 'Stop' - -$dir = Split-Path -Parent $MyInvocation.MyCommand.Path - -# Mirror the host's minimal environment plus the manifest's env block. -foreach ($k in 'PATH', 'SystemRoot', 'TEMP', 'TMP') { - if (-not (Test-Path "Env:$k")) { Write-Warning "missing $k in ambient env" } -} - -$psi = New-Object System.Diagnostics.ProcessStartInfo -$psi.FileName = 'cmd.exe' -$psi.Arguments = '/c crg.cmd' -$psi.WorkingDirectory = $dir -$psi.RedirectStandardInput = $true -$psi.RedirectStandardOutput = $true -$psi.RedirectStandardError = $true -$psi.UseShellExecute = $false -$psi.StandardOutputEncoding = [System.Text.Encoding]::UTF8 - -$proc = [System.Diagnostics.Process]::Start($psi) - -function Send($obj) { - $proc.StandardInput.WriteLine(($obj | ConvertTo-Json -Compress -Depth 8)) - $proc.StandardInput.Flush() -} - -function ReadLine([int]$waitSeconds = 25) { - $task = $proc.StandardOutput.ReadLineAsync() - if ($task.Wait([TimeSpan]::FromSeconds($waitSeconds))) { return $task.Result } - return $null -} - -Send @{ - jsonrpc = '2.0'; id = 1; method = 'initialize' - params = @{ - protocolVersion = '2025-06-18' - capabilities = @{} - clientInfo = @{ name = 'crg-selfcheck'; version = '1' } - } -} - -$initLine = ReadLine 40 -if (-not $initLine) { - Write-Host 'HANDSHAKE FAILED: no stdout from crg.cmd' -ForegroundColor Red - Write-Host '--- stderr ---' - Write-Host $proc.StandardError.ReadToEnd() - try { $proc.Kill() } catch { } - exit 1 -} - -$init = $initLine | ConvertFrom-Json -Write-Host ("HANDSHAKE OK server={0} {1}" -f $init.result.serverInfo.name, $init.result.serverInfo.version) -ForegroundColor Green - -Send @{ jsonrpc = '2.0'; method = 'notifications/initialized'; params = @{} } -Send @{ jsonrpc = '2.0'; id = 2; method = 'tools/list'; params = @{} } - -$toolsLine = ReadLine -if (-not $toolsLine) { - Write-Host 'tools/list returned nothing' -ForegroundColor Red - try { $proc.Kill() } catch { } - exit 1 -} -$tools = ($toolsLine | ConvertFrom-Json).result.tools -Write-Host ("TOOLS: {0}" -f $tools.Count) -ForegroundColor Green -foreach ($t in $tools) { Write-Host (" plugin_crg_mcp_crg_{0}" -f $t.name) } - -Send @{ - jsonrpc = '2.0'; id = 3; method = 'tools/call' - params = @{ name = 'list_graph_stats_tool'; arguments = @{} } -} -$callLine = ReadLine 40 -if ($callLine) { - $call = $callLine | ConvertFrom-Json - if ($call.result) { - $text = $call.result.content[0].text - Write-Host 'TOOL CALL OK' -ForegroundColor Green - Write-Host (' ' + ($text -split "`n")[0..3] -join ' | ') - } - else { - Write-Host ("TOOL CALL ERROR: {0}" -f ($call | ConvertTo-Json -Compress -Depth 6)) -ForegroundColor Red - } -} -else { - Write-Host 'TOOL CALL: no response' -ForegroundColor Red -} - -try { $proc.Kill() } catch { } diff --git a/scripts/test_qwen_ws_with_asr_demo.py b/scripts/test_qwen_ws_with_asr_demo.py new file mode 100644 index 0000000..0712db6 --- /dev/null +++ b/scripts/test_qwen_ws_with_asr_demo.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Serve the Asr-demo UI locally and bridge it to Qwen-ASR's realtime WS API. + +Run from the Qwen-Asr repository root, for example: + + python scripts/test_qwen_ws_with_asr_demo.py \ + --backend-url ws://127.0.0.1:33050/ws/v1/asr/qwen + +The default frontend directory points to the sibling Asr-demo checkout. The +checkout itself is never modified; the served HTML gets a small Qwen label and +file-type adjustment, while this bridge translates the legacy protocol. +Requires the `websockets` package, which is already listed by both projects. +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import logging +import mimetypes +import re +from http import HTTPStatus +from pathlib import Path +from typing import Any +from urllib.parse import unquote, urlsplit + +try: + from websockets.exceptions import ConnectionClosed + from websockets.legacy.client import connect + from websockets.legacy.server import serve +except ImportError as exc: + raise SystemExit( + "Missing websockets. Activate the Qwen-Asr or Asr-demo Python environment, " + "or install it with `python -m pip install websockets`." + ) from exc + +LOGGER = logging.getLogger("qwen_ws_frontend_test") +DEFAULT_BACKEND_URL = "ws://127.0.0.1:33050/ws/v1/asr/qwen" +DEFAULT_FRONTEND_DIR = ( + Path(__file__).resolve().parents[1].parent / "Asr-demo" / "frontend" / "static" +) + + +def _as_bool(value: Any) -> bool: + """Normalize the 0/1 values emitted by the existing browser UI.""" + if isinstance(value, str): + return value.strip().lower() not in {"", "0", "false", "no", "off"} + return bool(value) + + +def _qwen_start_message(frontend_message: dict[str, Any]) -> dict[str, Any]: + """Translate Asr-demo's flat start options into Qwen-ASR's payload shape.""" + source = str(frontend_message.get("source", "mic")).lower() + filename = str(frontend_message.get("file_name", "")) + suffix = Path(filename).suffix.lower() + + # Qwen's current websocket decoder accepts raw PCM16 or a WAV container. + if source == "mic" or suffix == ".pcm": + audio_format = "pcm" + elif suffix == ".wav": + audio_format = "wav" + else: + raise ValueError( + "Qwen WebSocket test input must be microphone audio, .pcm, or .wav; " + f"received {filename or source!r}." + ) + + payload: dict[str, Any] = { + "format": audio_format, + "sample_rate": 16000, + "enable_speaker": _as_bool(frontend_message.get("speaker_diarization", 1)), + "enable_speaker_identification": False, + "enable_native_partial_stream": True, + } + + # The reused UI exposes hotwords as a comma-separated string; Qwen accepts + # this text through its realtime context prompt. + hotwords = str(frontend_message.get("hotwords", "")).strip() + if hotwords: + payload["context"] = hotwords + + return {"type": "start", "payload": payload} + + +async def _relay_browser_to_qwen(browser: Any, qwen: Any) -> None: + """Forward audio frames and adapt the legacy UI's control messages.""" + audio_format = "pcm" + wav_buffer = bytearray() + + async for event in browser: + if isinstance(event, str): + message = json.loads(event) + msg_type = message.get("type") + if msg_type == "start": + try: + message = _qwen_start_message(message) + except ValueError as exc: + await browser.send(json.dumps({"type": "error", "message": str(exc)})) + return + audio_format = message["payload"]["format"] + elif msg_type == "eof": + # The demo signals end-of-file; Qwen finalizes a stream on stop. + if audio_format == "wav" and wav_buffer: + # Keep the WAV container intact; arbitrary network chunks do + # not each contain a WAV header and cannot be decoded alone. + await qwen.send(bytes(wav_buffer)) + wav_buffer.clear() + message = {"type": "stop"} + elif msg_type == "stop" and audio_format == "wav" and wav_buffer: + await qwen.send(bytes(wav_buffer)) + wav_buffer.clear() + await qwen.send(json.dumps(message, ensure_ascii=False)) + continue + + if isinstance(event, bytes): + if audio_format == "wav": + wav_buffer.extend(event) + else: + await qwen.send(event) + + +async def _relay_qwen_to_browser(qwen: Any, browser: Any) -> None: + """Pass Qwen-ASR's voice_id/start/sentences/end/error events to the UI.""" + async for message in qwen: + await browser.send(message) + + +async def _bridge_websocket(browser: Any, backend_url: str) -> None: + try: + async with connect(backend_url, max_size=None) as qwen: + LOGGER.info("Connected to Qwen-ASR WebSocket: %s", backend_url) + tasks = { + asyncio.create_task(_relay_browser_to_qwen(browser, qwen)), + asyncio.create_task(_relay_qwen_to_browser(qwen, browser)), + } + done, pending = await asyncio.wait( + tasks, + return_when=asyncio.FIRST_COMPLETED, + ) + for task in pending: + task.cancel() + await asyncio.gather(*pending, return_exceptions=True) + + # Surface real relay failures while treating disconnects as normal. + for task in done: + error = task.exception() + if error and not isinstance(error, ConnectionClosed): + raise error + except ConnectionClosed: + pass + except Exception as exc: + LOGGER.exception("WebSocket bridge failed") + try: + await browser.send(json.dumps({"type": "error", "message": str(exc)})) + except Exception: + pass + finally: + try: + await browser.close(code=1000) + except Exception: + pass + + +def _qwen_compatible_index(frontend_dir: Path) -> bytes: + """Adjust labels and selectable formats in the served copy of index only.""" + html = (frontend_dir / "index.html").read_text(encoding="utf-8") + html = html.replace( + 'id="engineModel" value="16k_zh_en_speaker" readonly', + 'id="engineModel" value="Qwen3-ASR realtime" readonly', + ) + html = re.sub( + r'()', + r"\1.pcm,.wav\2", + html, + ) + html = re.sub( + r'().*?()', + r"\1Mic streams live; files: 16 kHz mono PCM or WAV (sent after upload).\2", + html, + ) + return html.encode("utf-8") + + +def _make_http_handler(frontend_dir: Path): + async def process_request(path: str, headers: Any): + parsed = urlsplit(path) + if parsed.path == "/ws" and headers.get("Upgrade", "").lower() == "websocket": + return None + + if parsed.path == "/ws": + return ( + HTTPStatus.UPGRADE_REQUIRED, + [("Content-Type", "text/plain")], + b"WebSocket upgrade required", + ) + + if parsed.path == "/api/stop": + # The UI also sends stop over WebSocket; avoid its legacy 404 fetch. + return ( + HTTPStatus.OK, + [("Content-Type", "application/json")], + b'{"status":"ok","message":"Stop is sent over the WebSocket."}', + ) + + if parsed.path == "/": + body = _qwen_compatible_index(frontend_dir) + content_type = "text/html; charset=utf-8" + else: + asset_path = (frontend_dir / unquote(parsed.path).lstrip("/")).resolve() + if not asset_path.is_relative_to(frontend_dir): + return HTTPStatus.FORBIDDEN, [], b"Forbidden" + if not asset_path.is_file(): + return HTTPStatus.NOT_FOUND, [], b"Not found" + body = asset_path.read_bytes() + content_type = mimetypes.guess_type(asset_path.name)[0] or "application/octet-stream" + if content_type.startswith("text/") or content_type in { + "application/javascript", + "application/json", + }: + content_type += "; charset=utf-8" + + return HTTPStatus.OK, [("Content-Type", content_type)], body + + return process_request + + +async def run_server(frontend_dir: Path, backend_url: str, host: str, port: int) -> None: + async def handle_frontend_socket(browser: Any) -> None: + if urlsplit(browser.path).path != "/ws": + await browser.close(code=1008, reason="Use /ws") + return + await _bridge_websocket(browser, backend_url) + + async with serve( + handle_frontend_socket, + host, + port, + process_request=_make_http_handler(frontend_dir), + max_size=None, + ): + LOGGER.info("Open Asr-demo UI at http://127.0.0.1:%s", port) + LOGGER.info("Forwarding realtime ASR to %s", backend_url) + await asyncio.Future() + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Serve the Asr-demo frontend and connect it to Qwen-ASR realtime WS." + ) + parser.add_argument( + "--backend-url", + default=DEFAULT_BACKEND_URL, + help=f"Qwen-ASR websocket URL (default: {DEFAULT_BACKEND_URL})", + ) + parser.add_argument( + "--frontend-dir", + type=Path, + default=DEFAULT_FRONTEND_DIR, + help="Asr-demo frontend/static directory; defaults to the sibling checkout.", + ) + parser.add_argument( + "--host", + default="127.0.0.1", + help="Local UI bind address. Keep localhost for browser microphone permission.", + ) + parser.add_argument("--port", type=int, default=8188, help="Local UI HTTP port.") + parser.add_argument( + "--log-level", + choices=("critical", "error", "warning", "info", "debug"), + default="info", + ) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + frontend_dir = args.frontend_dir.resolve() + if not (frontend_dir / "index.html").is_file(): + raise SystemExit( + f"Asr-demo frontend not found at {frontend_dir}. " + "Pass --frontend-dir with its frontend/static directory." + ) + + logging.basicConfig(level=getattr(logging, args.log_level.upper())) + try: + asyncio.run(run_server(frontend_dir, args.backend_url, args.host, args.port)) + except KeyboardInterrupt: + LOGGER.info("Frontend test bridge stopped.") + + +if __name__ == "__main__": + main()