新测试页面,复用旧VLLM启动ASR服务

main
Bifang 2026-09-29 16:46:42 +08:00
parent e4282a747c
commit adc35b6458
7 changed files with 297 additions and 240 deletions

3
.gitignore vendored
View File

@ -19,7 +19,7 @@ __pycache__/
*.py[cod]
*$py.class
*.so
crg-mcp-plugin
crg-mcp-plugin/
# Model files (downloaded)
*.pt
@ -47,3 +47,4 @@ Thumbs.db
*.temp
.cache/
.code-review-graph/
.pi-crg-mcp/

View File

@ -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.

View File

@ -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

View File

@ -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_<tool>`. 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 };

View File

@ -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"
]
}

View File

@ -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 { }

View File

@ -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'(<input type="file" id="audioFile" accept=")[^"]*(" hidden>)',
r"\1.pcm,.wav\2",
html,
)
html = re.sub(
r'(<span class="file-info" id="fileInfo">).*?(</span>)',
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()