From 9bdce6499036e95f9bd73a17a64f91ff1c17ae9f Mon Sep 17 00:00:00 2001 From: zhifu gao Date: Mon, 7 Sep 2026 11:54:11 +0000 Subject: [PATCH] docs: restore maintained bilingual Agent integration routes Signed-off-by: zhifu gao --- docs/README.md | 1 + docs/agent_integration.md | 224 ++++++++++++++++++ docs/agent_integration_zh.md | 201 ++++++++++++++++ docs/index.rst | 2 + .../product-site/data/documentation.json | 1 + .../product-site/data/legacy_doc_anchors.json | 16 ++ web-pages/product-site/export_docs.py | 1 + .../tests/browser/documentation.spec.ts | 34 +++ .../product-site/tests/test_documentation.py | 90 ++++++- .../tests/test_legacy_doc_anchors.py | 13 + 10 files changed, 582 insertions(+), 1 deletion(-) create mode 100644 docs/agent_integration.md create mode 100644 docs/agent_integration_zh.md diff --git a/docs/README.md b/docs/README.md index c672eb65b..292ea9834 100644 --- a/docs/README.md +++ b/docs/README.md @@ -44,6 +44,7 @@ Python SDK, Python HTTP, native vLLM HTTP and C++ WebSocket are different interfaces. Choose the matching protocol before selecting a client. +- [Agent integration](agent_integration.md) / [中文](agent_integration_zh.md): HTTP, MCP, desktop recording and local subtitles - [HTTP schema](../examples/openai_api/OPENAPI.md), [JavaScript](../examples/openai_api/JAVASCRIPT.md), [workflow integration](../examples/openai_api/WORKFLOWS.md) - [C++ WebSocket protocol](../runtime/docs/websocket_protocol.md) and [ONNX binary output](../runtime/docs/onnxruntime_binary_output.md) - [HTTP security](../examples/openai_api/SECURITY.md) and [Kubernetes manifests](../examples/openai_api/kubernetes/README.md) diff --git a/docs/agent_integration.md b/docs/agent_integration.md new file mode 100644 index 000000000..f72a30080 --- /dev/null +++ b/docs/agent_integration.md @@ -0,0 +1,224 @@ +# FunASR Agent Integration + +[中文](agent_integration_zh.md) + +Connect a speech application through the interface it actually needs: an HTTP +file-transcription request, a local MCP tool, desktop recording, or a local +subtitle pipeline. These paths do not share every model, option, or result field. +For direct in-process inference, use the [Python SDK](python_api.md). + +## HTTP server + +The following source-based starting point includes the example scripts used +later on this page. The commands use a POSIX shell. Use a new checkout and virtual environment; installing the +PyPI package alone does not install these repository examples. + +```bash +git clone https://github.com/modelscope/FunASR.git FunASR-agent +cd FunASR-agent +git checkout --detach e19029adca384a06a2f60bd8c18cb98f1a0499aa +python3.11 -m venv .venv +source .venv/bin/activate +python -m pip install -e . +python -m pip install fastapi uvicorn python-multipart +python -m pip check +``` + +This pins the source, not all dependencies or model weights. Use the +[installation guide](installation/installation.md) to prepare the selected +CPU/GPU environment, record resolved package/model versions, and validate a +real request. `pip check` alone is not a CUDA, audio-decoder, or clean-install test. + +From that environment, run **one** of these commands. The CPU recipe selects +SenseVoice explicitly; the CUDA alternative needs a working GPU environment. +Keep this terminal running and use another prepared terminal for clients. + +```bash +funasr-server --host 127.0.0.1 --device cpu --model sensevoice --port 8000 +# Alternative: stop the CPU server before using the same port. +funasr-server --host 127.0.0.1 --device cuda --model sensevoice --port 8000 +``` + +```bash +curl -fsS http://localhost:8000/health +curl -fsS http://localhost:8000/v1/models +``` + +Use `/v1/audio/transcriptions` for uploaded files, `/openapi.json` for the running +service schema, and `/docs` for its Swagger UI. This service `/docs` is not the +FunASR website's documentation directory. Health or model-list responses do not +replace a successful transcription with the intended model. + +The packaged `funasr-server` and [example HTTP server](../examples/openai_api/README.md#api-contract) +have different defaults, aliases, and response schemas. Specify `model` in the +request as well as at startup. `paraformer-en` is an example-server alias, not a +built-in packaged-server alias. SenseVoice HTTP display text has rich tags removed; +it is not a dedicated emotion/event output API. Nano and MLT-Nano language coverage +and timestamp paths must be selected through the [model guide](model_selection.md), +not inferred from the HTTP interface. For a custom packaged-server model, use +`--model-path` with the appropriate `--hub` and request `model=custom`; an +arbitrary model ID is not automatically a built-in `--model` alias. + +[MOSS-Transcribe-Diarize](moss_transcribe_diarize.md) is a third-party OpenMOSS +model with its own deployment requirements. Its native anonymous speaker labels +do not require external VAD or an external speaker model; do not add those stages +or assume every client exposes all of its output. + +The local server does not authenticate the placeholder SDK key below. Before +network access, add TLS, authentication, upload limits and rate limits according +to the [security guide](../examples/openai_api/SECURITY.md). CORS is not authentication. + +## SDK and curl + +Install the separate OpenAI HTTP client in the client environment: + +```bash +python -m pip install openai +``` + +```python +from openai import OpenAI + +client = OpenAI(base_url="http://localhost:8000/v1", api_key="local-development") +with open("meeting.wav", "rb") as audio: + result = client.audio.transcriptions.create( + model="sensevoice", + file=audio, + response_format="verbose_json", + ) +print(result.text) +for segment in getattr(result, "segments", []): + print(segment) +``` + +```bash +curl -fsS http://localhost:8000/v1/audio/transcriptions \ + -F file=@audio.wav \ + -F model=sensevoice \ + -F response_format=verbose_json +``` + +`verbose_json` chooses a format; it does not enable diarization, recover rich +tags, or guarantee word alignment. The example server copies available +`sentence_info` into segments, otherwise returning an empty list. The packaged +server can supply coarse fallback segments. Both expose segment times in seconds, +but their `duration` fields differ. Use the [client result contract](../examples/openai_api/CLIENTS.md#response-formats) +before consuming timestamps. `json` and `text` are simpler response choices. +Speaker processing via `spk=true` belongs to the packaged API, not the example's +request schema; see [speaker labels and identity limits](speaker_emotion.md). + +## Workflow integrations + +Use a multipart HTTP node with method `POST`, the transcription endpoint, a +binary file field named `file`, and text fields `model` and `response_format`. +A file URL in the `file` field is not equivalent to uploading audio bytes. +For Dify/n8n running in a container, `localhost` means that container, not the +FunASR host; configure a reachable service address behind the intended gateway. + +- [Dify, n8n and webhook workers](../examples/openai_api/WORKFLOWS.md) provide request wiring examples. +- [JavaScript and TypeScript](../examples/openai_api/JAVASCRIPT.md) cover SDK and multipart clients. +- [Postman](../examples/openai_api/POSTMAN.md) and the [smoke test](../examples/openai_api/smoke_test.py) check a deployed endpoint. +- [Gradio](../examples/openai_api/GRADIO.md) provides a browser upload/microphone example. +- [OpenAPI](../examples/openai_api/OPENAPI.md) distinguishes the checked-in example schema from the deployed packaged schema. + +Register transcription as a tool in the host framework using these request and +result boundaries. The URL worker example does not implement a complete secure +downloader: add destination allowlists, private-network blocking, redirect +validation, size limits and timeouts before accepting untrusted URLs. Use the +client result contract above instead of assuming the workflow field table fits +both server implementations. These recipes are not proof that every framework version has +been integrated or that untrusted download URLs are safe. + +## MCP server + +From the prepared repository root and environment: + +```bash +python examples/mcp_server/funasr_mcp.py +``` + +Prepare PyTorch and a compatible audio-feature backend using the installation +guide before attempting transcription. Installing the toolkit or completing an +MCP handshake alone does not verify model execution; this script does not need +a separate MCP SDK package. An MCP client launches it over stdio, not HTTP. +Configure absolute paths to the prepared Python environment and checkout: + +```json +{ + "mcpServers": { + "funasr": { + "command": "/path/to/FunASR-agent/.venv/bin/python", + "args": ["/path/to/FunASR-agent/examples/mcp_server/funasr_mcp.py"], + "env": { + "FUNASR_DEVICE": "cpu", + "FUNASR_MODEL": "iic/SenseVoiceSmall" + } + } + } +} +``` + +`transcribe_audio` accepts an existing local `audio_path` visible to the server, +including a read-only mounted path in a container. It does not accept URLs or +live streams. The first call may download and load weights. The language hints +are `auto`, `zh`, `yue`, `en`, `ja`, and `ko`; setting `FUNASR_MODEL` does not +change that tool schema or guarantee another model is compatible with its VAD path. + +The result is formatted MCP `content` with `type=text`, optionally including +segments, not the HTTP response object. Top-level transcript rich tags are +removed; optional segment text is copied from model output. `FUNASR_DEVICE` +defaults to `cpu`, and `FUNASR_MODEL` defaults to `iic/SenseVoiceSmall`. +See the [MCP source and container setup](../examples/mcp_server/README.md) for +client configuration and filesystem mounts. Control which files the assistant +and server can access; a local tool is not a filesystem authorization boundary. + +## Desktop voice input + +With the HTTP server already running, use a second terminal in the prepared checkout: + +```bash +python -m pip install sounddevice numpy pyperclip openai pynput +python examples/voice_input/funasr_input.py --server http://localhost:8000/v1 --model sensevoice +``` + +The script toggles recording, uploads WAV audio to the HTTP service, and copies +the transcript for pasting. Microphone permissions and audio-device support are +required; macOS may also require accessibility permissions, and Linux automatic +paste uses `xdotool`. Clipboard/paste behavior varies by desktop session. +The current `--lang` option is parsed but not forwarded to the transcription +request, so it is not an effective language control in this path. + +A remote `--server` sends the recorded audio to that endpoint. Do not treat this +as an unconditional offline/privacy guarantee or a measured latency promise. +See [configuration](../examples/voice_input/README.md#配置选项) and the +[implementation](../examples/voice_input/funasr_input.py) before deployment. + +## Subtitle generation + +This is a local `AutoModel` pipeline, not an HTTP or MCP client. From the prepared +checkout, with local input files and a suitable inference environment: + +```bash +python examples/subtitle/generate_subtitle.py video.mp4 +python examples/subtitle/generate_subtitle.py meeting.wav --spk +python examples/subtitle/generate_subtitle.py podcast.mp3 --format vtt +python examples/subtitle/generate_subtitle.py audio.wav --device cpu +``` + +The default device is CUDA; the last command explicitly selects CPU. The default +model is SenseVoiceSmall with VAD and punctuation. This fixed pipeline is not a +generic recipe for arbitrary models. `--spk` adds +CAM++ anonymous labels, not verified identities. `--format` selects SRT or VTT; +`--output` chooses a path, and existing output files are overwritten. Choose a +new output path when preserving prior subtitles. `--lang` passes a non-auto language hint to generation. +`--max-single-segment-time` is in milliseconds, with current default `60000`. + +`--segment-mode readable` groups display cues without rewriting recognized +text or punctuation; `sentence` retains raw model sentence grouping. Neither +mode fixes punctuation errors or guarantees phonetic boundaries. Check actual +timestamp availability and playback against the original audio: a missing timing +result can fall back to a zero-duration `(0, 0)` interval, not a validated subtitle. +Input decoding, +model/dependency loading and GPU capacity still require environment-specific +validation. See [subtitle options](../examples/subtitle/README.md#options) and +the [speaker guide](speaker_emotion.md) for output interpretation. diff --git a/docs/agent_integration_zh.md b/docs/agent_integration_zh.md new file mode 100644 index 000000000..829dc0c87 --- /dev/null +++ b/docs/agent_integration_zh.md @@ -0,0 +1,201 @@ +# FunASR Agent 集成 + +[English](agent_integration.md) + +按应用需要选择接口:HTTP 文件转写、本地 MCP 工具、桌面录音或本地字幕流水线。 +这些路径的模型、选项和输出字段并不完全相同。直接在进程内推理请使用 +[Python SDK](python_api_zh.md)。 + +## HTTP 服务 + +以下源码安装起点包含本页后续用到的示例脚本,命令使用 POSIX shell。 +使用新目录和虚拟环境;仅安装 +PyPI 包不会安装这些仓库示例。 + +```bash +git clone https://github.com/modelscope/FunASR.git FunASR-agent +cd FunASR-agent +git checkout --detach e19029adca384a06a2f60bd8c18cb98f1a0499aa +python3.11 -m venv .venv +source .venv/bin/activate +python -m pip install -e . +python -m pip install fastapi uvicorn python-multipart +python -m pip check +``` + +这只固定源码,不会锁定所有依赖和模型权重。按照[安装指南](installation/installation_zh.md) +准备 CPU/GPU 环境,记录实际依赖版本和模型版本,再验证真实请求。 +`pip check` 不能代替 CUDA、音频解码或全新环境安装测试。 + +在该环境中选择以下**一条**命令运行。CPU 示例显式选择 SenseVoice;CUDA 方案需要 +可用的 GPU 环境。保持服务终端运行,在另一个已准备好的终端执行客户端命令。 + +```bash +funasr-server --host 127.0.0.1 --device cpu --model sensevoice --port 8000 +# 备选:使用相同端口前先停止 CPU 服务。 +funasr-server --host 127.0.0.1 --device cuda --model sensevoice --port 8000 +``` + +```bash +curl -fsS http://localhost:8000/health +curl -fsS http://localhost:8000/v1/models +``` + +上传文件使用 `/v1/audio/transcriptions`,运行中服务的 schema 位于 `/openapi.json`, +Swagger UI 位于 `/docs`。这个服务的 `/docs` 不是 FunASR 官网文档目录。 +健康检查或模型列表返回成功,不能代替目标模型的真实转写验收。 + +打包的 `funasr-server` 与[示例 HTTP 服务](../examples/openai_api/README_zh.md#api-contract) +具有不同的默认值、别名和响应结构。启动和请求时都应显式指定 `model`。 +`paraformer-en` 是示例服务的注册别名,不是打包服务内置别名。 +SenseVoice 的 HTTP 展示文本已清除富标签,不是专用的情感或事件输出 API。 +Nano 与 MLT-Nano 的语种和时间戳能力应按[模型选择指南](model_selection_zh.md) +及实际运行路径区分,不能从 HTTP 接口名称推断。打包服务的自定义模型应通过 +`--model-path` 配合正确的 `--hub` 加载,并在请求中指定 `model=custom`; +任意模型 ID 不会自动成为内置 `--model` 别名。 + +[MOSS-Transcribe-Diarize](moss_transcribe_diarize_zh.md) 是 OpenMOSS 的第三方模型, +有独立部署要求。其原生匿名说话人标签不需要外接 VAD 或说话人模型,不要叠加这些阶段, +也不要假设所有客户端都能呈现其全部输出。 + +本地服务不会认证下方 SDK 的占位 key。对外提供网络访问前,按 +[安全指南](../examples/openai_api/SECURITY_zh.md) 配置 TLS、鉴权、上传大小及速率限制。 +CORS 不是身份认证。 + +## SDK 与 curl + +在客户端环境单独安装 OpenAI HTTP 客户端: + +```bash +python -m pip install openai +``` + +```python +from openai import OpenAI + +client = OpenAI(base_url="http://localhost:8000/v1", api_key="local-development") +with open("meeting.wav", "rb") as audio: + result = client.audio.transcriptions.create( + model="sensevoice", + file=audio, + response_format="verbose_json", + ) +print(result.text) +for segment in getattr(result, "segments", []): + print(segment) +``` + +```bash +curl -fsS http://localhost:8000/v1/audio/transcriptions \ + -F file=@audio.wav \ + -F model=sensevoice \ + -F response_format=verbose_json +``` + +`verbose_json` 只选择格式,不会开启说话人分离、恢复富标签或保证词级对齐。 +示例服务把已有 `sentence_info` 转为 segments,没有时返回空列表;打包服务可能生成 +粗粒度回退分段。二者的分段时间均为秒,但 `duration` 字段语义不同。 +使用时间戳前请阅读[客户端输出契约](../examples/openai_api/CLIENTS.md#response-formats)。 +`json` 和 `text` 是更简单的响应格式。`spk=true` 的说话人处理属于打包 API, +不属于示例服务的请求 schema;另见[说话人标签与身份边界](speaker_emotion_zh.md)。 + +## 工作流集成 + +HTTP 节点使用 `POST` 转写端点和 multipart 请求体:二进制文件字段为 `file`, +文本字段为 `model` 与 `response_format`。把音频 URL 写入 `file` 字段不等于上传音频字节。 +当 Dify/n8n 运行在容器中时,`localhost` 指该容器而非 FunASR 主机, +应配置经过预期网关、工作流实际可达的服务地址。 + +- [Dify、n8n 与 webhook worker](../examples/openai_api/WORKFLOWS_zh.md) 提供请求连接示例。 +- [JavaScript 和 TypeScript](../examples/openai_api/JAVASCRIPT_zh.md) 提供 SDK 和 multipart 客户端。 +- [Postman](../examples/openai_api/POSTMAN_zh.md) 与[冒烟测试](../examples/openai_api/smoke_test.py) 检查已部署端点。 +- [Gradio](../examples/openai_api/GRADIO_zh.md) 提供浏览器上传及麦克风示例。 +- [OpenAPI](../examples/openai_api/OPENAPI_zh.md) 区分仓库内示例 schema 与已部署打包服务的 schema。 + +在宿主框架中按这些请求和输出边界注册转写工具。URL worker 示例不是完整的安全下载器; +接受不可信 URL 前,应补充目标白名单、私网阻断、重定向校验、大小限制和超时。 +响应字段以上方客户端契约为准,不要假设工作流字段表适用于两种服务实现。 +这些示例不代表所有框架版本均已完成 +集成验证,也不代表任意不可信下载 URL 都是安全的。 + +## MCP 服务 + +在已准备好的仓库根目录和环境中运行: + +```bash +python examples/mcp_server/funasr_mcp.py +``` + +执行转写前,应按安装指南准备 PyTorch 和兼容的音频特征后端。 +安装工具包或完成 MCP 握手,都不等于模型执行已通过验证;此脚本不需要额外安装 MCP SDK。 +MCP 客户端通过 stdio 启动此脚本,它不是 HTTP 监听服务。 +配置时使用已准备好的 Python 环境与仓库绝对路径: + +```json +{ + "mcpServers": { + "funasr": { + "command": "/path/to/FunASR-agent/.venv/bin/python", + "args": ["/path/to/FunASR-agent/examples/mcp_server/funasr_mcp.py"], + "env": { + "FUNASR_DEVICE": "cpu", + "FUNASR_MODEL": "iic/SenseVoiceSmall" + } + } + } +} +``` + +`transcribe_audio` 接受服务端可见、已存在的本地 `audio_path`,也可以是容器中 +只读挂载的路径,不接受 URL 或实时流。首次调用可能下载并加载权重。 +语种提示为 `auto`、`zh`、`yue`、`en`、`ja`、`ko`;设置 `FUNASR_MODEL` 不会改变 +工具 schema,也不能保证另一模型兼容该工具的 VAD 路径。 + +结果被格式化为 MCP `content` 中的 `type=text`,可包含分段,而不是 HTTP 响应对象。 +顶层转写文本会清除富标签;可选的分段文本来自模型输出。 +`FUNASR_DEVICE` 默认为 `cpu`,`FUNASR_MODEL` 默认为 `iic/SenseVoiceSmall`。 +[MCP 源码与容器说明](../examples/mcp_server/README.md) 提供客户端配置和文件挂载方法。 +控制助手及服务端可访问的文件;本地工具本身不是文件系统权限隔离机制。 + +## 桌面语音输入 + +保持 HTTP 服务运行,在已准备好的仓库内打开另一个终端: + +```bash +python -m pip install sounddevice numpy pyperclip openai pynput +python examples/voice_input/funasr_input.py --server http://localhost:8000/v1 --model sensevoice +``` + +脚本切换录音状态,将 WAV 上传至 HTTP 服务,再复制转写结果供粘贴。 +需要麦克风权限和音频设备支持;macOS 还可能需要辅助功能权限,Linux 自动粘贴使用 +`xdotool`,剪贴板及粘贴行为随桌面会话而异。当前 `--lang` 虽然被解析,却没有传入 +转写请求,因此在此路径中不是有效的语种控制项。 + +远程 `--server` 会把录音发送给该端点,不能无条件宣称完全离线、音频不离开本机或 +达到固定延迟。部署前请阅读[配置选项](../examples/voice_input/README.md#配置选项) +及[实现](../examples/voice_input/funasr_input.py)。 + +## 字幕生成 + +这是本地 `AutoModel` 流水线,不是 HTTP 或 MCP 客户端。 +在已准备好的仓库中,使用本地输入文件及适合的推理环境: + +```bash +python examples/subtitle/generate_subtitle.py video.mp4 +python examples/subtitle/generate_subtitle.py meeting.wav --spk +python examples/subtitle/generate_subtitle.py podcast.mp3 --format vtt +python examples/subtitle/generate_subtitle.py audio.wav --device cpu +``` + +默认设备为 CUDA,最后一条命令显式选择 CPU。默认模型为 SenseVoiceSmall,并使用 +VAD 和标点模型;这套固定流水线不是任意模型的通用配方。`--spk` 添加 CAM++ 匿名标签, +不验证真实身份。`--format` 选择 SRT/VTT,`--output` 指定输出路径,已有输出文件会被覆盖; +需要保留旧字幕时应指定新路径。`--lang` 将非 auto +语种提示传给推理。`--max-single-segment-time` 的单位是毫秒,当前默认 `60000`。 + +`--segment-mode readable` 对展示字幕分组,不改写识别文本或标点;`sentence` 保留 +原始模型句段分组。两种模式都不修复标点错误,也不保证音素级边界。 +应检查实际时间戳是否可用,并对照原音频回放;缺少时间信息时可能回退到零时长 `(0, 0)` +区间,这不是经过验证的字幕。输入解码、模型与依赖加载、GPU 容量 +仍需针对环境验证。输出语义见[字幕选项](../examples/subtitle/README.md#options) +及[说话人指南](speaker_emotion_zh.md)。 diff --git a/docs/index.rst b/docs/index.rst index d89e2a15b..91e660c49 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -69,6 +69,8 @@ entry to these guides. Source Markdown remains in this repository. :maxdepth: 1 :caption: Applications and Ecosystem + agent_integration + agent_integration_zh use_case_showcase use_case_showcase_zh community_projects diff --git a/web-pages/product-site/data/documentation.json b/web-pages/product-site/data/documentation.json index f20c63a90..4e5c503e6 100644 --- a/web-pages/product-site/data/documentation.json +++ b/web-pages/product-site/data/documentation.json @@ -34,6 +34,7 @@ {"slug": "security", "group": "operate", "zh": "服务安全边界", "en": "Service security boundaries", "source_zh": "examples/openai_api/SECURITY_zh.md", "source_en": "examples/openai_api/SECURITY.md"}, {"slug": "benchmark-method", "group": "operate", "zh": "可复现性能评测", "en": "Reproducible performance tests", "source_zh": "docs/benchmark/rtf_reproducibility.md", "source_en": "docs/benchmark/rtf_reproducibility.md"}, {"slug": "realtime-benchmark", "group": "operate", "zh": "实时 WebSocket 压测", "en": "Realtime WebSocket benchmarking", "source_zh": "docs/benchmark/realtime_ws_benchmark.md", "source_en": "docs/benchmark/realtime_ws_benchmark.md"}, + {"slug": "agent-integration", "group": "ecosystem", "zh": "Agent 集成", "en": "Agent integration", "source_zh": "docs/agent_integration_zh.md", "source_en": "docs/agent_integration.md"}, {"slug": "use-cases", "group": "ecosystem", "zh": "应用场景与示例", "en": "Use cases & examples", "source_zh": "docs/use_case_showcase_zh.md", "source_en": "docs/use_case_showcase.md"}, {"slug": "community", "group": "ecosystem", "zh": "社区集成", "en": "Community integrations", "source_zh": "docs/community_projects_zh.md", "source_en": "docs/community_projects.md"}, {"slug": "repositories", "group": "ecosystem", "zh": "四仓职责与贡献入口", "en": "Repositories & contribution paths", "source_zh": "docs/repository_roles_zh.md", "source_en": "docs/repository_roles.md"}, diff --git a/web-pages/product-site/data/legacy_doc_anchors.json b/web-pages/product-site/data/legacy_doc_anchors.json index da22d3982..a0bc12711 100644 --- a/web-pages/product-site/data/legacy_doc_anchors.json +++ b/web-pages/product-site/data/legacy_doc_anchors.json @@ -140,6 +140,22 @@ "checklist": "readiness-checklist", "help": "when-to-open-an-issue" }, + "agent.html": { + "server": "http-server", + "sdk": "sdk-and-curl", + "workflows": "workflow-integrations", + "mcp": "mcp-server", + "voice": "desktop-voice-input", + "subtitle": "subtitle-generation" + }, + "zh/agent.html": { + "server": "http-服务", + "sdk": "sdk-与-curl", + "workflows": "工作流集成", + "mcp": "mcp-服务", + "voice": "桌面语音输入", + "subtitle": "字幕生成" + }, "zh/deployment-matrix.html": { "matrix": "快速决策表", "choices": "常见选择", diff --git a/web-pages/product-site/export_docs.py b/web-pages/product-site/export_docs.py index 5ee7f42e3..83a78ad14 100644 --- a/web-pages/product-site/export_docs.py +++ b/web-pages/product-site/export_docs.py @@ -17,6 +17,7 @@ ALIASES = { 'moss-transcribe-diarize': 'moss-transcribe-diarize.html', 'vllm': 'vllm.html', 'deployment-matrix': 'deployment-matrix.html', + 'agent-integration': 'agent.html', } diff --git a/web-pages/product-site/tests/browser/documentation.spec.ts b/web-pages/product-site/tests/browser/documentation.spec.ts index 00a41df63..593d1e72f 100644 --- a/web-pages/product-site/tests/browser/documentation.spec.ts +++ b/web-pages/product-site/tests/browser/documentation.spec.ts @@ -92,3 +92,37 @@ test('public speech sample is playable and waveform has actual pixels', async ({ await page.locator('audio').evaluate((audio: HTMLAudioElement) => audio.pause()); expect(await page.locator('.hero-image').evaluate((image: HTMLImageElement) => image.naturalWidth)).toBeGreaterThan(1000); }); + +for (const prefix of ['', '/en']) { + for (const width of [390, 1440]) { + test(`Agent integration is discoverable and readable ${prefix || '/zh'} ${width}`, async ({ page, context }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + await page.setViewportSize({ width, height: 900 }); + const errors: string[] = []; + page.on('pageerror', error => errors.push(error.message)); + await page.goto(`${prefix}/docs/`); + await page.locator('[data-doc-search] input').fill('Agent'); + const route = `${prefix}/docs/agent-integration.html`; + await page.locator(`.search-results a[href="${route}"]`).click(); + await expect(page).toHaveURL(new RegExp(`${route}$`)); + await expect(page.locator('[data-source-link]')).toHaveAttribute('href', + new RegExp(`/docs/agent_integration${prefix ? '' : '_zh'}.md$`)); + await expect(page.locator('.docs-article h2')).toHaveCount(6); + const block = page.locator('.docs-article pre').first(); + const expected = await block.locator('code').innerText(); + await block.locator('button').click(); + expect((await page.evaluate(() => navigator.clipboard.readText())).trim()).toBe(expected.trim()); + for (const heading of await page.locator('.docs-article h2').all()) { + await heading.scrollIntoViewIfNeeded(); + expect(await page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1); + } + const token = page.locator('.docs-article p code').filter({ hasText: 'verbose_json' }).first(); + await expect(token).toBeVisible(); + expect((await token.boundingBox())!.height).toBeLessThan(40); + const peer = `${prefix ? '' : '/en'}/docs/agent-integration.html`; + await page.locator(`.docs-article a[href="${peer}"]`).click(); + await expect(page).toHaveURL(new RegExp(`${peer}$`)); + expect(errors).toEqual([]); + }); + } +} diff --git a/web-pages/product-site/tests/test_documentation.py b/web-pages/product-site/tests/test_documentation.py index 141f70c50..6f6433e0b 100644 --- a/web-pages/product-site/tests/test_documentation.py +++ b/web-pages/product-site/tests/test_documentation.py @@ -1,7 +1,10 @@ from pathlib import Path +import ast import json +import re +import shlex import sys -from urllib.parse import unquote +from urllib.parse import unquote, urlsplit from bs4 import BeautifulSoup import pytest @@ -121,3 +124,88 @@ def test_github_pages_hubs_have_task_navigation_and_search_handoff(): assert soup.select_one('a[href$="moss-transcribe-diarize.html"]') assert soup.select_one('a[href$="api.html"]') assert '170x' not in soup.get_text() + + +@pytest.mark.parametrize('language, prefix, suffix, peer', [ + ('zh', '', '_zh', '/en/docs/agent-integration.html'), + ('en', 'en/', '', '/docs/agent-integration.html'), +]) +def test_agent_guide_has_shared_sources_discovery_and_six_sections(output, language, prefix, suffix, peer): + route = '/' + prefix + 'docs/agent-integration.html' + path = output / route.lstrip('/') + assert path.is_file(), route + page = BeautifulSoup(path.read_text(), 'html.parser') + article = page.select_one('.docs-article') + assert len(article.select('h2')) == 6 + assert page.select_one('[data-source-link]')['href'].endswith(f'/docs/agent_integration{suffix}.md') + links = {a['href'] for a in article.select('a[href]')} + assert peer in links + routes = {urlsplit(link)._replace(fragment='').geturl() for link in links} + for slug in ('http-server', 'workflows', 'javascript', 'speaker-emotion', 'model-selection', 'security'): + assert '/' + prefix + 'docs/' + slug + '.html' in routes + index = BeautifulSoup((output / prefix / 'docs/index.html').read_text(), 'html.parser') + assert index.select_one(f'a[href="{route}"]') + search = json.loads((output / f'search-{language}.json').read_text()) + assert any(row['url'] == route and 'Agent' in row['title'] for row in search) + text = article.get_text(' ', strip=True) + for marker in ('127.0.0.1', '/v1/audio/transcriptions', '/v1/models', '/health', + 'verbose_json', 'transcribe_audio', 'FUNASR_DEVICE', + 'FUNASR_MODEL', 'sentence_info', '--lang', '--segment-mode', '60000'): + assert marker in text, (language, marker) + assert '31-language LLM-based ASR with timestamps' not in text + assert '31 语种 LLM-based ASR,支持时间戳' not in text + + +def test_agent_recipes_are_bilingual_and_use_existing_script_options(): + root = SITE_ROOT.parents[1] + recipes = [] + for suffix in ('', '_zh'): + path = root / f'docs/agent_integration{suffix}.md' + assert path.is_file(), path + source = path.read_text() + blocks = re.findall(r'^```(\w+)\n(.*?)^```', source, re.M | re.S) + commands = [] + structured = [] + for language, code in blocks: + if language == 'python': + tree = ast.parse(code) + compile(tree, str(path), 'exec') + assert any(isinstance(node, ast.With) for node in ast.walk(tree)) + structured.append((language, ast.dump(tree))) + elif language == 'json': + data = json.loads(code) + structured.append((language, data)) + config = data['mcpServers']['funasr'] + assert config['command'].endswith('/bin/python') + assert config['args'][0].endswith('/examples/mcp_server/funasr_mcp.py') + assert config['env']['FUNASR_DEVICE'] == 'cpu' + elif language == 'bash': + commands.extend(tokens for line in code.replace('\\\n', ' ').splitlines() + if (tokens := shlex.split(line, comments=True))) + recipes.append({'shell': commands, 'structured': structured}) + scripts = [] + for command in commands: + if command[0] == 'funasr-server': + assert command[command.index('--host') + 1] == '127.0.0.1' + assert command[command.index('--model') + 1] == 'sensevoice' + script = root / 'funasr/bin/server.py' + elif command[0] == 'python' and command[1].startswith('examples/'): + script = root / command[1] + scripts.append(command[1]) + else: + continue + assert script.is_file(), script + tree = ast.parse(script.read_text()) + options = {arg.value for node in ast.walk(tree) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) + and node.func.attr == 'add_argument' for arg in node.args + if isinstance(arg, ast.Constant) and isinstance(arg.value, str)} + for token in command[1:]: + if token.startswith('--'): + assert token in options, (script, token) + if script.name == 'funasr_input.py': + assert '--lang' not in command + assert set(scripts) == {'examples/mcp_server/funasr_mcp.py', + 'examples/voice_input/funasr_input.py', + 'examples/subtitle/generate_subtitle.py'} + assert recipes[0] == recipes[1] diff --git a/web-pages/product-site/tests/test_legacy_doc_anchors.py b/web-pages/product-site/tests/test_legacy_doc_anchors.py index be83abb54..56ce76686 100644 --- a/web-pages/product-site/tests/test_legacy_doc_anchors.py +++ b/web-pages/product-site/tests/test_legacy_doc_anchors.py @@ -19,6 +19,8 @@ OLD_IDS = { 'moss-transcribe-diarize.html': 'contract server runtimes evidence boundaries', 'vllm.html': 'overview install offline streaming-sdk websocket dynamic-vad performance api-reference faq', 'deployment-matrix.html': 'matrix choices runtime-diagnostics checklist help', + # Independently inventoried from public EN/ZH pages on 2026-09-07. + 'agent.html': 'server sdk workflows mcp voice subtitle', } PAGES = [prefix + name for name in OLD_IDS for prefix in ('', 'zh/')] @@ -138,6 +140,17 @@ def test_insertion_is_idempotent_and_keeps_existing_anchors(): assert soup.find(id='existing').name == 'a' +@pytest.mark.parametrize('page, targets', [ + ('agent.html', ['http-server', 'sdk-and-curl', 'workflow-integrations', + 'mcp-server', 'desktop-voice-input', 'subtitle-generation']), + ('zh/agent.html', ['http-服务', 'sdk-与-curl', '工作流集成', + 'mcp-服务', '桌面语音输入', '字幕生成']), +]) +def test_agent_legacy_fragments_keep_their_topics(page, targets): + pages = json.loads((SITE / 'data/legacy_doc_anchors.json').read_text())['pages'] + assert pages[page] == dict(zip('server sdk workflows mcp voice subtitle'.split(), targets)) + + @pytest.mark.parametrize('heading', ('

Topic

', '

Not a heading

')) def test_missing_or_nonheading_target_fails_explicitly(heading): from export_docs import insert_legacy_anchors