新测试页面,复用旧VLLM启动ASR服务
parent
e4282a747c
commit
adc35b6458
|
|
@ -19,7 +19,7 @@ __pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
*$py.class
|
*$py.class
|
||||||
*.so
|
*.so
|
||||||
crg-mcp-plugin
|
crg-mcp-plugin/
|
||||||
|
|
||||||
# Model files (downloaded)
|
# Model files (downloaded)
|
||||||
*.pt
|
*.pt
|
||||||
|
|
@ -47,3 +47,4 @@ Thumbs.db
|
||||||
*.temp
|
*.temp
|
||||||
.cache/
|
.cache/
|
||||||
.code-review-graph/
|
.code-review-graph/
|
||||||
|
.pi-crg-mcp/
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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 };
|
|
||||||
|
|
@ -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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -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 { }
|
|
||||||
|
|
@ -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()
|
||||||
Loading…
Reference in New Issue